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 committed by Tim Abbott
parent 9cedf0e8bc
commit d469d37d14
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 * as channel from "./channel";
import * as narrow from "./narrow";
import * as people from "./people";
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 {current_user} from "./state_data";
import {parse_html} from "./ui_util";
import {user_settings} from "./user_settings";
import * as user_status from "./user_status";
export function initialize() {
@ -40,6 +42,26 @@ export function initialize() {
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) => {
e.preventDefault();
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 {page_params} from "./page_params";
import * as people from "./people";
import * as settings_config from "./settings_config";
import * as settings_data from "./settings_data";
import * as starred_messages from "./starred_messages";
import {current_user, realm} from "./state_data";
@ -185,6 +186,10 @@ export function get_personal_menu_content_context() {
status_text,
status_emoji_info,
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

@ -1179,3 +1179,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%;
/* Gap between tabs in the tab picker */
--grid-gap-tab-picker: 2px;
/* Colors used across the app */
--color-date: hsl(0deg 0% 15% / 75%);
--color-background-private-message-header: hsl(46deg 35% 93%);
@ -195,6 +198,7 @@
--color-background-tab-picker-selected-tab: hsl(0deg 0% 100%);
--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-active: hsl(0deg 0% 100% / 35%);
--color-background-popover: hsl(0deg 0% 100%);
--color-background-alert-word: hsl(18deg 100% 84%);
--color-buddy-list-highlighted-user: hsl(120deg 12.3% 71.4% / 38%);
@ -500,6 +504,7 @@
--color-background-tab-picker-selected-tab: hsl(0deg 0% 100% / 7%);
--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-active: hsl(0deg 0% 100% / 3%);
--color-background-alert-word: hsl(22deg 70% 35%);
--color-buddy-list-highlighted-user: hsl(136deg 25% 73% / 20%);
--color-border-sidebar: hsl(0deg 0% 0% / 20%);

View File

@ -1481,3 +1481,14 @@ ul.navbar-dropdown-menu-outer-list {
border: 1px solid var(--color-hotkey-hint);
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}}
</ul>
</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">
<ul class="navbar-dropdown-menu-inner-list">
<li class="link-item navbar-dropdown-menu-inner-list-item">

View File

@ -85,6 +85,12 @@
{{t 'Help menu' }}
{{tooltip_hotkey_hints "G" "←"}}
</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">
<div class="views-tooltip-container" data-view-code="all_messages">
<div>{{t 'All messages' }}</div>