-
Notifications
You must be signed in to change notification settings - Fork 829
/
menuItemController.ts
251 lines (222 loc) · 6.95 KB
/
menuItemController.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {ReactiveController, ReactiveControllerHost} from 'lit';
import {
CloseReason,
createDefaultCloseMenuEvent,
isClosableKey,
} from './shared.js';
/**
* Interface specific to menu item and not HTMLElement.
*
* NOTE: required properties are expected to be reactive.
*/
interface MenuItemAdditions {
/**
* Whether or not the item is in the disabled state.
*/
disabled: boolean;
/**
* The text of the item that will be used for typeahead. If not set, defaults
* to the textContent of the element slotted into the headline.
*/
typeaheadText: string;
/**
* Whether or not the item is in the selected visual state.
*/
selected: boolean;
/**
* Sets the behavior and role of the menu item, defaults to "menuitem".
*/
type: MenuItemType;
/**
* Whether it should keep the menu open after click.
*/
keepOpen?: boolean;
/**
* Sets the underlying `HTMLAnchorElement`'s `href` resource attribute.
*/
href?: string;
/**
* Focuses the item.
*/
focus: () => void;
}
/**
* The interface of every menu item interactive with a menu. All menu items
* should implement this interface to be compatible with md-menu. Additionally
* it should have the `md-menu-item` attribute set.
*
* NOTE, the required properties are recommended to be reactive properties.
*/
export type MenuItem = MenuItemAdditions & HTMLElement;
/**
* Supported behaviors for a menu item.
*/
export type MenuItemType = 'menuitem' | 'option' | 'button' | 'link';
/**
* The options used to inialize MenuItemController.
*/
export interface MenuItemControllerConfig {
/**
* A function that returns the headline element of the menu item.
*/
getHeadlineElements: () => HTMLElement[];
/**
* A function that returns the supporting-text element of the menu item.
*/
getSupportingTextElements: () => HTMLElement[];
/**
* A function that returns the default slot / misc content.
*/
getDefaultElements: () => Node[];
/**
* The HTML Element that accepts user interactions like click. Used for
* occasions like programmatically clicking anchor tags when `Enter` is
* pressed.
*/
getInteractiveElement: () => HTMLElement | null;
}
/**
* A controller that provides most functionality of an element that implements
* the MenuItem interface.
*/
export class MenuItemController implements ReactiveController {
private internalTypeaheadText: string | null = null;
private readonly getHeadlineElements: MenuItemControllerConfig['getHeadlineElements'];
private readonly getSupportingTextElements: MenuItemControllerConfig['getSupportingTextElements'];
private readonly getDefaultElements: MenuItemControllerConfig['getDefaultElements'];
private readonly getInteractiveElement: MenuItemControllerConfig['getInteractiveElement'];
/**
* @param host The MenuItem in which to attach this controller to.
* @param config The object that configures this controller's behavior.
*/
constructor(
private readonly host: ReactiveControllerHost & MenuItem,
config: MenuItemControllerConfig,
) {
this.getHeadlineElements = config.getHeadlineElements;
this.getSupportingTextElements = config.getSupportingTextElements;
this.getDefaultElements = config.getDefaultElements;
this.getInteractiveElement = config.getInteractiveElement;
this.host.addController(this);
}
/**
* The text that is selectable via typeahead. If not set, defaults to the
* innerText of the item slotted into the `"headline"` slot, and if there are
* no slotted elements into headline, then it checks the _default_ slot, and
* then the `"supporting-text"` slot if nothing is in _default_.
*/
get typeaheadText() {
if (this.internalTypeaheadText !== null) {
return this.internalTypeaheadText;
}
const headlineElements = this.getHeadlineElements();
const textParts: string[] = [];
headlineElements.forEach((headlineElement) => {
if (headlineElement.textContent && headlineElement.textContent.trim()) {
textParts.push(headlineElement.textContent.trim());
}
});
// If there are no headline elements, check the default slot's text content
if (textParts.length === 0) {
this.getDefaultElements().forEach((defaultElement) => {
if (defaultElement.textContent && defaultElement.textContent.trim()) {
textParts.push(defaultElement.textContent.trim());
}
});
}
// If there are no headline nor default slot elements, check the
//supporting-text slot's text content
if (textParts.length === 0) {
this.getSupportingTextElements().forEach((supportingTextElement) => {
if (
supportingTextElement.textContent &&
supportingTextElement.textContent.trim()
) {
textParts.push(supportingTextElement.textContent.trim());
}
});
}
return textParts.join(' ');
}
/**
* The recommended tag name to render as the list item.
*/
get tagName() {
const type = this.host.type;
switch (type) {
case 'link':
return 'a' as const;
case 'button':
return 'button' as const;
default:
case 'menuitem':
case 'option':
return 'li' as const;
}
}
/**
* The recommended role of the menu item.
*/
get role() {
return this.host.type === 'option' ? 'option' : 'menuitem';
}
hostConnected() {
this.host.toggleAttribute('md-menu-item', true);
}
hostUpdate() {
if (this.host.href) {
this.host.type = 'link';
}
}
/**
* Bind this click listener to the interactive element. Handles closing the
* menu.
*/
onClick = () => {
if (this.host.keepOpen) return;
this.host.dispatchEvent(
createDefaultCloseMenuEvent(this.host, {
kind: CloseReason.CLICK_SELECTION,
}),
);
};
/**
* Bind this click listener to the interactive element. Handles closing the
* menu.
*/
onKeydown = (event: KeyboardEvent) => {
// Check if the interactive element is an anchor tag. If so, click it.
if (this.host.href && event.code === 'Enter') {
const interactiveElement = this.getInteractiveElement();
if (interactiveElement instanceof HTMLAnchorElement) {
interactiveElement.click();
}
}
if (event.defaultPrevented) return;
// If the host has keepOpen = true we should ignore clicks & Space/Enter,
// however we always maintain the ability to close a menu with a explicit
// `escape` keypress.
const keyCode = event.code;
if (this.host.keepOpen && keyCode !== 'Escape') return;
if (isClosableKey(keyCode)) {
event.preventDefault();
this.host.dispatchEvent(
createDefaultCloseMenuEvent(this.host, {
kind: CloseReason.KEYDOWN,
key: keyCode,
}),
);
}
};
/**
* Use to set the typeaheadText when it changes.
*/
setTypeaheadText(text: string) {
this.internalTypeaheadText = text;
}
}