Skip to content

Commit

Permalink
theme-switcher is now a select (#1320)
Browse files Browse the repository at this point in the history
  • Loading branch information
e111077 authored Apr 2, 2024
1 parent 3abbfda commit 168c0a5
Show file tree
Hide file tree
Showing 6 changed files with 266 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)}
>
Expand All @@ -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>`;
}
}
235 changes: 210 additions & 25 deletions packages/lit-dev-content/src/components/theme-switcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();

Expand All @@ -31,7 +36,7 @@ const Modes = {
},
auto: {
icon: autoModeIcon,
label: 'Match system mode',
label: 'System',
title: 'Color Mode Toggle (system)',
},
} as const;
Expand All @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions packages/lit-dev-content/src/icons/auto-mode-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions packages/lit-dev-content/src/icons/check-icon.ts
Original file line number Diff line number Diff line change
@@ -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>`;
5 changes: 3 additions & 2 deletions packages/lit-dev-content/src/icons/dark-mode-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading

0 comments on commit 168c0a5

Please sign in to comment.