From 168c0a52e42931cfc9ef05875e85653b7936c732 Mon Sep 17 00:00:00 2001 From: Elliott Marquez <5981958+e111077@users.noreply.github.com> Date: Tue, 2 Apr 2024 13:05:35 -0700 Subject: [PATCH] theme-switcher is now a select (#1320) --- .../components/litdev-ripple-icon-button.ts | 30 ++- .../src/components/theme-switcher.ts | 235 ++++++++++++++++-- .../src/icons/auto-mode-icon.ts | 5 +- .../lit-dev-content/src/icons/check-icon.ts | 18 ++ .../src/icons/dark-mode-icon.ts | 5 +- .../src/icons/light-mode-icon.ts | 5 +- 6 files changed, 266 insertions(+), 32 deletions(-) create mode 100644 packages/lit-dev-content/src/icons/check-icon.ts diff --git a/packages/lit-dev-content/src/components/litdev-ripple-icon-button.ts b/packages/lit-dev-content/src/components/litdev-ripple-icon-button.ts index 572242075..5fa7b0a27 100644 --- a/packages/lit-dev-content/src/components/litdev-ripple-icon-button.ts +++ b/packages/lit-dev-content/src/components/litdev-ripple-icon-button.ts @@ -27,6 +27,30 @@ export class LitDevRippleIconButton extends LitElement { @property({attribute: 'button-title'}) buttonTitle = ''; + /** + * Aria haspopup for the button. + */ + @property() + haspopup = ''; + + /** + * Aria expanded for the button. + */ + @property() + expanded = ''; + + /** + * Aria controls for the button. + */ + @property() + controls = ''; + + /** + * Sets the role for the inner button. + */ + @property({attribute: 'button-role'}) + buttonRole = ''; + /** * Href for the link button. If defined, this component switches to using an * anchor element instead of a button. @@ -140,8 +164,12 @@ export class LitDevRippleIconButton extends LitElement { <button class="root" part="root button" + role=${this.buttonRole ? this.buttonRole : nothing} aria-live=${this.live ? this.live : nothing} aria-label=${this.label ? this.label : nothing} + aria-haspopup=${this.haspopup ? this.haspopup : nothing} + aria-expanded=${this.expanded ? this.expanded : nothing} + aria-controls=${this.controls ? this.controls : nothing} ?disabled=${this.disabled} title=${this.buttonTitle ?? (nothing as unknown as string)} > @@ -165,7 +193,7 @@ export class LitDevRippleIconButton extends LitElement { } protected renderContent() { - return html` <div id="ripple"></div> + return html`<div id="ripple"></div> <slot></slot>`; } } diff --git a/packages/lit-dev-content/src/components/theme-switcher.ts b/packages/lit-dev-content/src/components/theme-switcher.ts index c48b1d40c..097eca920 100644 --- a/packages/lit-dev-content/src/components/theme-switcher.ts +++ b/packages/lit-dev-content/src/components/theme-switcher.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {LitElement, html, isServer} from 'lit'; -import {property, customElement} from 'lit/decorators.js'; +import {LitElement, html, isServer, css, PropertyValues} from 'lit'; +import {property, customElement, state} from 'lit/decorators.js'; import { getCurrentMode, applyColorThemeListeners, @@ -14,7 +14,12 @@ import { import {autoModeIcon} from '../icons/auto-mode-icon.js'; import {darkModeIcon} from '../icons/dark-mode-icon.js'; import {lightModeIcon} from '../icons/light-mode-icon.js'; +import {checkIcon} from '../icons/check-icon.js'; import './litdev-ripple-icon-button.js'; +import '@material/web/menu/menu.js'; +import '@material/web/menu/menu-item.js'; +import {FocusState, type CloseMenuEvent} from '@material/web/menu/menu.js'; +import {isElementInSubtree} from '@material/web/menu/internal/controllers/shared.js'; applyColorThemeListeners(); @@ -31,7 +36,7 @@ const Modes = { }, auto: { icon: autoModeIcon, - label: 'Match system mode', + label: 'System', title: 'Color Mode Toggle (system)', }, } as const; @@ -44,40 +49,220 @@ export class ThemeSwitcher extends LitElement { @property() mode: ColorMode = 'auto'; + @state() + menuOpen = false; + + @state() + defaultFocus: FocusState = FocusState.NONE; + render() { const modeOptions = Modes[this.mode]; - return html`<litdev-ripple-icon-button - @click=${this._click} - live="assertive" - .label=${modeOptions.label} - .buttonTitle=${modeOptions.title} - > - ${modeOptions.icon} - </litdev-ripple-icon-button>`; + return html` + <litdev-ripple-icon-button + @click=${() => { + this.menuOpen = !this.menuOpen; + }} + @keydown=${this._handleKeydown} + label="Theme selector" + haspopup="listbox" + .expanded=${this.menuOpen ? 'true' : 'false'} + controls="menu" + button-role="combobox" + .buttonTitle=${modeOptions.title} + id="button" + > + <span aria-label=${modeOptions.label}>${modeOptions.icon()}</span> + </litdev-ripple-icon-button> + <md-menu + id="menu" + anchor="button" + tabindex="-1" + role="listbox" + stay-open-on-focusout + .open=${this.menuOpen} + .defaultFocus=${this.defaultFocus} + @opening=${() => { + this.shadowRoot + ?.querySelector?.('#button span') + ?.removeAttribute?.('aria-live'); + }} + @opened=${() => { + if (this.defaultFocus !== FocusState.NONE) { + return; + } + + ( + this.shadowRoot?.querySelector?.( + 'md-menu-item[selected]' + ) as HTMLElement + )?.focus?.(); + }} + @closed=${() => { + this.menuOpen = false; + }} + @close-menu=${this._onCloseMenu} + > + ${Object.keys(Modes).map( + (mode) => html` + <md-menu-item + aria-selected=${mode === this.mode ? 'true' : 'false'} + ?selected=${mode === this.mode} + data-mode=${mode} + > + <span slot="headline">${Modes[mode as ColorMode].label}</span> + ${Modes[mode as ColorMode].icon('start')} + ${mode === this.mode + ? checkIcon('end') + : html`<span slot="end"></span>`} + </md-menu-item> + ` + )} + </md-menu> + `; + } + + update(changed: PropertyValues<this>) { + if (changed.get('mode')) { + this.dispatchEvent(new ChangeColorModeEvent(this.mode)); + } + + super.update(changed); } firstUpdated() { this.mode = getCurrentMode(); + this.addEventListener('focusout', this._focusout); } - private _click() { - let nextMode!: ColorMode; - - switch (this.mode) { - case 'auto': - nextMode = 'dark'; - break; - case 'dark': - nextMode = 'light'; - break; - case 'light': - nextMode = 'auto'; - break; + private _onCloseMenu(e: CloseMenuEvent) { + const nextMode = e.detail.itemPath[0]?.dataset?.mode as + | ColorMode + | undefined; + if (!nextMode) { + return; } - this.dispatchEvent(new ChangeColorModeEvent(nextMode)); this.mode = nextMode; } + + /** + * Handles opening the select on keydown and typahead selection when the menu + * is closed. Taken from md-select's implementation. + */ + private _handleKeydown(event: KeyboardEvent) { + const menu = this.shadowRoot?.querySelector?.('md-menu'); + + if (this.menuOpen || !menu) { + return; + } + + const typeaheadController = menu.typeaheadController; + const isOpenKey = + event.code === 'Space' || + event.code === 'ArrowDown' || + event.code === 'ArrowUp' || + event.code === 'End' || + event.code === 'Home' || + event.code === 'Enter'; + + // Do not open if currently typing ahead because the user may be typing the + // spacebar to match a word with a space + if (!typeaheadController.isTypingAhead && isOpenKey) { + event.preventDefault(); + this.menuOpen = true; + + // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/#kbd_label + switch (event.code) { + case 'Space': + case 'ArrowDown': + case 'Enter': + // We will handle focusing last selected item in this.handleOpening() + this.defaultFocus = FocusState.NONE; + break; + case 'End': + this.defaultFocus = FocusState.LAST_ITEM; + break; + case 'ArrowUp': + case 'Home': + this.defaultFocus = FocusState.FIRST_ITEM; + break; + default: + break; + } + return; + } + + const isPrintableKey = event.key.length === 1; + + // Handles typing ahead when the menu is closed by delegating the event to + // the underlying menu's typeaheadController + if (isPrintableKey) { + typeaheadController.onKeydown(event); + event.preventDefault(); + + const {lastActiveRecord} = typeaheadController; + + if (!lastActiveRecord) { + return; + } + + const labelEl = this.shadowRoot?.querySelector?.('#button span'); + + labelEl?.setAttribute?.('aria-live', 'polite'); + this.mode = lastActiveRecord[1].dataset.mode as ColorMode; + } + } + + /** + * Handles closing the menu when the focus leaves the select's subtree. + * Taken from md-select's implementation. + */ + private _focusout(event: FocusEvent) { + // Don't close the menu if we are switching focus between menu, + // select-option, and field + if (event.relatedTarget && isElementInSubtree(event.relatedTarget, this)) { + return; + } + + this.menuOpen = false; + } + + static override styles = css` + :host { + position: relative; + --md-sys-color-primary: var(--sys-color-primary); + --md-sys-color-surface: var(--sys-color-surface); + --md-sys-color-on-surface: var(--sys-color-on-surface); + --md-sys-color-on-surface-variant: var(--sys-color-on-surface-variant); + --md-sys-color-surface-container: var(--sys-color-surface-container); + --md-sys-color-secondary-container: var(--sys-color-primary-container); + --md-sys-color-on-secondary-container: var( + --sys-color-on-primary-container + ); + --md-focus-ring-color: var(--sys-color-secondary); + } + + md-menu-item[selected] { + --md-focus-ring-color: var(--sys-color-secondary-container); + } + + md-menu { + min-width: 208px; + } + + #button > span { + display: flex; + } + + [slot='end'] { + width: 24px; + height: 24px; + } + + [slot='headline'] { + font-family: Manrope, sans-serif; + } + `; } if (isServer) { diff --git a/packages/lit-dev-content/src/icons/auto-mode-icon.ts b/packages/lit-dev-content/src/icons/auto-mode-icon.ts index bc2c5cdd8..ea68f38ac 100644 --- a/packages/lit-dev-content/src/icons/auto-mode-icon.ts +++ b/packages/lit-dev-content/src/icons/auto-mode-icon.ts @@ -4,16 +4,17 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {html} from 'lit'; +import {html, nothing} from 'lit'; // Source: https://fonts.google.com/icons?selected=Material+Symbols+Outlined:routine:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=routine -export const autoModeIcon = html` +export const autoModeIcon = (slot = '') => html` <svg fill="currentColor" width="24" height="24" aria-hidden="true" viewBox="0 -960 960 960" + slot=${slot || nothing} > <path d="M396-396q-32-32-58.5-67T289-537q-5 14-6.5 28.5T281-480q0 83 58 141t141 58q14 0 28.5-2t28.5-6q-39-22-74-48.5T396-396Zm57-56q51 51 114 87.5T702-308q-40 51-98 79.5T481-200q-117 0-198.5-81.5T201-480q0-65 28.5-123t79.5-98q20 72 56.5 135T453-452Zm290 72q-20-5-39.5-11T665-405q8-18 11.5-36.5T680-480q0-83-58.5-141.5T480-680q-20 0-38.5 3.5T405-665q-8-19-13.5-38T381-742q24-9 49-13.5t51-4.5q117 0 198.5 81.5T761-480q0 26-4.5 51T743-380ZM440-840v-120h80v120h-80Zm0 840v-120h80V0h-80Zm323-706-57-57 85-84 57 56-85 85ZM169-113l-57-56 85-85 57 57-85 84Zm671-327v-80h120v80H840ZM0-440v-80h120v80H0Zm791 328-85-85 57-57 84 85-56 57ZM197-706l-84-85 56-57 85 85-57 57Zm199 310Z" diff --git a/packages/lit-dev-content/src/icons/check-icon.ts b/packages/lit-dev-content/src/icons/check-icon.ts new file mode 100644 index 000000000..4b68dd894 --- /dev/null +++ b/packages/lit-dev-content/src/icons/check-icon.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {html, nothing} from 'lit'; + +// Source: https://fonts.google.com/icons?selected=Material+Icons+Outlined:check_circle +export const checkIcon = (slot = '') => html`<svg + height="24px" + viewBox="0 0 24 24" + width="24px" + fill="currentcolor" + slot=${slot || nothing} +> + <path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z" /> +</svg>`; diff --git a/packages/lit-dev-content/src/icons/dark-mode-icon.ts b/packages/lit-dev-content/src/icons/dark-mode-icon.ts index 9bb9a43b1..e51e7cff3 100644 --- a/packages/lit-dev-content/src/icons/dark-mode-icon.ts +++ b/packages/lit-dev-content/src/icons/dark-mode-icon.ts @@ -4,16 +4,17 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {html} from 'lit'; +import {html, nothing} from 'lit'; // Source: https://fonts.google.com/icons?selected=Material+Symbols+Outlined:dark_mode:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=dark+mode -export const darkModeIcon = html` +export const darkModeIcon = (slot = '') => html` <svg fill="currentColor" width="24" height="24" aria-hidden="true" viewBox="0 -960 960 960" + slot=${slot || nothing} > <path d="M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q14 0 27.5 1t26.5 3q-41 29-65.5 75.5T444-660q0 90 63 153t153 63q55 0 101-24.5t75-65.5q2 13 3 26.5t1 27.5q0 150-105 255T480-120Zm0-80q88 0 158-48.5T740-375q-20 5-40 8t-40 3q-123 0-209.5-86.5T364-660q0-20 3-40t8-40q-78 32-126.5 102T200-480q0 116 82 198t198 82Zm-10-270Z" diff --git a/packages/lit-dev-content/src/icons/light-mode-icon.ts b/packages/lit-dev-content/src/icons/light-mode-icon.ts index 571c13c2a..274ea4b03 100644 --- a/packages/lit-dev-content/src/icons/light-mode-icon.ts +++ b/packages/lit-dev-content/src/icons/light-mode-icon.ts @@ -4,16 +4,17 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {html} from 'lit'; +import {html, nothing} from 'lit'; // Source: https://fonts.google.com/icons?selected=Material+Symbols+Outlined:light_mode:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=light+mode -export const lightModeIcon = html` +export const lightModeIcon = (slot = '') => html` <svg width="24" height="24" viewBox="0 -960 960 960" fill="currentcolor" aria-hidden="true" + slot=${slot || nothing} > <path d="M480-360q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Zm0 80q-83 0-141.5-58.5T280-480q0-83 58.5-141.5T480-680q83 0 141.5 58.5T680-480q0 83-58.5 141.5T480-280ZM200-440H40v-80h160v80Zm720 0H760v-80h160v80ZM440-760v-160h80v160h-80Zm0 720v-160h80v160h-80ZM256-650l-101-97 57-59 96 100-52 56Zm492 496-97-101 53-55 101 97-57 59Zm-98-550 97-101 59 57-100 96-56-52ZM154-212l101-97 55 53-97 101-59-57Zm326-268Z"