4
4
* SPDX-License-Identifier: BSD-3-Clause
5
5
*/
6
6
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' ;
9
9
import {
10
10
getCurrentMode ,
11
11
applyColorThemeListeners ,
@@ -14,7 +14,12 @@ import {
14
14
import { autoModeIcon } from '../icons/auto-mode-icon.js' ;
15
15
import { darkModeIcon } from '../icons/dark-mode-icon.js' ;
16
16
import { lightModeIcon } from '../icons/light-mode-icon.js' ;
17
+ import { checkIcon } from '../icons/check-icon.js' ;
17
18
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' ;
18
23
19
24
applyColorThemeListeners ( ) ;
20
25
@@ -31,7 +36,7 @@ const Modes = {
31
36
} ,
32
37
auto : {
33
38
icon : autoModeIcon ,
34
- label : 'Match system mode ' ,
39
+ label : 'System ' ,
35
40
title : 'Color Mode Toggle (system)' ,
36
41
} ,
37
42
} as const ;
@@ -44,40 +49,220 @@ export class ThemeSwitcher extends LitElement {
44
49
@property ( )
45
50
mode : ColorMode = 'auto' ;
46
51
52
+ @state ( )
53
+ menuOpen = false ;
54
+
55
+ @state ( )
56
+ defaultFocus : FocusState = FocusState . NONE ;
57
+
47
58
render ( ) {
48
59
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- butto n
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- butto n>
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 ) ;
57
130
}
58
131
59
132
firstUpdated ( ) {
60
133
this . mode = getCurrentMode ( ) ;
134
+ this . addEventListener ( 'focusout' , this . _focusout ) ;
61
135
}
62
136
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 ;
76
143
}
77
144
78
- this . dispatchEvent ( new ChangeColorModeEvent ( nextMode ) ) ;
79
145
this . mode = nextMode ;
80
146
}
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
+ ` ;
81
266
}
82
267
83
268
if ( isServer ) {
0 commit comments