Skip to content

Commit 168c0a5

Browse files
authored
theme-switcher is now a select (#1320)
1 parent 3abbfda commit 168c0a5

File tree

6 files changed

+266
-32
lines changed

6 files changed

+266
-32
lines changed

packages/lit-dev-content/src/components/litdev-ripple-icon-button.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,30 @@ export class LitDevRippleIconButton extends LitElement {
2727
@property({attribute: 'button-title'})
2828
buttonTitle = '';
2929

30+
/**
31+
* Aria haspopup for the button.
32+
*/
33+
@property()
34+
haspopup = '';
35+
36+
/**
37+
* Aria expanded for the button.
38+
*/
39+
@property()
40+
expanded = '';
41+
42+
/**
43+
* Aria controls for the button.
44+
*/
45+
@property()
46+
controls = '';
47+
48+
/**
49+
* Sets the role for the inner button.
50+
*/
51+
@property({attribute: 'button-role'})
52+
buttonRole = '';
53+
3054
/**
3155
* Href for the link button. If defined, this component switches to using an
3256
* anchor element instead of a button.
@@ -140,8 +164,12 @@ export class LitDevRippleIconButton extends LitElement {
140164
<button
141165
class="root"
142166
part="root button"
167+
role=${this.buttonRole ? this.buttonRole : nothing}
143168
aria-live=${this.live ? this.live : nothing}
144169
aria-label=${this.label ? this.label : nothing}
170+
aria-haspopup=${this.haspopup ? this.haspopup : nothing}
171+
aria-expanded=${this.expanded ? this.expanded : nothing}
172+
aria-controls=${this.controls ? this.controls : nothing}
145173
?disabled=${this.disabled}
146174
title=${this.buttonTitle ?? (nothing as unknown as string)}
147175
>
@@ -165,7 +193,7 @@ export class LitDevRippleIconButton extends LitElement {
165193
}
166194

167195
protected renderContent() {
168-
return html` <div id="ripple"></div>
196+
return html`<div id="ripple"></div>
169197
<slot></slot>`;
170198
}
171199
}

packages/lit-dev-content/src/components/theme-switcher.ts

Lines changed: 210 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
* SPDX-License-Identifier: BSD-3-Clause
55
*/
66

7-
import {LitElement, html, isServer} from 'lit';
8-
import {property, customElement} from 'lit/decorators.js';
7+
import {LitElement, html, isServer, css, PropertyValues} from 'lit';
8+
import {property, customElement, state} from 'lit/decorators.js';
99
import {
1010
getCurrentMode,
1111
applyColorThemeListeners,
@@ -14,7 +14,12 @@ import {
1414
import {autoModeIcon} from '../icons/auto-mode-icon.js';
1515
import {darkModeIcon} from '../icons/dark-mode-icon.js';
1616
import {lightModeIcon} from '../icons/light-mode-icon.js';
17+
import {checkIcon} from '../icons/check-icon.js';
1718
import './litdev-ripple-icon-button.js';
19+
import '@material/web/menu/menu.js';
20+
import '@material/web/menu/menu-item.js';
21+
import {FocusState, type CloseMenuEvent} from '@material/web/menu/menu.js';
22+
import {isElementInSubtree} from '@material/web/menu/internal/controllers/shared.js';
1823

1924
applyColorThemeListeners();
2025

@@ -31,7 +36,7 @@ const Modes = {
3136
},
3237
auto: {
3338
icon: autoModeIcon,
34-
label: 'Match system mode',
39+
label: 'System',
3540
title: 'Color Mode Toggle (system)',
3641
},
3742
} as const;
@@ -44,40 +49,220 @@ export class ThemeSwitcher extends LitElement {
4449
@property()
4550
mode: ColorMode = 'auto';
4651

52+
@state()
53+
menuOpen = false;
54+
55+
@state()
56+
defaultFocus: FocusState = FocusState.NONE;
57+
4758
render() {
4859
const modeOptions = Modes[this.mode];
49-
return html`<litdev-ripple-icon-button
50-
@click=${this._click}
51-
live="assertive"
52-
.label=${modeOptions.label}
53-
.buttonTitle=${modeOptions.title}
54-
>
55-
${modeOptions.icon}
56-
</litdev-ripple-icon-button>`;
60+
return html`
61+
<litdev-ripple-icon-button
62+
@click=${() => {
63+
this.menuOpen = !this.menuOpen;
64+
}}
65+
@keydown=${this._handleKeydown}
66+
label="Theme selector"
67+
haspopup="listbox"
68+
.expanded=${this.menuOpen ? 'true' : 'false'}
69+
controls="menu"
70+
button-role="combobox"
71+
.buttonTitle=${modeOptions.title}
72+
id="button"
73+
>
74+
<span aria-label=${modeOptions.label}>${modeOptions.icon()}</span>
75+
</litdev-ripple-icon-button>
76+
<md-menu
77+
id="menu"
78+
anchor="button"
79+
tabindex="-1"
80+
role="listbox"
81+
stay-open-on-focusout
82+
.open=${this.menuOpen}
83+
.defaultFocus=${this.defaultFocus}
84+
@opening=${() => {
85+
this.shadowRoot
86+
?.querySelector?.('#button span')
87+
?.removeAttribute?.('aria-live');
88+
}}
89+
@opened=${() => {
90+
if (this.defaultFocus !== FocusState.NONE) {
91+
return;
92+
}
93+
94+
(
95+
this.shadowRoot?.querySelector?.(
96+
'md-menu-item[selected]'
97+
) as HTMLElement
98+
)?.focus?.();
99+
}}
100+
@closed=${() => {
101+
this.menuOpen = false;
102+
}}
103+
@close-menu=${this._onCloseMenu}
104+
>
105+
${Object.keys(Modes).map(
106+
(mode) => html`
107+
<md-menu-item
108+
aria-selected=${mode === this.mode ? 'true' : 'false'}
109+
?selected=${mode === this.mode}
110+
data-mode=${mode}
111+
>
112+
<span slot="headline">${Modes[mode as ColorMode].label}</span>
113+
${Modes[mode as ColorMode].icon('start')}
114+
${mode === this.mode
115+
? checkIcon('end')
116+
: html`<span slot="end"></span>`}
117+
</md-menu-item>
118+
`
119+
)}
120+
</md-menu>
121+
`;
122+
}
123+
124+
update(changed: PropertyValues<this>) {
125+
if (changed.get('mode')) {
126+
this.dispatchEvent(new ChangeColorModeEvent(this.mode));
127+
}
128+
129+
super.update(changed);
57130
}
58131

59132
firstUpdated() {
60133
this.mode = getCurrentMode();
134+
this.addEventListener('focusout', this._focusout);
61135
}
62136

63-
private _click() {
64-
let nextMode!: ColorMode;
65-
66-
switch (this.mode) {
67-
case 'auto':
68-
nextMode = 'dark';
69-
break;
70-
case 'dark':
71-
nextMode = 'light';
72-
break;
73-
case 'light':
74-
nextMode = 'auto';
75-
break;
137+
private _onCloseMenu(e: CloseMenuEvent) {
138+
const nextMode = e.detail.itemPath[0]?.dataset?.mode as
139+
| ColorMode
140+
| undefined;
141+
if (!nextMode) {
142+
return;
76143
}
77144

78-
this.dispatchEvent(new ChangeColorModeEvent(nextMode));
79145
this.mode = nextMode;
80146
}
147+
148+
/**
149+
* Handles opening the select on keydown and typahead selection when the menu
150+
* is closed. Taken from md-select's implementation.
151+
*/
152+
private _handleKeydown(event: KeyboardEvent) {
153+
const menu = this.shadowRoot?.querySelector?.('md-menu');
154+
155+
if (this.menuOpen || !menu) {
156+
return;
157+
}
158+
159+
const typeaheadController = menu.typeaheadController;
160+
const isOpenKey =
161+
event.code === 'Space' ||
162+
event.code === 'ArrowDown' ||
163+
event.code === 'ArrowUp' ||
164+
event.code === 'End' ||
165+
event.code === 'Home' ||
166+
event.code === 'Enter';
167+
168+
// Do not open if currently typing ahead because the user may be typing the
169+
// spacebar to match a word with a space
170+
if (!typeaheadController.isTypingAhead && isOpenKey) {
171+
event.preventDefault();
172+
this.menuOpen = true;
173+
174+
// https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/#kbd_label
175+
switch (event.code) {
176+
case 'Space':
177+
case 'ArrowDown':
178+
case 'Enter':
179+
// We will handle focusing last selected item in this.handleOpening()
180+
this.defaultFocus = FocusState.NONE;
181+
break;
182+
case 'End':
183+
this.defaultFocus = FocusState.LAST_ITEM;
184+
break;
185+
case 'ArrowUp':
186+
case 'Home':
187+
this.defaultFocus = FocusState.FIRST_ITEM;
188+
break;
189+
default:
190+
break;
191+
}
192+
return;
193+
}
194+
195+
const isPrintableKey = event.key.length === 1;
196+
197+
// Handles typing ahead when the menu is closed by delegating the event to
198+
// the underlying menu's typeaheadController
199+
if (isPrintableKey) {
200+
typeaheadController.onKeydown(event);
201+
event.preventDefault();
202+
203+
const {lastActiveRecord} = typeaheadController;
204+
205+
if (!lastActiveRecord) {
206+
return;
207+
}
208+
209+
const labelEl = this.shadowRoot?.querySelector?.('#button span');
210+
211+
labelEl?.setAttribute?.('aria-live', 'polite');
212+
this.mode = lastActiveRecord[1].dataset.mode as ColorMode;
213+
}
214+
}
215+
216+
/**
217+
* Handles closing the menu when the focus leaves the select's subtree.
218+
* Taken from md-select's implementation.
219+
*/
220+
private _focusout(event: FocusEvent) {
221+
// Don't close the menu if we are switching focus between menu,
222+
// select-option, and field
223+
if (event.relatedTarget && isElementInSubtree(event.relatedTarget, this)) {
224+
return;
225+
}
226+
227+
this.menuOpen = false;
228+
}
229+
230+
static override styles = css`
231+
:host {
232+
position: relative;
233+
--md-sys-color-primary: var(--sys-color-primary);
234+
--md-sys-color-surface: var(--sys-color-surface);
235+
--md-sys-color-on-surface: var(--sys-color-on-surface);
236+
--md-sys-color-on-surface-variant: var(--sys-color-on-surface-variant);
237+
--md-sys-color-surface-container: var(--sys-color-surface-container);
238+
--md-sys-color-secondary-container: var(--sys-color-primary-container);
239+
--md-sys-color-on-secondary-container: var(
240+
--sys-color-on-primary-container
241+
);
242+
--md-focus-ring-color: var(--sys-color-secondary);
243+
}
244+
245+
md-menu-item[selected] {
246+
--md-focus-ring-color: var(--sys-color-secondary-container);
247+
}
248+
249+
md-menu {
250+
min-width: 208px;
251+
}
252+
253+
#button > span {
254+
display: flex;
255+
}
256+
257+
[slot='end'] {
258+
width: 24px;
259+
height: 24px;
260+
}
261+
262+
[slot='headline'] {
263+
font-family: Manrope, sans-serif;
264+
}
265+
`;
81266
}
82267

83268
if (isServer) {

packages/lit-dev-content/src/icons/auto-mode-icon.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@
44
* SPDX-License-Identifier: BSD-3-Clause
55
*/
66

7-
import {html} from 'lit';
7+
import {html, nothing} from 'lit';
88

99
// Source: https://fonts.google.com/icons?selected=Material+Symbols+Outlined:routine:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=routine
10-
export const autoModeIcon = html`
10+
export const autoModeIcon = (slot = '') => html`
1111
<svg
1212
fill="currentColor"
1313
width="24"
1414
height="24"
1515
aria-hidden="true"
1616
viewBox="0 -960 960 960"
17+
slot=${slot || nothing}
1718
>
1819
<path
1920
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"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* @license
3+
* Copyright 2024 Google LLC
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
*/
6+
7+
import {html, nothing} from 'lit';
8+
9+
// Source: https://fonts.google.com/icons?selected=Material+Icons+Outlined:check_circle
10+
export const checkIcon = (slot = '') => html`<svg
11+
height="24px"
12+
viewBox="0 0 24 24"
13+
width="24px"
14+
fill="currentcolor"
15+
slot=${slot || nothing}
16+
>
17+
<path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z" />
18+
</svg>`;

packages/lit-dev-content/src/icons/dark-mode-icon.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@
44
* SPDX-License-Identifier: BSD-3-Clause
55
*/
66

7-
import {html} from 'lit';
7+
import {html, nothing} from 'lit';
88

99
// Source: https://fonts.google.com/icons?selected=Material+Symbols+Outlined:dark_mode:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=dark+mode
10-
export const darkModeIcon = html`
10+
export const darkModeIcon = (slot = '') => html`
1111
<svg
1212
fill="currentColor"
1313
width="24"
1414
height="24"
1515
aria-hidden="true"
1616
viewBox="0 -960 960 960"
17+
slot=${slot || nothing}
1718
>
1819
<path
1920
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"

0 commit comments

Comments
 (0)