personal_menu: Add theme switcher to personal menu popover.

This commit introduces a theme switcher feature within the personal
menu popover. The implementation begins with the development of a
tab picker, which has the following features:

* Utilization of radio buttons to emulate the tab picker.
    Radio input buttons provides the native way in HTML to select one
    value out of a set.

* Support for both horizontal (default) and vertical orientations.
    Vertical orientation can be achieved by appending the
    `.tab-picker-vertical` class.

* Respects the `prefers-reduced-motion` option set by the user.
    Disables the sliding tab animation to improve accessibility.

Additionally, the theme switcher component incorporates error handling
mechanisms. In the event of a server/network error, the tab slider
reverts to the previous theme option after a delay of 500ms. This
behavior visually communicates the occurrence of an error to the user,
improving the UX.

Fixes: #22803.
This commit is contained in:
Sayam Samal 2024-03-09 03:24:17 +05:30
parent f4d58f1ba6
commit ad013a94b3
7 changed files with 199 additions and 0 deletions

View File

@ -3,6 +3,7 @@ import tippy from "tippy.js";
import render_personal_menu from "../templates/personal_menu.hbs"; import render_personal_menu from "../templates/personal_menu.hbs";
import * as channel from "./channel";
import * as narrow from "./narrow"; import * as narrow from "./narrow";
import * as people from "./people"; import * as people from "./people";
import * as popover_menus from "./popover_menus"; import * as popover_menus from "./popover_menus";
@ -10,6 +11,7 @@ import * as popover_menus_data from "./popover_menus_data";
import * as popovers from "./popovers"; import * as popovers from "./popovers";
import {current_user} from "./state_data"; import {current_user} from "./state_data";
import {parse_html} from "./ui_util"; import {parse_html} from "./ui_util";
import {user_settings} from "./user_settings";
import * as user_status from "./user_status"; import * as user_status from "./user_status";
export function initialize() { export function initialize() {
@ -40,6 +42,26 @@ export function initialize() {
appendTo: document.body, appendTo: document.body,
}); });
$popper.on("change", "input[name='theme-select']", (e) => {
const new_theme_code = $(e.currentTarget).attr("data-theme-code");
channel.patch({
url: "/json/settings",
data: {color_scheme: new_theme_code},
error() {
// NOTE: The additional delay allows us to visually communicate
// that an error occurred due to which we are reverting back
// to the previously used value.
setTimeout(() => {
const prev_theme_code = user_settings.color_scheme;
$(e.currentTarget)
.parent()
.find(`input[data-theme-code="${prev_theme_code}"]`)
.prop("checked", true);
}, 500);
},
});
});
$popper.one("click", ".personal-menu-clear-status", (e) => { $popper.one("click", ".personal-menu-clear-status", (e) => {
e.preventDefault(); e.preventDefault();
const me = current_user.user_id; const me = current_user.user_id;

View File

@ -14,6 +14,7 @@ import * as message_lists from "./message_lists";
import * as muted_users from "./muted_users"; import * as muted_users from "./muted_users";
import {page_params} from "./page_params"; import {page_params} from "./page_params";
import * as people from "./people"; import * as people from "./people";
import * as settings_config from "./settings_config";
import * as settings_data from "./settings_data"; import * as settings_data from "./settings_data";
import * as starred_messages from "./starred_messages"; import * as starred_messages from "./starred_messages";
import {current_user, realm} from "./state_data"; import {current_user, realm} from "./state_data";
@ -185,6 +186,10 @@ export function get_personal_menu_content_context() {
status_text, status_text,
status_emoji_info, status_emoji_info,
user_time: people.get_user_time(my_user_id), user_time: people.get_user_time(my_user_id),
// user color scheme
user_color_scheme: user_settings.color_scheme,
color_scheme_values: settings_config.color_scheme_values,
}; };
} }

View File

@ -1195,3 +1195,132 @@ div.overlay {
} }
} }
} }
/**
* Use the "tab-picker-vertical" class in conjunction
* for a vertical tab picker.
*/
.tab-picker {
position: relative;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: var(--grid-gap-tab-picker);
box-sizing: border-box;
border-radius: 5px;
/* Using max-content ensures equal width tabs. */
min-width: max-content;
padding: 1px;
background-color: var(--color-background-tab-picker-container);
font-size: 16px;
input[type="radio"] {
display: none;
}
.tab-option-content {
z-index: 2;
display: flex;
justify-content: center;
align-items: center;
/* Avoids layout shift while showing a border when pressed. */
border: 1px solid transparent;
border-radius: 4px;
box-sizing: border-box;
padding: 3px 7px;
color: var(--color-text-dropdown-menu);
& .zulip-icon {
/* Clear inherited position. */
position: static;
color: var(--color-icon-purple);
}
}
.tab-option:not(:checked) + .tab-option-content {
&:hover,
&:focus-visible {
background-color: var(
--color-background-tab-picker-tab-option-hover
);
}
&:active {
background-color: var(
--color-background-tab-picker-tab-option-active
);
}
}
.tab-option {
&:nth-of-type(1):checked ~ .slider {
--tab-selected-tab-picker: 0;
}
&:nth-of-type(2):checked ~ .slider {
--tab-selected-tab-picker: 1;
}
&:nth-of-type(3):checked ~ .slider {
--tab-selected-tab-picker: 2;
}
&:nth-of-type(4):checked ~ .slider {
--tab-selected-tab-picker: 3;
}
&:nth-of-type(5):checked ~ .slider {
--tab-selected-tab-picker: 4;
}
/* If a tab picker with more than 5 tabs is required,
extend this using the same pattern as above. */
}
.slider {
position: absolute;
z-index: 1;
inset: 0;
grid-column: 1 / 2;
grid-row: 1 / 2;
/* Move along X-axis: own width + gap between two tabs. */
transform: translateX(
calc(
(100% + var(--grid-gap-tab-picker)) *
var(--tab-selected-tab-picker)
)
);
transition: transform 0.25s cubic-bezier(0.64, 0, 0.78, 0);
box-sizing: border-box;
border: 1px solid var(--color-outline-tab-picker-tab-option);
border-radius: 4px;
background-color: var(--color-background-tab-picker-selected-tab);
}
/* Detect if a user has enabled a setting on their device
to minimize the amount of non-essential motion. */
@media (prefers-reduced-motion) {
.slider {
transition: none;
}
.tab-option:not(:checked) + .tab-option-content:active {
border-color: var(--color-outline-tab-picker-tab-option);
}
}
/* Related to vertical tab picker. */
&.tab-picker-vertical {
grid-auto-flow: row;
grid-auto-rows: 1fr;
.slider {
/* Move along Y-axis: own height + gap between two tabs. */
transform: translateY(
calc(
(100% + var(--grid-gap-tab-picker)) *
var(--tab-selected-tab-picker)
)
);
}
}
}

View File

@ -140,6 +140,9 @@
*/ */
--stream-subscriber-list-max-height: 100%; --stream-subscriber-list-max-height: 100%;
/* Gap between tabs in the tab picker */
--grid-gap-tab-picker: 2px;
/* Colors used across the app */ /* Colors used across the app */
--color-date: hsl(0deg 0% 15% / 75%); --color-date: hsl(0deg 0% 15% / 75%);
--color-background-private-message-header: hsl(46deg 35% 93%); --color-background-private-message-header: hsl(46deg 35% 93%);
@ -195,6 +198,7 @@
--color-background-tab-picker-selected-tab: hsl(0deg 0% 100%); --color-background-tab-picker-selected-tab: hsl(0deg 0% 100%);
--color-outline-tab-picker-tab-option: hsl(0deg 0% 0% / 30%); --color-outline-tab-picker-tab-option: hsl(0deg 0% 0% / 30%);
--color-background-tab-picker-tab-option-hover: hsl(0deg 0% 100% / 60%); --color-background-tab-picker-tab-option-hover: hsl(0deg 0% 100% / 60%);
--color-background-tab-picker-tab-option-active: hsl(0deg 0% 100% / 35%);
--color-background-popover: hsl(0deg 0% 100%); --color-background-popover: hsl(0deg 0% 100%);
--color-background-alert-word: hsl(18deg 100% 84%); --color-background-alert-word: hsl(18deg 100% 84%);
--color-buddy-list-highlighted-user: hsl(120deg 12.3% 71.4% / 38%); --color-buddy-list-highlighted-user: hsl(120deg 12.3% 71.4% / 38%);
@ -497,6 +501,7 @@
--color-background-tab-picker-selected-tab: hsl(0deg 0% 100% / 7%); --color-background-tab-picker-selected-tab: hsl(0deg 0% 100% / 7%);
--color-outline-tab-picker-tab-option: hsl(0deg 0% 100% / 12%); --color-outline-tab-picker-tab-option: hsl(0deg 0% 100% / 12%);
--color-background-tab-picker-tab-option-hover: hsl(0deg 0% 100% / 5%); --color-background-tab-picker-tab-option-hover: hsl(0deg 0% 100% / 5%);
--color-background-tab-picker-tab-option-active: hsl(0deg 0% 100% / 3%);
--color-background-alert-word: hsl(22deg 70% 35%); --color-background-alert-word: hsl(22deg 70% 35%);
--color-buddy-list-highlighted-user: hsl(136deg 25% 73% / 20%); --color-buddy-list-highlighted-user: hsl(136deg 25% 73% / 20%);

View File

@ -1488,3 +1488,14 @@ ul.navbar-dropdown-menu-outer-list {
border: 1px solid var(--color-hotkey-hint); border: 1px solid var(--color-hotkey-hint);
margin-left: auto; margin-left: auto;
} }
#theme-switcher {
margin: 0 10px;
.tab-option-content:focus-visible {
border-radius: 4px;
/* Override the default focus style */
outline: 1px solid var(--color-outline-focus) !important;
outline-offset: -1px;
}
}

View File

@ -84,6 +84,27 @@
{{/if}} {{/if}}
</ul> </ul>
</li> </li>
<li class="navbar-dropdown-menu-outer-list-item">
<ul class="navbar-dropdown-menu-inner-list">
<li class="navbar-dropdown-menu-inner-list-item">
<div id="theme-switcher" class="tab-picker">
<input type="radio" id="select-automatic-theme" class="tab-option" name="theme-select" data-theme-code="{{color_scheme_values.automatic.code}}" {{#if (eq user_color_scheme color_scheme_values.automatic.code)}}checked{{/if}} />
<label class="tab-option-content tippy-zulip-delayed-tooltip" for="select-automatic-theme" aria-label="{{t 'Select automatic theme' }}" data-tooltip-template-id="automatic-theme-template" tabindex="0">
<i class="zulip-icon zulip-icon-monitor" aria-hidden="true"></i>
</label>
<input type="radio" id="select-light-theme" class="tab-option" name="theme-select" data-theme-code="{{color_scheme_values.day.code}}" {{#if (eq user_color_scheme color_scheme_values.day.code)}}checked{{/if}} />
<label class="tab-option-content tippy-zulip-delayed-tooltip" for="select-light-theme" aria-label="{{t 'Select light theme' }}" data-tippy-content="{{t 'Light theme' }}" tabindex="0">
<i class="zulip-icon zulip-icon-sun" aria-hidden="true"></i>
</label>
<input type="radio" id="select-dark-theme" class="tab-option" name="theme-select" data-theme-code="{{color_scheme_values.night.code}}" {{#if (eq user_color_scheme color_scheme_values.night.code)}}checked{{/if}} />
<label class="tab-option-content tippy-zulip-delayed-tooltip" for="select-dark-theme" aria-label="{{t 'Select dark theme' }}" data-tippy-content="{{t 'Dark theme' }}" tabindex="0">
<i class="zulip-icon zulip-icon-moon" aria-hidden="true"></i>
</label>
<span class="slider"></span>
</div>
</li>
</ul>
</li>
<li class="navbar-dropdown-menu-outer-list-item"> <li class="navbar-dropdown-menu-outer-list-item">
<ul class="navbar-dropdown-menu-inner-list"> <ul class="navbar-dropdown-menu-inner-list">
<li class="link-item navbar-dropdown-menu-inner-list-item"> <li class="link-item navbar-dropdown-menu-inner-list-item">

View File

@ -85,6 +85,12 @@
{{t 'Help menu' }} {{t 'Help menu' }}
{{tooltip_hotkey_hints "G" "←"}} {{tooltip_hotkey_hints "G" "←"}}
</template> </template>
<template id="automatic-theme-template">
<div>
<div>{{t "Automatic theme" }}</div>
<div class="tooltip-inner-content italic">{{t "Follows system settings." }}</div>
</div>
</template>
<template id="all-message-tooltip-template"> <template id="all-message-tooltip-template">
<div class="views-tooltip-container" data-view-code="all_messages"> <div class="views-tooltip-container" data-view-code="all_messages">
<div>{{t 'All messages' }}</div> <div>{{t 'All messages' }}</div>