@aurodesignsystem-dev/auro-formkit 0.0.0-pr1498.0 → 0.0.0-pr1499.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/checkbox/demo/customize.min.js +2 -2
- package/components/checkbox/demo/getting-started.min.js +2 -2
- package/components/checkbox/demo/index.min.js +2 -2
- package/components/checkbox/dist/index.js +2 -2
- package/components/checkbox/dist/registered.js +2 -2
- package/components/combobox/demo/customize.md +3 -7
- package/components/combobox/demo/customize.min.js +1703 -1461
- package/components/combobox/demo/getting-started.min.js +1703 -1461
- package/components/combobox/demo/index.min.js +1703 -1461
- package/components/combobox/dist/auro-combobox.d.ts +0 -5
- package/components/combobox/dist/index.js +162 -495
- package/components/combobox/dist/registered.js +162 -495
- package/components/counter/demo/customize.min.js +19 -18
- package/components/counter/demo/index.min.js +19 -18
- package/components/counter/dist/index.js +160 -5811
- package/components/counter/dist/registered.js +187 -5442
- package/components/datepicker/demo/customize.md +477 -384
- package/components/datepicker/demo/customize.min.js +99 -171
- package/components/datepicker/demo/index.md +0 -1
- package/components/datepicker/demo/index.min.js +99 -171
- package/components/datepicker/dist/index.js +99 -171
- package/components/datepicker/dist/registered.js +99 -171
- package/components/datepicker/dist/src/auro-datepicker.d.ts +2 -2
- package/components/dropdown/demo/customize.min.js +17 -16
- package/components/dropdown/demo/getting-started.min.js +17 -16
- package/components/dropdown/demo/index.min.js +17 -16
- package/components/dropdown/dist/auro-dropdown.d.ts +2 -1
- package/components/dropdown/dist/index.js +17 -16
- package/components/dropdown/dist/registered.js +17 -16
- package/components/form/demo/customize.min.js +8494 -8428
- package/components/form/demo/getting-started.min.js +8494 -8428
- package/components/form/demo/index.min.js +8494 -8428
- package/components/form/demo/registerDemoDeps.min.js +8518 -8452
- package/components/input/demo/customize.md +539 -472
- package/components/input/demo/customize.min.js +61 -136
- package/components/input/demo/getting-started.min.js +61 -136
- package/components/input/demo/index.min.js +61 -136
- package/components/input/dist/base-input.d.ts +0 -16
- package/components/input/dist/index.js +61 -136
- package/components/input/dist/registered.js +61 -136
- package/components/menu/demo/api.md +45 -41
- package/components/menu/demo/customize.md +28 -0
- package/components/menu/demo/index.min.js +1358 -783
- package/components/menu/dist/auro-menu.context.d.ts +238 -0
- package/components/menu/dist/auro-menu.d.ts +110 -95
- package/components/menu/dist/auro-menuoption.d.ts +138 -32
- package/components/menu/dist/index.js +1312 -757
- package/components/menu/dist/registered.js +1312 -769
- package/components/radio/demo/customize.min.js +2 -2
- package/components/radio/demo/getting-started.min.js +2 -2
- package/components/radio/demo/index.min.js +2 -2
- package/components/radio/dist/index.js +2 -2
- package/components/radio/dist/registered.js +2 -2
- package/components/select/demo/customize.md +3 -7
- package/components/select/demo/customize.min.js +1634 -1089
- package/components/select/demo/getting-started.min.js +1634 -1089
- package/components/select/demo/index.min.js +1634 -1089
- package/components/select/dist/index.js +31 -61
- package/components/select/dist/registered.js +31 -61
- package/custom-elements.json +1024 -378
- package/package.json +1 -1
- package/components/select/dist/selectUtils.d.ts +0 -12
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { css, LitElement, html } from 'lit';
|
|
2
|
+
import { createContext, ContextProvider, ContextConsumer } from '@lit/context';
|
|
2
3
|
import { classMap } from 'lit/directives/class-map.js';
|
|
3
4
|
import { unsafeStatic, literal, html as html$1 } from 'lit/static-html.js';
|
|
4
5
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
@@ -113,6 +114,720 @@ class AuroElement extends LitElement {
|
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/* eslint-disable */
|
|
118
|
+
|
|
119
|
+
class MenuService {
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* PROPERTIES AND GETTERS
|
|
123
|
+
*/
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Gets the list of registered menu options.
|
|
127
|
+
* @returns {AuroMenuOption[]}
|
|
128
|
+
*/
|
|
129
|
+
get menuOptions() {
|
|
130
|
+
return this._menuOptions;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Gets the currently highlighted option.
|
|
135
|
+
* @returns {AuroMenuOption|null}
|
|
136
|
+
*/
|
|
137
|
+
get highlightedOption() {
|
|
138
|
+
return this._menuOptions[this.highlightedIndex] || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Gets the current value(s) of the selected option(s).
|
|
143
|
+
* @returns {string|string[]|undefined}
|
|
144
|
+
*/
|
|
145
|
+
get currentValue() {
|
|
146
|
+
const values = (this.selectedOptions || []).map(option => option.value);
|
|
147
|
+
return this.multiSelect ? values : values[0];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Gets the label(s) of the currently selected option(s).
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
get currentLabel() {
|
|
155
|
+
const labels = (this.selectedOptions || []).map(option => option.textContent);
|
|
156
|
+
return this.multiSelect ? labels.join(", ") : labels[0] || '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Gets the string representation of the current value(s).
|
|
161
|
+
* For multi-select, this is a JSON stringified array.
|
|
162
|
+
* @returns {string|undefined}
|
|
163
|
+
*/
|
|
164
|
+
get stringValue() {
|
|
165
|
+
const { currentValue } = this;
|
|
166
|
+
|
|
167
|
+
if (Array.isArray(currentValue)) {
|
|
168
|
+
if (currentValue.length > 0) {
|
|
169
|
+
return JSON.stringify(currentValue);
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (typeof currentValue === 'string') {
|
|
175
|
+
if (currentValue.length > 0) {
|
|
176
|
+
return currentValue;
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Future: handle other types here (e.g., number, object, etc.)
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Gets the key(s) of the currently selected option(s).
|
|
187
|
+
* @returns {string|string[]|undefined}
|
|
188
|
+
*/
|
|
189
|
+
get currentKeys() {
|
|
190
|
+
const keys = (this.selectedOptions || []).map(option => option.key);
|
|
191
|
+
return this.multiSelect ? keys : keys[0];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* CONSTRUCTOR
|
|
196
|
+
*/
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Creates a new MenuService instance.
|
|
200
|
+
* @param {Object} options - The options object.
|
|
201
|
+
* @param {AuroMenu} options.host - The host element that this service will control. Required.
|
|
202
|
+
* @throws {Error} If the host is not provided.
|
|
203
|
+
*/
|
|
204
|
+
constructor({ host } = {}) {
|
|
205
|
+
|
|
206
|
+
// Ensure a host was passed
|
|
207
|
+
if (!host) {
|
|
208
|
+
throw new Error("MenuService requires a host element.");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Attach the service to the host
|
|
212
|
+
this.host = host;
|
|
213
|
+
this.host.addController(this);
|
|
214
|
+
|
|
215
|
+
// Set default properties
|
|
216
|
+
this.size = undefined;
|
|
217
|
+
this.shape = undefined;
|
|
218
|
+
this.noCheckmark = undefined;
|
|
219
|
+
this.disabled = undefined;
|
|
220
|
+
this.matchWord = undefined;
|
|
221
|
+
this.multiSelect = undefined;
|
|
222
|
+
this.allowDeselect = undefined;
|
|
223
|
+
this.selectAllMatchingOptions = undefined;
|
|
224
|
+
|
|
225
|
+
this.highlightedIndex = -1;
|
|
226
|
+
|
|
227
|
+
this._menuOptions = [];
|
|
228
|
+
this._subscribers = [];
|
|
229
|
+
this.internalUpdateInProgress = false;
|
|
230
|
+
this.selectedOptions = [];
|
|
231
|
+
this._pendingValue = null;
|
|
232
|
+
this._pendingRetryScheduled = false;
|
|
233
|
+
this._pendingRetryCount = 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* PROPERTY SYNCING
|
|
238
|
+
*/
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Handles host updates.
|
|
242
|
+
* This is a lit reactive lifecycle method.
|
|
243
|
+
* This comes from the Lit controller interface provided by adding this service as a controller to the host.
|
|
244
|
+
* See constructor for `this.host.addController(this)`
|
|
245
|
+
* You can read more about Lit reactive controllers here: https://lit.dev/docs/composition/controllers/
|
|
246
|
+
*/
|
|
247
|
+
hostUpdated() {
|
|
248
|
+
|
|
249
|
+
// Reset selection if multiSelect mode changes
|
|
250
|
+
if (this.host.multiSelect !== this.multiSelect) {
|
|
251
|
+
this.selectedOptions = [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Update properties on host update
|
|
255
|
+
this.setProperties({
|
|
256
|
+
size: this.host.size,
|
|
257
|
+
shape: this.host.shape,
|
|
258
|
+
noCheckmark: this.host.noCheckmark,
|
|
259
|
+
disabled: this.host.disabled,
|
|
260
|
+
matchWord: this.host.matchWord,
|
|
261
|
+
multiSelect: this.host.multiSelect,
|
|
262
|
+
allowDeselect: this.host.allowDeselect,
|
|
263
|
+
selectAllMatchingOptions: this.host.selectAllMatchingOptions
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Handles host disconnection and memory cleanup.
|
|
269
|
+
*/
|
|
270
|
+
hostDisconnected() {
|
|
271
|
+
this._subscribers = [];
|
|
272
|
+
this._menuOptions = [];
|
|
273
|
+
this._pendingValue = null;
|
|
274
|
+
this._pendingRetryScheduled = false;
|
|
275
|
+
this._pendingRetryCount = 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Sets a property value if it exists on the instance and the value has changed.
|
|
280
|
+
* @param {string} property
|
|
281
|
+
* @param {any} value
|
|
282
|
+
*/
|
|
283
|
+
setProperty(property, value) {
|
|
284
|
+
|
|
285
|
+
// Only update if we are tracking the property in this service
|
|
286
|
+
if (this.hasOwnProperty(property)) {
|
|
287
|
+
|
|
288
|
+
// Check if the value has changed
|
|
289
|
+
const valueChanged = this[property] !== value;
|
|
290
|
+
|
|
291
|
+
// Update and notify if changed
|
|
292
|
+
if (valueChanged) {
|
|
293
|
+
this[property] = value;
|
|
294
|
+
this.notify({ property, value });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Sets multiple properties on the instance.
|
|
301
|
+
* @param {Object} properties - Key-value pairs of properties to set.
|
|
302
|
+
*/
|
|
303
|
+
setProperties(properties) {
|
|
304
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
305
|
+
this.setProperty(key, value);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* MENU OPTION HIGHLIGHTING
|
|
311
|
+
*/
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Highlights the next active option in the menu.
|
|
315
|
+
*/
|
|
316
|
+
highlightNext() {
|
|
317
|
+
this.moveHighlightedOption("next");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Highlights the previous active option in the menu.
|
|
322
|
+
*/
|
|
323
|
+
highlightPrevious() {
|
|
324
|
+
this.moveHighlightedOption("previous");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Moves the highlighted option in the specified direction.
|
|
329
|
+
* @param {string} direction - The direction to move the highlight ("next" or "previous").
|
|
330
|
+
*/
|
|
331
|
+
moveHighlightedOption(direction) {
|
|
332
|
+
|
|
333
|
+
// Get the active options
|
|
334
|
+
const activeOptions = this._menuOptions.filter(option => option.isActive);
|
|
335
|
+
|
|
336
|
+
// Get the currently active option
|
|
337
|
+
const currentActiveOption = activeOptions[activeOptions.indexOf(this.highlightedOption)];
|
|
338
|
+
|
|
339
|
+
// Determine the new index based on the currently active option and direction
|
|
340
|
+
let newIndex = currentActiveOption
|
|
341
|
+
? direction === "previous"
|
|
342
|
+
? activeOptions.indexOf(currentActiveOption) - 1
|
|
343
|
+
: activeOptions.indexOf(currentActiveOption) + 1
|
|
344
|
+
: direction === "previous"
|
|
345
|
+
? activeOptions.length - 1
|
|
346
|
+
: 0;
|
|
347
|
+
|
|
348
|
+
// Wrap around the index if needed
|
|
349
|
+
newIndex = newIndex < 0 ? activeOptions.length - 1 : newIndex >= activeOptions.length ? 0 : newIndex;
|
|
350
|
+
|
|
351
|
+
// Get the new active option and set it as highlighted
|
|
352
|
+
const newActiveOption = activeOptions[newIndex];
|
|
353
|
+
this.setHighlightedOption(newActiveOption);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Sets the highlighted index to the specified option.
|
|
358
|
+
* @param {AuroMenuOption} option - The option to highlight.
|
|
359
|
+
*/
|
|
360
|
+
setHighlightedOption(option) {
|
|
361
|
+
|
|
362
|
+
if (!option) return;
|
|
363
|
+
|
|
364
|
+
// Get the index of the option to highlight
|
|
365
|
+
const index = this._menuOptions.indexOf(option);
|
|
366
|
+
|
|
367
|
+
// Update highlighted index
|
|
368
|
+
this.highlightedIndex = index;
|
|
369
|
+
|
|
370
|
+
// Notify subscribers of highlight change
|
|
371
|
+
this.notify({ type: 'highlightChange', option, index: this.highlightedIndex });
|
|
372
|
+
|
|
373
|
+
// Dispatch the change event
|
|
374
|
+
this.dispatchChangeEvent('auroMenu-activatedOption', option);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Sets the highlighted option to the option at the specified index if it exists.
|
|
379
|
+
* @param {number} index
|
|
380
|
+
*/
|
|
381
|
+
setHighlightedIndex(index) {
|
|
382
|
+
const option = this._menuOptions[index] || null;
|
|
383
|
+
this.setHighlightedOption(option);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Selects the currently highlighted option.
|
|
388
|
+
*/
|
|
389
|
+
selectHighlightedOption() {
|
|
390
|
+
if (this.highlightedOption) {
|
|
391
|
+
this.toggleOption(this.highlightedOption);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* SELECTION AND DESELECTION METHODS
|
|
397
|
+
*/
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Selects one or more options in a batch operation
|
|
401
|
+
* @param {AuroMenuOption|AuroMenuOption[]} options - Single option or array of options to select
|
|
402
|
+
*/
|
|
403
|
+
selectOptions(options) {
|
|
404
|
+
let optionsToSelect = Array.isArray(options) ? options : [options];
|
|
405
|
+
|
|
406
|
+
// Filter out options that are inactive
|
|
407
|
+
optionsToSelect = optionsToSelect.filter(option => option.isActive);
|
|
408
|
+
|
|
409
|
+
if (!optionsToSelect.length) return;
|
|
410
|
+
|
|
411
|
+
if (this.multiSelect) {
|
|
412
|
+
this.selectedOptions = [...(this.selectedOptions || []), ...optionsToSelect];
|
|
413
|
+
} else {
|
|
414
|
+
// In single select mode, only take the last option
|
|
415
|
+
this.selectedOptions = [optionsToSelect[optionsToSelect.length - 1]];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
this.stageUpdate();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Deselects one or more options in a batch operation
|
|
423
|
+
* @param {AuroMenuOption|AuroMenuOption[]} options - Single option or array of options to deselect
|
|
424
|
+
*/
|
|
425
|
+
deselectOptions(options) {
|
|
426
|
+
const optionsToDeselect = Array.isArray(options) ? options : [options];
|
|
427
|
+
|
|
428
|
+
if (!optionsToDeselect.length) return;
|
|
429
|
+
|
|
430
|
+
// Check if deselection should be prevented
|
|
431
|
+
const shouldPreventDeselect = !this.allowDeselect && !this.multiSelect;
|
|
432
|
+
const isOnlySelectedOption = this.selectedOptions.length === 1 && optionsToDeselect.includes(this.selectedOptions[0]);
|
|
433
|
+
|
|
434
|
+
// Prevent deselecting the only selected option if not allowed
|
|
435
|
+
if (shouldPreventDeselect && isOnlySelectedOption) {
|
|
436
|
+
optionsToDeselect.forEach(option => {
|
|
437
|
+
option.selected = true;
|
|
438
|
+
});
|
|
439
|
+
this.dispatchChangeEvent('auroMenu-deselectPrevented', {
|
|
440
|
+
values: optionsToDeselect
|
|
441
|
+
});
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const optionsSet = new Set(optionsToDeselect);
|
|
446
|
+
const previousCount = this.selectedOptions.length;
|
|
447
|
+
this.selectedOptions = (this.selectedOptions || [])
|
|
448
|
+
.filter(opt => !optionsSet.has(opt));
|
|
449
|
+
|
|
450
|
+
if (this.selectedOptions.length < previousCount) {
|
|
451
|
+
this.stageUpdate();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Selects a single option.
|
|
457
|
+
* @param {AuroMenuOption} option
|
|
458
|
+
*/
|
|
459
|
+
selectOption(option) {
|
|
460
|
+
this.selectOptions(option);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Deselects a single option.
|
|
465
|
+
* @param {AuroMenuOption} option
|
|
466
|
+
*/
|
|
467
|
+
deselectOption(option) {
|
|
468
|
+
this.deselectOptions(option);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Toggles the selection state of a single option.
|
|
473
|
+
* @param {AuroMenuOption} option
|
|
474
|
+
*/
|
|
475
|
+
toggleOption(option) {
|
|
476
|
+
if (option.selected) {
|
|
477
|
+
this.deselectOption(option);
|
|
478
|
+
} else {
|
|
479
|
+
this.selectOption(option);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Selects options based on their value(s) when compared to a passed value or values.
|
|
485
|
+
* Value or values are normalized to an array of strings that can be matched to option keys.
|
|
486
|
+
* @param {string|number|Array<string|number>} value - The value(s) to select.
|
|
487
|
+
*/
|
|
488
|
+
selectByValue(value) {
|
|
489
|
+
const isEmptyValue = value === undefined ||
|
|
490
|
+
value === null ||
|
|
491
|
+
(Array.isArray(value) && value.length === 0) ||
|
|
492
|
+
(typeof value === 'string' && value.trim() === '');
|
|
493
|
+
|
|
494
|
+
// Early exit for invalid/empty values
|
|
495
|
+
if (isEmptyValue) {
|
|
496
|
+
this.selectedOptions.forEach(opt => opt.selected = false);
|
|
497
|
+
this.selectedOptions = [];
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// If an internal update cycle is still in progress, defer value application
|
|
502
|
+
// rather than dropping it.
|
|
503
|
+
if (this.internalUpdateInProgress || this.host.internalUpdateInProgress) {
|
|
504
|
+
this.queuePendingValue(value);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Normalize values to array of strings
|
|
509
|
+
const normalizedValues = this._getNormalizedValues(value);
|
|
510
|
+
|
|
511
|
+
// Validate for single-select mode
|
|
512
|
+
let validatedValues = normalizedValues;
|
|
513
|
+
if (normalizedValues.length > 1 && !this.multiSelect) {
|
|
514
|
+
console.warn("MenuService - Multiple values provided for single-select menu. Only the first value will be selected.");
|
|
515
|
+
validatedValues = [normalizedValues[0]];
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (this._menuOptions.length === 0) {
|
|
519
|
+
this.queuePendingValue(value);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Find matching options by comparing available options to validated values
|
|
524
|
+
const trackedKeys = new Set();
|
|
525
|
+
const optionsToSelect = this._menuOptions.filter(option => {
|
|
526
|
+
const passesFilter = validatedValues.includes(option.key);
|
|
527
|
+
const alreadyTracked = trackedKeys.has(option.key);
|
|
528
|
+
const isActive = option.isActive;
|
|
529
|
+
|
|
530
|
+
trackedKeys.add(option.key);
|
|
531
|
+
|
|
532
|
+
// Include the option in the options to be selected if it passes the filter check and
|
|
533
|
+
// either hasn't been tracked yet or selectAllMatchingOptions is true
|
|
534
|
+
return isActive && passesFilter && (!alreadyTracked || (alreadyTracked && this.selectAllMatchingOptions));
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// Handle no matches: clear existing selection, but do not dispatch an intermediate
|
|
538
|
+
// undefined value that can overwrite the host value in parent components.
|
|
539
|
+
if (!optionsToSelect.length) {
|
|
540
|
+
const hasUnresolvedKeys = this._menuOptions.some((option) => option.isActive && option.key == null);
|
|
541
|
+
|
|
542
|
+
if (hasUnresolvedKeys) {
|
|
543
|
+
this.queuePendingValue(value);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
this.clearPendingValue();
|
|
548
|
+
|
|
549
|
+
if (this.selectedOptions.length > 0) {
|
|
550
|
+
this.selectedOptions = [];
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Always notify so the host resets any stale invalid value, even when
|
|
554
|
+
// selectedOptions was already empty (e.g. double-clicking set-invalid).
|
|
555
|
+
this.stageUpdate({ reason: 'no-match' });
|
|
556
|
+
|
|
557
|
+
// Dispatch failure event if no matches found
|
|
558
|
+
if (validatedValues.length) {
|
|
559
|
+
this.dispatchChangeEvent('auroMenu-selectValueFailure', {
|
|
560
|
+
message: 'No matching options found for the provided value(s).',
|
|
561
|
+
values: validatedValues
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
this.clearPendingValue();
|
|
569
|
+
|
|
570
|
+
if (this.optionsArraysMatch(optionsToSelect, this.selectedOptions)) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Apply programmatic selection as a single transaction and emit one final state.
|
|
575
|
+
this.selectedOptions = optionsToSelect;
|
|
576
|
+
this.stageUpdate();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Queues a pending value and schedules a bounded retry.
|
|
581
|
+
* @param {string|number|Array<string|number>} value - The value to retry.
|
|
582
|
+
*/
|
|
583
|
+
queuePendingValue(value) {
|
|
584
|
+
this._pendingValue = value;
|
|
585
|
+
|
|
586
|
+
if (this._pendingRetryScheduled || this._pendingRetryCount >= 5) {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
this._pendingRetryScheduled = true;
|
|
591
|
+
this._pendingRetryCount += 1;
|
|
592
|
+
|
|
593
|
+
setTimeout(() => {
|
|
594
|
+
this._pendingRetryScheduled = false;
|
|
595
|
+
|
|
596
|
+
if (this._pendingValue == null) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const pendingValue = this._pendingValue;
|
|
601
|
+
this.selectByValue(pendingValue);
|
|
602
|
+
}, 0);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Clears pending retry state.
|
|
607
|
+
*/
|
|
608
|
+
clearPendingValue() {
|
|
609
|
+
this._pendingValue = null;
|
|
610
|
+
this._pendingRetryScheduled = false;
|
|
611
|
+
this._pendingRetryCount = 0;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Resets the selected options to an empty array.
|
|
616
|
+
*/
|
|
617
|
+
reset() {
|
|
618
|
+
const previousOptions = [...this.selectedOptions];
|
|
619
|
+
previousOptions.forEach(opt => opt.selected = false);
|
|
620
|
+
this.selectedOptions = [];
|
|
621
|
+
|
|
622
|
+
// Single update after clearing all
|
|
623
|
+
if (previousOptions.length) {
|
|
624
|
+
this.stageUpdate();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* SUBSCRIPTION, NOTIFICATION AND EVENT DISPATCH METHODS
|
|
630
|
+
*/
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Subscribes a callback to menu service events.
|
|
634
|
+
* @param {Function} callback - The callback to invoke on events.
|
|
635
|
+
*/
|
|
636
|
+
subscribe(callback) {
|
|
637
|
+
this._subscribers.push(callback);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Remove a previously subscribed callback from menu service events.
|
|
642
|
+
* @param {Function} callback
|
|
643
|
+
*/
|
|
644
|
+
unsubscribe(callback) {
|
|
645
|
+
this._subscribers = this._subscribers.filter(cb => cb !== callback);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Stages an update to notify subscribers of state and value changes.
|
|
650
|
+
*/
|
|
651
|
+
stageUpdate(meta = {}) {
|
|
652
|
+
this.notifyStateChange(meta);
|
|
653
|
+
this.notifyValueChange(meta);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Notifies subscribers of a menu service event.
|
|
658
|
+
* All notifications are sent to all subscribers.
|
|
659
|
+
* @param {string} event - The event to send to subscribers.
|
|
660
|
+
*/
|
|
661
|
+
notify(event) {
|
|
662
|
+
this._subscribers.forEach(callback => callback(event));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Notifies subscribers of a state change (selected options has changed).
|
|
667
|
+
*/
|
|
668
|
+
notifyStateChange(meta = {}) {
|
|
669
|
+
this.notify({
|
|
670
|
+
type: 'stateChange',
|
|
671
|
+
selectedOptions: this.selectedOptions,
|
|
672
|
+
...meta
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Notifies subscribers of a value change (current value has changed).
|
|
678
|
+
*/
|
|
679
|
+
notifyValueChange(meta = {}) {
|
|
680
|
+
|
|
681
|
+
// Prepare details for the event
|
|
682
|
+
const details = {
|
|
683
|
+
value: this.currentValue,
|
|
684
|
+
stringValue: this.stringValue,
|
|
685
|
+
keys: this.currentKeys,
|
|
686
|
+
options: this.selectedOptions,
|
|
687
|
+
label: this.currentLabel
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
// If only one option is selected, include its index
|
|
691
|
+
if (this.selectedOptions.length === 1) details.index = this._menuOptions.indexOf(this.selectedOptions[0]);
|
|
692
|
+
|
|
693
|
+
this.notify({
|
|
694
|
+
type: 'valueChange',
|
|
695
|
+
...meta,
|
|
696
|
+
...details
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Dispatches a custom event from the host element.
|
|
702
|
+
* @param {string} eventName
|
|
703
|
+
* @param {any} detail
|
|
704
|
+
*/
|
|
705
|
+
dispatchChangeEvent(eventName, detail) {
|
|
706
|
+
this.host.dispatchEvent(new CustomEvent(eventName, {
|
|
707
|
+
bubbles: true,
|
|
708
|
+
cancelable: false,
|
|
709
|
+
composed: true,
|
|
710
|
+
detail
|
|
711
|
+
}));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* MENU OPTION MANAGEMENT METHODS
|
|
716
|
+
*/
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Adds a menu option to the service's list.
|
|
720
|
+
* @param {AuroMenuOption} option - the option to track
|
|
721
|
+
*/
|
|
722
|
+
addMenuOption(option) {
|
|
723
|
+
this._menuOptions.push(option);
|
|
724
|
+
this.notify({ type: 'optionsChange', options: this._menuOptions });
|
|
725
|
+
|
|
726
|
+
if (this._pendingValue != null) {
|
|
727
|
+
this.queuePendingValue(this._pendingValue);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Removes a menu option from the service's list.
|
|
733
|
+
* @param {AuroMenuOption} option - the option to remove
|
|
734
|
+
*/
|
|
735
|
+
removeMenuOption(option) {
|
|
736
|
+
this._menuOptions = this._menuOptions.filter(opt => opt !== option);
|
|
737
|
+
this.notify({ type: 'optionsChange', options: this._menuOptions });
|
|
738
|
+
|
|
739
|
+
if (this._menuOptions.length === 0) {
|
|
740
|
+
this.clearPendingValue();
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* UTILITIES
|
|
746
|
+
*/
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Normalizes a value or array of values into an array of strings for option selection.
|
|
750
|
+
* This function ensures that input values are consistently formatted for matching menu options.
|
|
751
|
+
*
|
|
752
|
+
* @param {string|number|Array<string|number>} value - The value(s) to normalize.
|
|
753
|
+
* @returns {Array<string>} An array of string values suitable for option matching.
|
|
754
|
+
* @throws {Error} If any value is not a string or number.
|
|
755
|
+
*/
|
|
756
|
+
_getNormalizedValues(value) {
|
|
757
|
+
let values = value;
|
|
758
|
+
|
|
759
|
+
// Handle JSON string and single value string input
|
|
760
|
+
if (!Array.isArray(values) && typeof values === 'string') {
|
|
761
|
+
|
|
762
|
+
// Attempt to parse as JSON array
|
|
763
|
+
try {
|
|
764
|
+
|
|
765
|
+
// Normalize single quotes to double quotes for JSON parsing
|
|
766
|
+
// This will not handle complex cases but will cover basic usage
|
|
767
|
+
const parseValue = values.replace(/'([^']*?)'/g, '"$1"');
|
|
768
|
+
|
|
769
|
+
// Attempt parse
|
|
770
|
+
const parsed = JSON.parse(parseValue);
|
|
771
|
+
|
|
772
|
+
// Ensure parsed value is an array
|
|
773
|
+
if (!Array.isArray(parsed)) throw new Error('Not an array');
|
|
774
|
+
|
|
775
|
+
// Set values to parsed array
|
|
776
|
+
values = parsed;
|
|
777
|
+
} catch (err) {
|
|
778
|
+
|
|
779
|
+
// If parsing fails, treat as single value
|
|
780
|
+
values = [value];
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Handle a single number being passed
|
|
785
|
+
if (typeof values === 'number') {
|
|
786
|
+
values = [String(values)];
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Coerce each value to string and validate types
|
|
790
|
+
values.forEach((val, index) => {
|
|
791
|
+
|
|
792
|
+
// Throw an error for invalid value types
|
|
793
|
+
if (typeof val !== 'string' && typeof val !== 'number') {
|
|
794
|
+
throw new Error('Value contains invalid value type. Supported types are string and number.');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Convert numbers to strings for consistency
|
|
798
|
+
if (typeof val === 'number') {
|
|
799
|
+
values[index] = String(val);
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// Return the resulting array of string values
|
|
804
|
+
return values;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Returns whether two arrays of options contain the same elements.
|
|
809
|
+
* @param {AuroMenuOption[]} arr1 - First array of options.
|
|
810
|
+
* @param {AuroMenuOption[]} arr2 - Second array of options.
|
|
811
|
+
* @returns {boolean} True if arrays match, false otherwise.
|
|
812
|
+
*/
|
|
813
|
+
optionsArraysMatch(arr1, arr2) {
|
|
814
|
+
if (arr1.length !== arr2.length) return false;
|
|
815
|
+
|
|
816
|
+
const set1 = new Set(arr1);
|
|
817
|
+
const set2 = new Set(arr2);
|
|
818
|
+
|
|
819
|
+
for (let item of set1) {
|
|
820
|
+
if (!set2.has(item)) {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const MenuContext = createContext('menu-context');
|
|
830
|
+
|
|
116
831
|
// Copyright (c) Alaska Air. All right reserved. Licensed under the Apache-2.0 license
|
|
117
832
|
// See LICENSE in the project root for license information.
|
|
118
833
|
|
|
@@ -200,18 +915,6 @@ class AuroLibraryRuntimeUtils {
|
|
|
200
915
|
// See LICENSE in the project root for license information.
|
|
201
916
|
|
|
202
917
|
|
|
203
|
-
/**
|
|
204
|
-
* Validates if an option can be interacted with.
|
|
205
|
-
* @private
|
|
206
|
-
* @param {HTMLElement} option - The option to check.
|
|
207
|
-
* @returns {boolean} True if option is interactive.
|
|
208
|
-
*/
|
|
209
|
-
function isOptionInteractive(option) {
|
|
210
|
-
return !option.hasAttribute('hidden') &&
|
|
211
|
-
!option.hasAttribute('disabled') &&
|
|
212
|
-
!option.hasAttribute('static');
|
|
213
|
-
}
|
|
214
|
-
|
|
215
918
|
/**
|
|
216
919
|
* Helper method to dispatch custom events.
|
|
217
920
|
* @param {HTMLElement} element - Element to dispatch event from.
|
|
@@ -232,7 +935,7 @@ function dispatchMenuEvent(element, eventName, detail = null) {
|
|
|
232
935
|
element.dispatchEvent(new CustomEvent(eventName, eventConfig));
|
|
233
936
|
}
|
|
234
937
|
|
|
235
|
-
/* eslint-disable no-underscore-dangle
|
|
938
|
+
/* eslint-disable no-underscore-dangle */
|
|
236
939
|
// Copyright (c) 2025 Alaska Airlines. All right reserved. Licensed under the Apache-2.0 license
|
|
237
940
|
// See LICENSE in the project root for license information.
|
|
238
941
|
|
|
@@ -246,12 +949,16 @@ function dispatchMenuEvent(element, eventName, detail = null) {
|
|
|
246
949
|
* @event {CustomEvent<any>} auroMenu-customEventFired - Notifies that a custom event has been fired.
|
|
247
950
|
* @event {CustomEvent<{ loading: boolean; hasLoadingPlaceholder: boolean; }>} auroMenu-loadingChange - Notifies when the loading attribute is changed.
|
|
248
951
|
* @event {CustomEvent<any>} auroMenu-selectValueFailure - Notifies that an attempt to select a menuoption by matching a value has failed.
|
|
952
|
+
* @event {CustomEvent<{ values: HTMLElement[] }>} auroMenu-deselectPrevented - Notifies that deselection was prevented and includes the affected options in `detail.values`.
|
|
249
953
|
* @event {CustomEvent<any>} auroMenu-selectValueReset - Notifies that the component value has been reset.
|
|
250
954
|
* @event {CustomEvent<any>} auroMenu-selectedOption - Notifies that a new menuoption selection has been made.
|
|
251
955
|
* @slot loadingText - Text to show while loading attribute is set
|
|
252
956
|
* @slot loadingIcon - Icon to show while loading attribute is set
|
|
253
957
|
* @slot - Slot for insertion of menu options.
|
|
254
958
|
*/
|
|
959
|
+
|
|
960
|
+
/* eslint-disable max-lines */
|
|
961
|
+
|
|
255
962
|
class AuroMenu extends AuroElement {
|
|
256
963
|
|
|
257
964
|
constructor() {
|
|
@@ -269,8 +976,6 @@ class AuroMenu extends AuroElement {
|
|
|
269
976
|
*/
|
|
270
977
|
this.size = "sm";
|
|
271
978
|
|
|
272
|
-
// Value of the selected options
|
|
273
|
-
this.value = undefined;
|
|
274
979
|
// Currently selected option
|
|
275
980
|
this.optionSelected = undefined;
|
|
276
981
|
// String used for highlighting/filtering
|
|
@@ -283,24 +988,13 @@ class AuroMenu extends AuroElement {
|
|
|
283
988
|
this.loading = false;
|
|
284
989
|
// Multi-select mode
|
|
285
990
|
this.multiSelect = false;
|
|
991
|
+
// Allow deselecting of menu options
|
|
992
|
+
this.allowDeselect = false;
|
|
993
|
+
// Select all matching options when setting value in multi-select mode
|
|
994
|
+
this.selectAllMatchingOptions = false;
|
|
286
995
|
|
|
287
996
|
// Event Bindings
|
|
288
997
|
|
|
289
|
-
/**
|
|
290
|
-
* @private
|
|
291
|
-
*/
|
|
292
|
-
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* @private
|
|
296
|
-
*/
|
|
297
|
-
this.handleMouseSelect = this.handleMouseSelect.bind(this);
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* @private
|
|
301
|
-
*/
|
|
302
|
-
this.handleOptionHover = this.handleOptionHover.bind(this);
|
|
303
|
-
|
|
304
998
|
/**
|
|
305
999
|
* @private
|
|
306
1000
|
*/
|
|
@@ -327,6 +1021,14 @@ class AuroMenu extends AuroElement {
|
|
|
327
1021
|
return {
|
|
328
1022
|
...super.properties,
|
|
329
1023
|
|
|
1024
|
+
/**
|
|
1025
|
+
* Allows deselecting an already selected option when clicked again in single-select mode.
|
|
1026
|
+
*/
|
|
1027
|
+
allowDeselect: {
|
|
1028
|
+
type: Boolean,
|
|
1029
|
+
reflect: true,
|
|
1030
|
+
},
|
|
1031
|
+
|
|
330
1032
|
/**
|
|
331
1033
|
* When true, the entire menu and all options are disabled.
|
|
332
1034
|
*/
|
|
@@ -335,6 +1037,20 @@ class AuroMenu extends AuroElement {
|
|
|
335
1037
|
reflect: true
|
|
336
1038
|
},
|
|
337
1039
|
|
|
1040
|
+
/**
|
|
1041
|
+
* Indicates whether the menu has a loadingIcon or loadingText to render when in a loading state.
|
|
1042
|
+
*/
|
|
1043
|
+
hasLoadingPlaceholder: {
|
|
1044
|
+
type: Boolean
|
|
1045
|
+
},
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* @private
|
|
1049
|
+
*/
|
|
1050
|
+
layout: {
|
|
1051
|
+
type: String
|
|
1052
|
+
},
|
|
1053
|
+
|
|
338
1054
|
/**
|
|
339
1055
|
* Indent level for submenus.
|
|
340
1056
|
* @private
|
|
@@ -395,6 +1111,44 @@ class AuroMenu extends AuroElement {
|
|
|
395
1111
|
type: Object
|
|
396
1112
|
},
|
|
397
1113
|
|
|
1114
|
+
/**
|
|
1115
|
+
* Available menu options.
|
|
1116
|
+
* @readonly
|
|
1117
|
+
*/
|
|
1118
|
+
options: {
|
|
1119
|
+
type: Array,
|
|
1120
|
+
reflect: false,
|
|
1121
|
+
attribute: false
|
|
1122
|
+
},
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Sets the size of the menu.
|
|
1126
|
+
* @type {'sm' | 'md'}
|
|
1127
|
+
* @default 'sm'
|
|
1128
|
+
*/
|
|
1129
|
+
size: {
|
|
1130
|
+
type: String,
|
|
1131
|
+
reflect: true
|
|
1132
|
+
},
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* When true, selects all options that match the provided value/key when setting value and multiselect is enabled.
|
|
1136
|
+
*/
|
|
1137
|
+
selectAllMatchingOptions: {
|
|
1138
|
+
type: Boolean,
|
|
1139
|
+
reflect: true,
|
|
1140
|
+
},
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Sets the shape of the menu.
|
|
1144
|
+
* @type {'box' | 'round'}
|
|
1145
|
+
* @default 'box'
|
|
1146
|
+
*/
|
|
1147
|
+
shape: {
|
|
1148
|
+
type: String,
|
|
1149
|
+
reflect: true
|
|
1150
|
+
},
|
|
1151
|
+
|
|
398
1152
|
/**
|
|
399
1153
|
* The value of the selected option. In multi-select mode, this is a JSON stringified array of selected option values.
|
|
400
1154
|
*/
|
|
@@ -415,23 +1169,20 @@ class AuroMenu extends AuroElement {
|
|
|
415
1169
|
}
|
|
416
1170
|
|
|
417
1171
|
/**
|
|
418
|
-
*
|
|
419
|
-
* @
|
|
420
|
-
*
|
|
421
|
-
* @example
|
|
422
|
-
* AuroMenu.register("custom-menu") // this will register this element to <custom-menu/>
|
|
423
|
-
*
|
|
1172
|
+
* @readonly
|
|
1173
|
+
* @returns {string} - Returns the label of the currently selected option(s).
|
|
424
1174
|
*/
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
1175
|
+
get currentLabel() {
|
|
1176
|
+
return this.menuService.currentLabel;
|
|
1177
|
+
};
|
|
428
1178
|
|
|
429
1179
|
/**
|
|
430
1180
|
* @readonly
|
|
431
1181
|
* @returns {Array<HTMLElement>} - Returns the array of available menu options.
|
|
1182
|
+
* @deprecated Use `options` property instead.
|
|
432
1183
|
*/
|
|
433
|
-
get
|
|
434
|
-
return this.
|
|
1184
|
+
get items() {
|
|
1185
|
+
return this.options;
|
|
435
1186
|
}
|
|
436
1187
|
|
|
437
1188
|
/**
|
|
@@ -442,101 +1193,145 @@ class AuroMenu extends AuroElement {
|
|
|
442
1193
|
}
|
|
443
1194
|
|
|
444
1195
|
/**
|
|
445
|
-
* @param {number} value - Sets the index of the currently active option.
|
|
1196
|
+
* @param {number} value - Sets the index of the currently active option.
|
|
1197
|
+
*/
|
|
1198
|
+
set index(value) {
|
|
1199
|
+
this.menuService.setHighlightedIndex(value);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* This will register this element with the browser.
|
|
1204
|
+
* @param {string} [name="auro-menu"] - The name of the element that you want to register.
|
|
1205
|
+
*
|
|
1206
|
+
* @example
|
|
1207
|
+
* AuroMenu.register("custom-menu") // this will register this element to <custom-menu/>
|
|
1208
|
+
*
|
|
1209
|
+
*/
|
|
1210
|
+
static register(name = "auro-menu") {
|
|
1211
|
+
AuroLibraryRuntimeUtils.prototype.registerComponent(name, AuroMenu);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Formatted value based on `multiSelect` state.
|
|
1216
|
+
* Default type is `String`, changing to `Array<String>` when `multiSelect` is true.
|
|
1217
|
+
* @private
|
|
1218
|
+
* @returns {String|Array<String>}
|
|
1219
|
+
*/
|
|
1220
|
+
get formattedValue() {
|
|
1221
|
+
return this.menuService.currentValue;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Gets the current property values for the menu service.
|
|
1226
|
+
* @private
|
|
1227
|
+
* @returns {Object}
|
|
446
1228
|
*/
|
|
447
|
-
|
|
448
|
-
|
|
1229
|
+
get propertyValues() {
|
|
1230
|
+
return {
|
|
1231
|
+
size: this.size,
|
|
1232
|
+
shape: this.shape,
|
|
1233
|
+
noCheckmark: this.nocheckmark,
|
|
1234
|
+
disabled: this.disabled
|
|
1235
|
+
};
|
|
449
1236
|
}
|
|
450
1237
|
|
|
451
1238
|
/**
|
|
452
|
-
*
|
|
453
|
-
*
|
|
1239
|
+
* Provides the menu context to child components.
|
|
1240
|
+
* Initializes the MenuService and subscribes to menu changes.
|
|
1241
|
+
* @protected
|
|
454
1242
|
*/
|
|
455
|
-
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
return
|
|
1243
|
+
provideContext() {
|
|
1244
|
+
if (this.parentElement && this.parentElement.closest('auro-menu, [auro-menu]')) {
|
|
1245
|
+
this.rootMenu = false;
|
|
1246
|
+
this.menuService = this.parentElement.menuService;
|
|
1247
|
+
this._contextProvider = this.parentElement._contextProvider;
|
|
1248
|
+
return;
|
|
461
1249
|
}
|
|
462
|
-
|
|
1250
|
+
|
|
1251
|
+
this.menuService = new MenuService({host: this});
|
|
1252
|
+
this.menuService.setProperties(this.propertyValues);
|
|
1253
|
+
this.menuService.subscribe(this.handleMenuChange.bind(this));
|
|
1254
|
+
this._contextProvider = new ContextProvider(this, {
|
|
1255
|
+
context: MenuContext,
|
|
1256
|
+
initialValue: this.menuService
|
|
1257
|
+
});
|
|
463
1258
|
}
|
|
464
1259
|
|
|
465
1260
|
/**
|
|
466
|
-
*
|
|
467
|
-
* @
|
|
1261
|
+
* Updates the currently active option in the menu.
|
|
1262
|
+
* @param {HTMLElement} option - The option to set as active.
|
|
468
1263
|
*/
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
return opts.length > 0 ? opts[0] : null;
|
|
1264
|
+
updateActiveOption(option) {
|
|
1265
|
+
this.menuService.setHighlightedOption(option);
|
|
472
1266
|
}
|
|
473
1267
|
|
|
474
1268
|
/**
|
|
475
|
-
*
|
|
476
|
-
* @
|
|
1269
|
+
* Sets the internal value and manages update state.
|
|
1270
|
+
* @param {String|Array<String>} value - The value to set.
|
|
1271
|
+
* @protected
|
|
477
1272
|
*/
|
|
478
|
-
|
|
479
|
-
if (
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
1273
|
+
setInternalValue(value) {
|
|
1274
|
+
if (this.value !== value) {
|
|
1275
|
+
this.internalUpdateInProgress = true;
|
|
1276
|
+
this.value = value;
|
|
1277
|
+
|
|
1278
|
+
setTimeout(() => {
|
|
1279
|
+
this.internalUpdateInProgress = false;
|
|
1280
|
+
});
|
|
484
1281
|
}
|
|
485
|
-
return this.optionSelected.textContent || '';
|
|
486
1282
|
}
|
|
487
1283
|
|
|
488
1284
|
/**
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
* @
|
|
492
|
-
* @returns {String|Array<String>}
|
|
1285
|
+
* Handles changes from the menu service and updates component state.
|
|
1286
|
+
* @param {Object} event - The event object from the menu service.
|
|
1287
|
+
* @protected
|
|
493
1288
|
*/
|
|
494
|
-
|
|
495
|
-
if (
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
return [String(this.value)];
|
|
1289
|
+
handleMenuChange(event) {
|
|
1290
|
+
if (event.type === 'valueChange') {
|
|
1291
|
+
|
|
1292
|
+
// New option is array value or first option with fallback to undefined for empty array in all cases
|
|
1293
|
+
const newOption = this.multiSelect && event.options.length ? event.options : event.options[0] || undefined;
|
|
1294
|
+
const newValue = event.stringValue;
|
|
1295
|
+
|
|
1296
|
+
// Check if the option or value has actually changed
|
|
1297
|
+
if (this.optionSelected !== newOption || this.stringValue !== newValue) {
|
|
1298
|
+
this.optionSelected = newOption;
|
|
1299
|
+
this.setInternalValue(newValue);
|
|
506
1300
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
1301
|
+
|
|
1302
|
+
// Notify components of selection change
|
|
1303
|
+
this.notifySelectionChange(event);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
if (event.type === 'highlightChange') {
|
|
1307
|
+
this.optionActive = event.option;
|
|
1308
|
+
this._index = event.index;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
if (event.type === 'optionsChange') {
|
|
1312
|
+
this.options = event.options;
|
|
1313
|
+
this.dispatchEvent(new CustomEvent('auroMenu-optionsChange', {
|
|
1314
|
+
detail: {
|
|
1315
|
+
options: event.options
|
|
515
1316
|
}
|
|
516
|
-
}
|
|
517
|
-
return [this.value];
|
|
1317
|
+
}));
|
|
518
1318
|
}
|
|
519
|
-
return this.value;
|
|
520
1319
|
}
|
|
521
1320
|
|
|
522
1321
|
/**
|
|
523
|
-
*
|
|
524
|
-
* @
|
|
525
|
-
* @public
|
|
1322
|
+
* Gets the currently selected options.
|
|
1323
|
+
* @returns {Array<HTMLElement>}
|
|
526
1324
|
*/
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
(Array.isArray(value) && value.length === 0) ||
|
|
531
|
-
(typeof value === 'string' && value.trim() === '');
|
|
532
|
-
|
|
533
|
-
if (isEmpty) {
|
|
534
|
-
this.clearSelection();
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
1325
|
+
get selectedOptions() {
|
|
1326
|
+
return this.menuService ? this.menuService.selectedOptions : [];
|
|
1327
|
+
}
|
|
537
1328
|
|
|
538
|
-
|
|
539
|
-
|
|
1329
|
+
/**
|
|
1330
|
+
* Gets the first selected option, or null if none.
|
|
1331
|
+
* @returns {HTMLElement|null}
|
|
1332
|
+
*/
|
|
1333
|
+
get selectedOption() {
|
|
1334
|
+
return this.menuService ? this.menuService.selectedOptions[0] : null;
|
|
540
1335
|
}
|
|
541
1336
|
|
|
542
1337
|
// Lifecycle Methods
|
|
@@ -544,7 +1339,9 @@ class AuroMenu extends AuroElement {
|
|
|
544
1339
|
connectedCallback() {
|
|
545
1340
|
super.connectedCallback();
|
|
546
1341
|
|
|
547
|
-
this.
|
|
1342
|
+
this.provideContext();
|
|
1343
|
+
|
|
1344
|
+
// this.addEventListener('keydown', this.handleKeyDown);
|
|
548
1345
|
this.addEventListener('auroMenuOption-click', this.handleMouseSelect);
|
|
549
1346
|
this.addEventListener('auroMenuOption-mouseover', this.handleOptionHover);
|
|
550
1347
|
this.addEventListener('slotchange', this.handleSlotChange);
|
|
@@ -552,7 +1349,7 @@ class AuroMenu extends AuroElement {
|
|
|
552
1349
|
}
|
|
553
1350
|
|
|
554
1351
|
disconnectedCallback() {
|
|
555
|
-
this.removeEventListener('keydown', this.handleKeyDown);
|
|
1352
|
+
// this.removeEventListener('keydown', this.handleKeyDown);
|
|
556
1353
|
this.removeEventListener('auroMenuOption-click', this.handleMouseSelect);
|
|
557
1354
|
this.removeEventListener('auroMenuOption-mouseover', this.handleOptionHover);
|
|
558
1355
|
this.removeEventListener('slotchange', this.handleSlotChange);
|
|
@@ -567,114 +1364,25 @@ class AuroMenu extends AuroElement {
|
|
|
567
1364
|
this.initializeMenu();
|
|
568
1365
|
}
|
|
569
1366
|
|
|
570
|
-
/**
|
|
571
|
-
* Sets an attribute that matches the default tag name if the tag name is not the default.
|
|
572
|
-
* @param {string} tagName - The tag name to set as an attribute.
|
|
573
|
-
* @private
|
|
574
|
-
*/
|
|
575
|
-
setTagAttribute(tagName) {
|
|
576
|
-
if (this.tagName.toLowerCase() !== tagName) {
|
|
577
|
-
this.setAttribute(tagName, true);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
1367
|
|
|
581
|
-
// eslint-disable-next-line complexity
|
|
582
1368
|
updated(changedProperties) {
|
|
583
1369
|
super.updated(changedProperties);
|
|
584
1370
|
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
// Reset selection if multiSelect mode changes
|
|
595
|
-
if (changedProperties.has('multiSelect') && !changedProperties.has("value")) {
|
|
596
|
-
this.clearSelection();
|
|
1371
|
+
// Apply value selection synchronously so that static-HTML fixtures
|
|
1372
|
+
// resolve within a single update cycle. The refactored selectByValue
|
|
1373
|
+
// no longer calls reset() first, so the destructive intermediate-event
|
|
1374
|
+
// cascade that originally required deferral is eliminated. If option
|
|
1375
|
+
// keys are not yet resolved (framework mount-order race), selectByValue
|
|
1376
|
+
// queues a bounded retry automatically via queuePendingValue.
|
|
1377
|
+
if (changedProperties.has('value') && !this.internalUpdateInProgress) {
|
|
1378
|
+
this.menuService.selectByValue(this.value);
|
|
597
1379
|
}
|
|
598
1380
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
// value before children render). Without this guard, matching against an empty `items`
|
|
603
|
-
// would falsely dispatch `auroMenu-selectValueFailure` for valid initial values.
|
|
604
|
-
if (!this.items) {
|
|
605
|
-
this.initItems();
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Handle null/undefined/empty case — empty/whitespace strings clear selection
|
|
609
|
-
// consistently with selectByValue(''), and avoid downstream `.includes('')` matches.
|
|
610
|
-
if (this.value === undefined || this.value === null || (typeof this.value === 'string' && this.value.trim() === '')) {
|
|
611
|
-
this.clearSelection();
|
|
612
|
-
} else {
|
|
613
|
-
let newSelected = null;
|
|
614
|
-
|
|
615
|
-
if (this.multiSelect) {
|
|
616
|
-
// In multiselect mode, this.value should be an array of strings.
|
|
617
|
-
// Defensive default: `formattedValue` can be undefined for unexpected value types,
|
|
618
|
-
// and calling `.includes` on undefined would throw during reconciliation.
|
|
619
|
-
const valueArray = this.formattedValue || [];
|
|
620
|
-
const matchingOptions = this.items ? this.items.filter((item) => valueArray.includes(item.value)) : [];
|
|
621
|
-
newSelected = matchingOptions.length > 0 ? matchingOptions : undefined;
|
|
622
|
-
} else {
|
|
623
|
-
// In single-select mode, this.value should be a string
|
|
624
|
-
const matchingOption = this.items ? this.items.find((item) => item.value === this.value) : undefined;
|
|
625
|
-
|
|
626
|
-
if (matchingOption) {
|
|
627
|
-
newSelected = matchingOption;
|
|
628
|
-
this._index = this.items.indexOf(matchingOption);
|
|
629
|
-
} else {
|
|
630
|
-
// If no matching option found, reset selection
|
|
631
|
-
newSelected = undefined;
|
|
632
|
-
this._index = -1;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// If no matching options were found in either mode
|
|
637
|
-
if (!newSelected || (Array.isArray(newSelected) && newSelected.length === 0)) {
|
|
638
|
-
// Defer failure when no options are loaded yet (async pattern: parent sets
|
|
639
|
-
// value before slotted options render). handleSlotChange re-runs matching
|
|
640
|
-
// once items arrive. Without this guard, a valid preselected value gets
|
|
641
|
-
// cleared by the failure listener before options ever exist to match against.
|
|
642
|
-
const hasItemsToMatch = this.items && this.items.length > 0;
|
|
643
|
-
if (hasItemsToMatch) {
|
|
644
|
-
// Clear state BEFORE dispatching so synchronous listeners (e.g. auro-select's
|
|
645
|
-
// updateDisplayedValue) read fresh `optionSelected` rather than the stale prior
|
|
646
|
-
// selection and re-render the old label.
|
|
647
|
-
if (this.optionSelected !== undefined) {
|
|
648
|
-
this.optionSelected = undefined;
|
|
649
|
-
}
|
|
650
|
-
this._index = -1;
|
|
651
|
-
dispatchMenuEvent(this, 'auroMenu-selectValueFailure');
|
|
652
|
-
}
|
|
653
|
-
} else if (!this.selectionEquals(this.optionSelected, newSelected)) {
|
|
654
|
-
this.optionSelected = newSelected;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// Update UI state
|
|
659
|
-
this.updateItemsState(new Map([
|
|
660
|
-
[
|
|
661
|
-
'optionSelected',
|
|
662
|
-
true
|
|
663
|
-
]
|
|
664
|
-
]));
|
|
665
|
-
|
|
666
|
-
// Notify of changes
|
|
667
|
-
if (this.optionSelected !== undefined) {
|
|
668
|
-
const selected = Array.isArray(this.optionSelected) ? this.optionSelected : [this.optionSelected];
|
|
669
|
-
selected.forEach((opt) => {
|
|
670
|
-
if (opt.hasAttribute('event')) {
|
|
671
|
-
this.handleCustomEvent(opt);
|
|
672
|
-
}
|
|
673
|
-
});
|
|
674
|
-
}
|
|
1381
|
+
// Handle loading state changes
|
|
1382
|
+
if (changedProperties.has('loading')) {
|
|
1383
|
+
this.setLoadingState(this.loading);
|
|
675
1384
|
}
|
|
676
1385
|
|
|
677
|
-
// Process all other UI updates
|
|
678
1386
|
if (changedProperties.has('multiSelect') && this.rootMenu) {
|
|
679
1387
|
if (this.multiSelect) {
|
|
680
1388
|
this.setAttribute('aria-multiselectable', 'true');
|
|
@@ -682,129 +1390,30 @@ class AuroMenu extends AuroElement {
|
|
|
682
1390
|
this.removeAttribute('aria-multiselectable');
|
|
683
1391
|
}
|
|
684
1392
|
}
|
|
685
|
-
|
|
686
|
-
this.updateItemsState(changedProperties);
|
|
687
1393
|
}
|
|
688
1394
|
|
|
689
1395
|
/**
|
|
690
|
-
*
|
|
1396
|
+
* Sets an attribute that matches the default tag name if the tag name is not the default.
|
|
1397
|
+
* @param {string} tagName - The tag name to set as an attribute.
|
|
691
1398
|
* @private
|
|
692
|
-
* @param {Map<string, boolean>} changedProperties - LitElement's changed properties map.
|
|
693
1399
|
*/
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
this.setAttribute("aria-busy", this.loading);
|
|
698
|
-
dispatchMenuEvent(this, "auroMenu-loadingChange", {
|
|
699
|
-
loading: this.loading,
|
|
700
|
-
hasLoadingPlaceholder: this.hasLoadingPlaceholder
|
|
701
|
-
});
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
if (!this.items) {
|
|
705
|
-
return;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// Handle noCheckmark propagation to all menus and options.
|
|
709
|
-
// Propagate in both directions so toggling back to false also clears nested elements
|
|
710
|
-
// (otherwise nested menus/options would stay stuck in no-checkmark mode).
|
|
711
|
-
if (changedProperties.has('noCheckmark')) {
|
|
712
|
-
this.querySelectorAll('auro-menu, [auro-menu], auro-menuoption, [auro-menuoption]').forEach((element) => {
|
|
713
|
-
element.noCheckmark = this.noCheckmark;
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// Handle layout propagation to all menus and options.
|
|
718
|
-
// Skip elements that had size/shape set by the author (marked in initItems);
|
|
719
|
-
// explicit per-option overrides must survive menu-level propagation.
|
|
720
|
-
const propagationTargets = this.querySelectorAll('auro-menu, [auro-menu], auro-menuoption, [auro-menuoption]');
|
|
721
|
-
[
|
|
722
|
-
'size',
|
|
723
|
-
'shape'
|
|
724
|
-
].forEach((prop) => {
|
|
725
|
-
if (changedProperties.has(prop)) {
|
|
726
|
-
const explicitKey = prop === 'size' ? '_explicitSize' : '_explicitShape';
|
|
727
|
-
propagationTargets.forEach((el) => {
|
|
728
|
-
if (el[explicitKey]) {
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
el.setAttribute(prop, this[prop]);
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
// Regex for matchWord if needed
|
|
737
|
-
let regexWord = null;
|
|
738
|
-
|
|
739
|
-
if (changedProperties.has('matchWord') && this.matchWord && this.matchWord.length) {
|
|
740
|
-
const escapedWord = this.matchWord.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
741
|
-
regexWord = new RegExp(escapedWord, 'giu');
|
|
1400
|
+
setTagAttribute(tagName) {
|
|
1401
|
+
if (this.tagName.toLowerCase() !== tagName) {
|
|
1402
|
+
this.setAttribute(tagName, true);
|
|
742
1403
|
}
|
|
1404
|
+
}
|
|
743
1405
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
} else {
|
|
755
|
-
option.removeAttribute('selected');
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// Update text highlighting if matchWord changed
|
|
760
|
-
if (changedProperties.has('matchWord') && regexWord &&
|
|
761
|
-
isOptionInteractive(option) && !option.hasAttribute('persistent')) {
|
|
762
|
-
// Create nested spacers
|
|
763
|
-
const nested = option.querySelectorAll('.nestingSpacer');
|
|
764
|
-
|
|
765
|
-
const displayValueEl = option.querySelector('[slot="displayValue"]');
|
|
766
|
-
if (displayValueEl) {
|
|
767
|
-
option.removeChild(displayValueEl);
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// Build highlighted content via DOM APIs rather than innerHTML so any
|
|
771
|
-
// `<`, `>`, or `&` in the option text renders literally (prevents XSS).
|
|
772
|
-
const originalText = option.textContent;
|
|
773
|
-
option.textContent = '';
|
|
774
|
-
|
|
775
|
-
nested.forEach(() => {
|
|
776
|
-
const spacer = document.createElement('span');
|
|
777
|
-
spacer.className = 'nestingSpacer';
|
|
778
|
-
option.appendChild(spacer);
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
const matches = [...originalText.matchAll(regexWord)];
|
|
782
|
-
let lastIndex = 0;
|
|
783
|
-
matches.forEach((match) => {
|
|
784
|
-
const [matchText] = match;
|
|
785
|
-
if (match.index > lastIndex) {
|
|
786
|
-
option.appendChild(document.createTextNode(originalText.slice(lastIndex, match.index)));
|
|
787
|
-
}
|
|
788
|
-
const strong = document.createElement('strong');
|
|
789
|
-
strong.textContent = matchText;
|
|
790
|
-
option.appendChild(strong);
|
|
791
|
-
lastIndex = match.index + matchText.length;
|
|
792
|
-
});
|
|
793
|
-
if (lastIndex < originalText.length) {
|
|
794
|
-
option.appendChild(document.createTextNode(originalText.slice(lastIndex)));
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
if (displayValueEl) {
|
|
798
|
-
option.append(displayValueEl);
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
// Update disabled state
|
|
803
|
-
if (changedProperties.has('disabled')) {
|
|
804
|
-
option.disabled = this.disabled;
|
|
805
|
-
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Sets the loading state and dispatches a loading change event.
|
|
1408
|
+
* @param {boolean} isLoading - Whether the menu is loading.
|
|
1409
|
+
* @protected
|
|
1410
|
+
*/
|
|
1411
|
+
setLoadingState(isLoading) {
|
|
1412
|
+
this.setAttribute("aria-busy", isLoading);
|
|
1413
|
+
dispatchMenuEvent(this, "auroMenu-loadingChange", {
|
|
1414
|
+
loading: isLoading,
|
|
1415
|
+
hasLoadingPlaceholder: this.hasLoadingPlaceholder
|
|
806
1416
|
});
|
|
807
|
-
|
|
808
1417
|
}
|
|
809
1418
|
|
|
810
1419
|
// Init Methods
|
|
@@ -814,7 +1423,6 @@ class AuroMenu extends AuroElement {
|
|
|
814
1423
|
* @private
|
|
815
1424
|
*/
|
|
816
1425
|
initializeMenu() {
|
|
817
|
-
this.initItems();
|
|
818
1426
|
if (this.rootMenu) {
|
|
819
1427
|
this.setAttribute('role', 'listbox');
|
|
820
1428
|
this.setAttribute('root', '');
|
|
@@ -824,117 +1432,15 @@ class AuroMenu extends AuroElement {
|
|
|
824
1432
|
}
|
|
825
1433
|
}
|
|
826
1434
|
|
|
827
|
-
// Must run for nested menus too — sets level, role="group", and aria-label="submenu" based on parent.
|
|
828
1435
|
this.handleNestedMenus(this);
|
|
829
1436
|
}
|
|
830
1437
|
|
|
831
1438
|
/**
|
|
832
|
-
*
|
|
833
|
-
* @
|
|
834
|
-
*/
|
|
835
|
-
initItems() {
|
|
836
|
-
const found = Array.from(this.querySelectorAll('auro-menuoption, [auro-menuoption]'));
|
|
837
|
-
this.items = found.length > 0 ? found : undefined;
|
|
838
|
-
|
|
839
|
-
// Record whether each propagation target had an author-set size/shape attribute
|
|
840
|
-
// BEFORE menu has had a chance to propagate. Marker is set once per element so a
|
|
841
|
-
// later menu-driven setAttribute doesn't re-flag the element as "explicit".
|
|
842
|
-
this.querySelectorAll('auro-menu, [auro-menu], auro-menuoption, [auro-menuoption]').forEach((el) => {
|
|
843
|
-
if (el._explicitSize === undefined) {
|
|
844
|
-
el._explicitSize = el.hasAttribute('size');
|
|
845
|
-
}
|
|
846
|
-
if (el._explicitShape === undefined) {
|
|
847
|
-
el._explicitShape = el.hasAttribute('shape');
|
|
848
|
-
}
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
if (this.noCheckmark) {
|
|
852
|
-
this.updateItemsState(new Map([
|
|
853
|
-
[
|
|
854
|
-
'noCheckmark',
|
|
855
|
-
true
|
|
856
|
-
]
|
|
857
|
-
]));
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
this.dispatchEvent(new CustomEvent('auroMenu-optionsChange', {
|
|
861
|
-
detail: {
|
|
862
|
-
options: this.items
|
|
863
|
-
}
|
|
864
|
-
}));
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
// Logic Methods
|
|
868
|
-
|
|
869
|
-
/**
|
|
870
|
-
* Updates menu state when an option is selected.
|
|
871
|
-
* @private
|
|
872
|
-
* @param {HTMLElement} option - The option element to select.
|
|
873
|
-
*/
|
|
874
|
-
handleSelectState(option) {
|
|
875
|
-
if (this.multiSelect) {
|
|
876
|
-
const currentValue = this.formattedValue || [];
|
|
877
|
-
const currentSelected = this.optionSelected || [];
|
|
878
|
-
|
|
879
|
-
if (!currentValue.includes(option.value)) {
|
|
880
|
-
this.value = JSON.stringify([
|
|
881
|
-
...currentValue,
|
|
882
|
-
option.value
|
|
883
|
-
]);
|
|
884
|
-
}
|
|
885
|
-
if (!currentSelected.includes(option)) {
|
|
886
|
-
this.optionSelected = [
|
|
887
|
-
...currentSelected,
|
|
888
|
-
option
|
|
889
|
-
];
|
|
890
|
-
}
|
|
891
|
-
} else {
|
|
892
|
-
this.value = option.value;
|
|
893
|
-
this.optionSelected = option;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
this._index = this.items.indexOf(option);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
/**
|
|
900
|
-
* Deselects a menu option and updates related state.
|
|
901
|
-
* @private
|
|
902
|
-
* @param {HTMLElement} option - The menuoption to be deselected.
|
|
1439
|
+
* Selects the currently highlighted option.
|
|
1440
|
+
* @protected
|
|
903
1441
|
*/
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
// Remove this option from array
|
|
907
|
-
const newFormattedValue = (this.formattedValue || []).filter((val) => val !== option.value);
|
|
908
|
-
|
|
909
|
-
// If array is empty after removal, set back to undefined
|
|
910
|
-
if (newFormattedValue && newFormattedValue.length === 0) {
|
|
911
|
-
this.value = undefined;
|
|
912
|
-
} else {
|
|
913
|
-
this.value = JSON.stringify(newFormattedValue);
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
this.optionSelected = this.optionSelected.filter((val) => val !== option);
|
|
917
|
-
if (this.optionSelected.length === 0) {
|
|
918
|
-
this.optionSelected = undefined;
|
|
919
|
-
}
|
|
920
|
-
} else {
|
|
921
|
-
// For single-select: Back to undefined when deselected
|
|
922
|
-
this.value = undefined;
|
|
923
|
-
this.optionSelected = undefined;
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
// Update the index tracking
|
|
927
|
-
this._index = this.items.indexOf(option);
|
|
928
|
-
|
|
929
|
-
// Update UI to reflect changes
|
|
930
|
-
this.updateItemsState(new Map([
|
|
931
|
-
[
|
|
932
|
-
'optionSelected',
|
|
933
|
-
true
|
|
934
|
-
]
|
|
935
|
-
]));
|
|
936
|
-
|
|
937
|
-
// Notification happens via updated() when optionSelected changes above.
|
|
1442
|
+
makeSelection() {
|
|
1443
|
+
this.menuService.selectHighlightedOption();
|
|
938
1444
|
}
|
|
939
1445
|
|
|
940
1446
|
/**
|
|
@@ -953,18 +1459,7 @@ class AuroMenu extends AuroElement {
|
|
|
953
1459
|
* @public
|
|
954
1460
|
*/
|
|
955
1461
|
reset() {
|
|
956
|
-
|
|
957
|
-
this.value = undefined;
|
|
958
|
-
this.optionSelected = undefined;
|
|
959
|
-
this._index = -1;
|
|
960
|
-
|
|
961
|
-
// Reset UI state
|
|
962
|
-
this.updateItemsState(new Map([
|
|
963
|
-
[
|
|
964
|
-
'optionSelected',
|
|
965
|
-
true
|
|
966
|
-
]
|
|
967
|
-
]));
|
|
1462
|
+
this.menuService.reset();
|
|
968
1463
|
|
|
969
1464
|
// Dispatch reset event
|
|
970
1465
|
dispatchMenuEvent(this, 'auroMenu-selectValueReset');
|
|
@@ -976,239 +1471,44 @@ class AuroMenu extends AuroElement {
|
|
|
976
1471
|
* @param {HTMLElement} menu - Root menu element.
|
|
977
1472
|
*/
|
|
978
1473
|
handleNestedMenus(menu) {
|
|
979
|
-
// Slot changes can fire on a menu mid-teardown (e.g. while a parent menu
|
|
980
|
-
// is removing children to rebuild its content). In that window the menu
|
|
981
|
-
// is detached and parentElement is null. Skip — handleNestedMenus will
|
|
982
|
-
// run again when the menu is reattached.
|
|
983
|
-
if (!menu.parentElement) {
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
1474
|
menu.level = menu.parentElement.level >= 0 ? menu.parentElement.level + 1 : 0;
|
|
988
1475
|
|
|
989
|
-
if (menu.level > 0) {
|
|
990
|
-
menu.setAttribute('role', 'group');
|
|
991
|
-
menu.removeAttribute("root");
|
|
992
|
-
if (!menu.hasAttribute('aria-label')) {
|
|
993
|
-
menu.setAttribute('aria-label', 'submenu');
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
const options = menu.querySelectorAll(':scope > auro-menuoption, :scope > [auro-menuoption]');
|
|
998
|
-
options.forEach((option) => {
|
|
999
|
-
const regex = new RegExp(this.nestingSpacer, "gu");
|
|
1000
|
-
option.innerHTML = this.nestingSpacer.repeat(menu.level) + option.innerHTML.replace(regex, '');
|
|
1001
|
-
});
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// Event Handlers
|
|
1005
|
-
|
|
1006
|
-
/**
|
|
1007
|
-
* Makes a selection based on the current index.
|
|
1008
|
-
* @private
|
|
1009
|
-
*/
|
|
1010
|
-
makeSelection() {
|
|
1011
|
-
if (!this.items) {
|
|
1012
|
-
this.initItems();
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
// Get currently selected menu option based on index
|
|
1016
|
-
const option = this.items ? this.items[this._index] : undefined;
|
|
1017
|
-
|
|
1018
|
-
// Return early if option is not interactive
|
|
1019
|
-
if (!option || !isOptionInteractive(option)) {
|
|
1020
|
-
return;
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
// Handle custom events first
|
|
1024
|
-
if (option.hasAttribute('event')) {
|
|
1025
|
-
this.handleCustomEvent(option);
|
|
1026
|
-
return;
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
if (this.multiSelect) {
|
|
1030
|
-
// In multiselect, toggle individual selections
|
|
1031
|
-
this.toggleOption(option);
|
|
1032
|
-
// In single select, only handle selection of new options
|
|
1033
|
-
} else if (!this.isOptionSelected(option)) {
|
|
1034
|
-
this.clearSelection();
|
|
1035
|
-
this.handleSelectState(option);
|
|
1036
|
-
} else {
|
|
1037
|
-
// Re-selecting the already-selected option in single-select doesn't change
|
|
1038
|
-
// state, so updated() won't fire. Notify explicitly so consumers (e.g.
|
|
1039
|
-
// auro-select closing its dropdown on Enter) still get the event.
|
|
1040
|
-
this.notifySelectionChange();
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
/**
|
|
1045
|
-
* Toggle the selection state of the menuoption.
|
|
1046
|
-
* @private
|
|
1047
|
-
* @param {HTMLElement} option - The menuoption to toggle.
|
|
1048
|
-
*/
|
|
1049
|
-
toggleOption(option) {
|
|
1050
|
-
const isCurrentlySelected = this.isOptionSelected(option);
|
|
1051
|
-
|
|
1052
|
-
if (isCurrentlySelected) {
|
|
1053
|
-
this.handleDeselectState(option);
|
|
1054
|
-
} else if (option.value === undefined || option.value === '') {
|
|
1055
|
-
dispatchMenuEvent(this, 'auroMenu-selectValueFailure');
|
|
1056
|
-
} else {
|
|
1057
|
-
this.handleSelectState(option);
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
/**
|
|
1062
|
-
* Handles keyboard navigation and selection.
|
|
1063
|
-
* @private
|
|
1064
|
-
* @param {KeyboardEvent} event - The keydown event.
|
|
1065
|
-
*/
|
|
1066
|
-
handleKeyDown(event) {
|
|
1067
|
-
switch (event.key) {
|
|
1068
|
-
case "ArrowDown":
|
|
1069
|
-
event.preventDefault();
|
|
1070
|
-
this.navigateOptions('down');
|
|
1071
|
-
break;
|
|
1072
|
-
case "ArrowUp":
|
|
1073
|
-
event.preventDefault();
|
|
1074
|
-
this.navigateOptions('up');
|
|
1075
|
-
break;
|
|
1076
|
-
case "Tab":
|
|
1077
|
-
// Do not preventDefault on Tab so focus can move out of the menu (a11y: avoid trapping keyboard users).
|
|
1078
|
-
this.makeSelection();
|
|
1079
|
-
break;
|
|
1080
|
-
case "Enter":
|
|
1081
|
-
event.preventDefault();
|
|
1082
|
-
this.makeSelection();
|
|
1083
|
-
break;
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
/**
|
|
1088
|
-
* Handles option selection via click events from menuoptions.
|
|
1089
|
-
* @private
|
|
1090
|
-
* @param {CustomEvent} event - The auroMenuOption-click event.
|
|
1091
|
-
*/
|
|
1092
|
-
handleMouseSelect(event) {
|
|
1093
|
-
if (!this.rootMenu || this.disabled) {
|
|
1094
|
-
return;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
const option = event.detail;
|
|
1098
|
-
if (option && this.items) {
|
|
1099
|
-
const idx = this.items.indexOf(option);
|
|
1100
|
-
if (idx >= 0) {
|
|
1101
|
-
this._index = idx;
|
|
1102
|
-
this.makeSelection();
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
/**
|
|
1108
|
-
* Handles option hover events.
|
|
1109
|
-
* @private
|
|
1110
|
-
* @param {CustomEvent} event - Event object from the browser.
|
|
1111
|
-
*/
|
|
1112
|
-
handleOptionHover(event) {
|
|
1113
|
-
const option = event.detail;
|
|
1114
|
-
if (this.items) {
|
|
1115
|
-
const idx = this.items.indexOf(option);
|
|
1116
|
-
if (idx >= 0) {
|
|
1117
|
-
this.updateActiveOption(idx);
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
/**
|
|
1123
|
-
* Handles slot change events.
|
|
1124
|
-
* @private
|
|
1125
|
-
*/
|
|
1126
|
-
handleSlotChange() {
|
|
1127
|
-
if (this.parentElement && this.parentElement.closest('auro-menu, [auro-menu]')) {
|
|
1128
|
-
this.rootMenu = false;
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
// Nested menus must also reinitialize so items, level, role="group", and aria-label refresh on content changes.
|
|
1132
|
-
// Root-specific attributes (listbox/root/aria-multiselectable) remain gated by `rootMenu` inside initializeMenu.
|
|
1133
|
-
this.initializeMenu();
|
|
1134
|
-
|
|
1135
|
-
// When options arrive after `value` was set (async option load), re-run matching
|
|
1136
|
-
// against the now-populated items. The earlier updated('value') call deferred
|
|
1137
|
-
// the failure dispatch because items were empty; this triggers the match now.
|
|
1138
|
-
const hasPendingValue = this.value !== undefined &&
|
|
1139
|
-
this.value !== null &&
|
|
1140
|
-
!(typeof this.value === 'string' && this.value.trim() === '');
|
|
1141
|
-
if (hasPendingValue && this.items && this.items.length > 0 && this.optionSelected === undefined) {
|
|
1142
|
-
this.requestUpdate('value', undefined);
|
|
1476
|
+
if (menu.level > 0) {
|
|
1477
|
+
menu.setAttribute('role', 'group');
|
|
1478
|
+
menu.removeAttribute("root");
|
|
1479
|
+
if (!menu.hasAttribute('aria-label')) {
|
|
1480
|
+
menu.setAttribute('aria-label', 'submenu');
|
|
1481
|
+
}
|
|
1143
1482
|
}
|
|
1483
|
+
|
|
1484
|
+
const options = menu.querySelectorAll(':scope > auro-menuoption, :scope > [auro-menuoption]');
|
|
1485
|
+
options.forEach((option) => {
|
|
1486
|
+
const regex = new RegExp(this.nestingSpacer, "gu");
|
|
1487
|
+
option.innerHTML = this.nestingSpacer.repeat(menu.level) + option.innerHTML.replace(regex, '');
|
|
1488
|
+
});
|
|
1144
1489
|
}
|
|
1145
1490
|
|
|
1146
1491
|
/**
|
|
1147
|
-
* Navigates
|
|
1148
|
-
* @param {
|
|
1492
|
+
* Navigates the menu options in the specified direction.
|
|
1493
|
+
* @param {'up'|'down'} direction - The direction to navigate.
|
|
1494
|
+
* @protected
|
|
1149
1495
|
*/
|
|
1150
1496
|
navigateOptions(direction) {
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
let newIndex = this._index;
|
|
1157
|
-
if (newIndex === -1 && direction === 'up') {
|
|
1158
|
-
newIndex = this.items.length;
|
|
1159
|
-
}
|
|
1160
|
-
const increment = direction === 'down' ? 1 : -1;
|
|
1161
|
-
const maxIterations = this.items.length;
|
|
1162
|
-
let iterations = 0;
|
|
1163
|
-
let foundInteractiveOption = false;
|
|
1164
|
-
|
|
1165
|
-
do {
|
|
1166
|
-
newIndex = (newIndex + increment + this.items.length) % this.items.length;
|
|
1167
|
-
iterations += 1;
|
|
1168
|
-
|
|
1169
|
-
// Check if current option is interactive
|
|
1170
|
-
const currentOption = this.items[newIndex];
|
|
1171
|
-
if (isOptionInteractive(currentOption)) {
|
|
1172
|
-
foundInteractiveOption = true;
|
|
1173
|
-
break;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
// Break if all options were checked
|
|
1177
|
-
if (iterations >= maxIterations) {
|
|
1178
|
-
break;
|
|
1179
|
-
}
|
|
1180
|
-
} while (iterations < maxIterations);
|
|
1181
|
-
|
|
1182
|
-
// Update only if an interactive option was found
|
|
1183
|
-
if (foundInteractiveOption) {
|
|
1184
|
-
this.updateActiveOption(newIndex);
|
|
1497
|
+
if (direction === 'up') {
|
|
1498
|
+
this.menuService.highlightPrevious();
|
|
1499
|
+
} else if (direction === 'down') {
|
|
1500
|
+
this.menuService.highlightNext();
|
|
1185
1501
|
}
|
|
1186
1502
|
}
|
|
1187
1503
|
|
|
1188
1504
|
/**
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
* @param {number|HTMLElement} indexOrOption - Index of the option or the option element to make active.
|
|
1505
|
+
* Handles slot change events.
|
|
1506
|
+
* @private
|
|
1192
1507
|
*/
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
if (typeof indexOrOption === 'number') {
|
|
1197
|
-
idx = indexOrOption;
|
|
1198
|
-
} else {
|
|
1199
|
-
idx = this.items ? this.items.indexOf(indexOrOption) : -1;
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
if (!this.items || !this.items[idx]) {
|
|
1203
|
-
return;
|
|
1508
|
+
handleSlotChange() {
|
|
1509
|
+
if (this.rootMenu) {
|
|
1510
|
+
this.initializeMenu();
|
|
1204
1511
|
}
|
|
1205
|
-
|
|
1206
|
-
this.items.forEach((item) => item.classList.remove('active'));
|
|
1207
|
-
this.items[idx].classList.add('active');
|
|
1208
|
-
this.optionActive = this.items[idx];
|
|
1209
|
-
this._index = idx;
|
|
1210
|
-
|
|
1211
|
-
dispatchMenuEvent(this, 'auroMenu-activatedOption', this.items[idx]);
|
|
1212
1512
|
}
|
|
1213
1513
|
|
|
1214
1514
|
/**
|
|
@@ -1218,8 +1518,8 @@ class AuroMenu extends AuroElement {
|
|
|
1218
1518
|
*/
|
|
1219
1519
|
handleCustomEvent(option) {
|
|
1220
1520
|
const eventName = option.getAttribute('event');
|
|
1221
|
-
dispatchMenuEvent(this, eventName
|
|
1222
|
-
dispatchMenuEvent(this, 'auroMenu-customEventFired'
|
|
1521
|
+
dispatchMenuEvent(this, eventName);
|
|
1522
|
+
dispatchMenuEvent(this, 'auroMenu-customEventFired');
|
|
1223
1523
|
}
|
|
1224
1524
|
|
|
1225
1525
|
/**
|
|
@@ -1227,33 +1527,14 @@ class AuroMenu extends AuroElement {
|
|
|
1227
1527
|
* @param {any} source - The source that triggers this event.
|
|
1228
1528
|
* @private
|
|
1229
1529
|
*/
|
|
1230
|
-
notifySelectionChange(
|
|
1231
|
-
dispatchMenuEvent(this, 'auroMenu-selectedOption', {
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
* @returns {boolean} Whether the selections are equal.
|
|
1239
|
-
*/
|
|
1240
|
-
selectionEquals(current, next) {
|
|
1241
|
-
if (current === next) {
|
|
1242
|
-
return true;
|
|
1243
|
-
}
|
|
1244
|
-
if (!current || !next) {
|
|
1245
|
-
return false;
|
|
1246
|
-
}
|
|
1247
|
-
if (Array.isArray(current) && Array.isArray(next)) {
|
|
1248
|
-
if (current.length !== next.length) {
|
|
1249
|
-
return false;
|
|
1250
|
-
}
|
|
1251
|
-
// Compare as sets — selection order may differ from DOM order after value-driven
|
|
1252
|
-
// reconciliation, but the selected set is what matters for change detection.
|
|
1253
|
-
const nextSet = new Set(next);
|
|
1254
|
-
return current.every((item) => nextSet.has(item));
|
|
1255
|
-
}
|
|
1256
|
-
return false;
|
|
1530
|
+
notifySelectionChange({value, stringValue, keys, options, reason} = {}) {
|
|
1531
|
+
dispatchMenuEvent(this, 'auroMenu-selectedOption', {
|
|
1532
|
+
value,
|
|
1533
|
+
stringValue,
|
|
1534
|
+
keys,
|
|
1535
|
+
options,
|
|
1536
|
+
reason
|
|
1537
|
+
});
|
|
1257
1538
|
}
|
|
1258
1539
|
|
|
1259
1540
|
/**
|
|
@@ -1268,6 +1549,7 @@ class AuroMenu extends AuroElement {
|
|
|
1268
1549
|
}
|
|
1269
1550
|
|
|
1270
1551
|
if (this.multiSelect) {
|
|
1552
|
+
// In multi-select mode, check if the option is in the selected array
|
|
1271
1553
|
return Array.isArray(this.optionSelected) && this.optionSelected.some((selectedOption) => selectedOption === option);
|
|
1272
1554
|
}
|
|
1273
1555
|
|
|
@@ -1410,12 +1692,37 @@ let menuOptionIdCounter = 0;
|
|
|
1410
1692
|
* @slot default - The default slot for the menu option text.
|
|
1411
1693
|
*
|
|
1412
1694
|
* @event auroMenuOption-mouseover - Notifies that this option has been hovered over.
|
|
1413
|
-
* @event auroMenuOption-click - Notifies that this option has been clicked.
|
|
1414
1695
|
*/
|
|
1415
1696
|
class AuroMenuOption extends AuroElement {
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* This will register this element with the browser.
|
|
1700
|
+
* @param {string} [name="auro-menuoption"] - The name of the element that you want to register.
|
|
1701
|
+
*
|
|
1702
|
+
* @example
|
|
1703
|
+
* AuroMenuOption.register("custom-menuoption") // this will register this element to <custom-menuoption/>
|
|
1704
|
+
*
|
|
1705
|
+
*/
|
|
1706
|
+
static register(name = "auro-menuoption") {
|
|
1707
|
+
AuroLibraryRuntimeUtils.prototype.registerComponent(name, AuroMenuOption);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* Returns whether the menu option is currently active and selectable.
|
|
1712
|
+
* An option is considered active if it is not hidden, not disabled, and not static.
|
|
1713
|
+
* @returns {boolean} True if the option is active, false otherwise.
|
|
1714
|
+
*/
|
|
1715
|
+
get isActive() {
|
|
1716
|
+
return !this.hasAttribute('hidden') &&
|
|
1717
|
+
!this.disabled &&
|
|
1718
|
+
!this.hasAttribute('static');
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1416
1721
|
constructor() {
|
|
1417
1722
|
super();
|
|
1418
1723
|
|
|
1724
|
+
this.bindEvents();
|
|
1725
|
+
|
|
1419
1726
|
/**
|
|
1420
1727
|
* @private
|
|
1421
1728
|
*/
|
|
@@ -1440,57 +1747,109 @@ class AuroMenuOption extends AuroElement {
|
|
|
1440
1747
|
/**
|
|
1441
1748
|
* @private
|
|
1442
1749
|
*/
|
|
1443
|
-
this.
|
|
1750
|
+
this.runtimeUtils = new AuroLibraryRuntimeUtils();
|
|
1751
|
+
|
|
1752
|
+
// Initialize context-related properties
|
|
1753
|
+
this.menuService = null;
|
|
1754
|
+
this.unsubscribe = null;
|
|
1444
1755
|
|
|
1445
1756
|
/**
|
|
1446
1757
|
* @private
|
|
1447
1758
|
*/
|
|
1448
|
-
this.
|
|
1449
|
-
|
|
1450
|
-
this.addEventListener('click', this.handleClick.bind(this));
|
|
1759
|
+
this.handleMenuChange = this.handleMenuChange.bind(this);
|
|
1451
1760
|
}
|
|
1452
1761
|
|
|
1453
1762
|
static get properties() {
|
|
1454
1763
|
return {
|
|
1455
1764
|
...super.properties,
|
|
1456
|
-
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* When true, disables the menu option.
|
|
1768
|
+
*/
|
|
1769
|
+
disabled: {
|
|
1457
1770
|
type: Boolean,
|
|
1458
1771
|
reflect: true
|
|
1459
1772
|
},
|
|
1460
|
-
|
|
1461
|
-
|
|
1773
|
+
|
|
1774
|
+
/**
|
|
1775
|
+
* @private
|
|
1776
|
+
*/
|
|
1777
|
+
event: {
|
|
1778
|
+
type: String,
|
|
1462
1779
|
reflect: true
|
|
1463
1780
|
},
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1781
|
+
|
|
1782
|
+
/**
|
|
1783
|
+
* @private
|
|
1784
|
+
*/
|
|
1785
|
+
layout: {
|
|
1786
|
+
type: String
|
|
1467
1787
|
},
|
|
1468
|
-
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Allows users to set a unique key for the menu option for specified option selection. If no key is provided, the value property will be used.
|
|
1791
|
+
*/
|
|
1792
|
+
key: {
|
|
1469
1793
|
type: String,
|
|
1470
1794
|
reflect: true
|
|
1471
1795
|
},
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* @private
|
|
1799
|
+
*/
|
|
1800
|
+
menuService: {
|
|
1801
|
+
type: Object,
|
|
1802
|
+
state: true
|
|
1475
1803
|
},
|
|
1476
1804
|
|
|
1477
1805
|
/**
|
|
1478
1806
|
* @private
|
|
1479
1807
|
*/
|
|
1480
|
-
|
|
1808
|
+
matchWord: {
|
|
1481
1809
|
type: String,
|
|
1810
|
+
state: true
|
|
1811
|
+
},
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* @private
|
|
1815
|
+
*/
|
|
1816
|
+
noCheckmark: {
|
|
1817
|
+
type: Boolean,
|
|
1482
1818
|
reflect: true
|
|
1483
1819
|
},
|
|
1484
1820
|
|
|
1485
1821
|
/**
|
|
1486
|
-
* When true, marks this option as the "no matching results" placeholder shown by combobox
|
|
1487
|
-
* when the user's input does not match any available options.
|
|
1822
|
+
* When true, marks this option as the "no matching results" placeholder shown by combobox when the user's input does not match any available options. Enables distinct styling and prevents the option from being treated as a selectable match.
|
|
1488
1823
|
*/
|
|
1489
1824
|
noMatch: {
|
|
1490
1825
|
type: Boolean,
|
|
1491
1826
|
reflect: true,
|
|
1492
1827
|
attribute: 'nomatch'
|
|
1493
1828
|
},
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* Specifies that an option is selected.
|
|
1832
|
+
*/
|
|
1833
|
+
selected: {
|
|
1834
|
+
type: Boolean,
|
|
1835
|
+
reflect: true
|
|
1836
|
+
},
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* Specifies the tab index of the menu option.
|
|
1840
|
+
*/
|
|
1841
|
+
tabIndex: {
|
|
1842
|
+
type: Number,
|
|
1843
|
+
reflect: true
|
|
1844
|
+
},
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* Specifies the value to be sent to a server.
|
|
1848
|
+
*/
|
|
1849
|
+
value: {
|
|
1850
|
+
type: String,
|
|
1851
|
+
reflect: true
|
|
1852
|
+
},
|
|
1494
1853
|
};
|
|
1495
1854
|
}
|
|
1496
1855
|
|
|
@@ -1502,48 +1861,41 @@ class AuroMenuOption extends AuroElement {
|
|
|
1502
1861
|
];
|
|
1503
1862
|
}
|
|
1504
1863
|
|
|
1505
|
-
/**
|
|
1506
|
-
* This will register this element with the browser.
|
|
1507
|
-
* @param {string} [name="auro-menuoption"] - The name of element that you want to register to.
|
|
1508
|
-
*
|
|
1509
|
-
* @example
|
|
1510
|
-
* AuroMenuOption.register("custom-menuoption") // this will register this element to <custom-menuoption/>
|
|
1511
|
-
*
|
|
1512
|
-
*/
|
|
1513
|
-
static register(name = "auro-menuoption") {
|
|
1514
|
-
AuroLibraryRuntimeUtils.prototype.registerComponent(name, AuroMenuOption);
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
/**
|
|
1518
|
-
* Returns whether the menu option is currently active and selectable.
|
|
1519
|
-
* @returns {boolean}
|
|
1520
|
-
*/
|
|
1521
|
-
get isActive() {
|
|
1522
|
-
return !this.hasAttribute('hidden') &&
|
|
1523
|
-
!this.disabled &&
|
|
1524
|
-
!this.hasAttribute('static');
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
1864
|
connectedCallback() {
|
|
1528
1865
|
super.connectedCallback();
|
|
1529
1866
|
|
|
1867
|
+
// Add the tag name as an attribute if it is different than the component name
|
|
1868
|
+
// Add this step soon as this node gets attached to the DOM to avoid racing condition with menu's value setting logic.
|
|
1530
1869
|
this.runtimeUtils.handleComponentTagRename(this, 'auro-menuoption');
|
|
1870
|
+
|
|
1871
|
+
// Set up context consumption in connectedCallback
|
|
1872
|
+
this._contextConsumer = new ContextConsumer(this, {
|
|
1873
|
+
context: MenuContext,
|
|
1874
|
+
callback: this.attachTo.bind(this),
|
|
1875
|
+
subscribe: true
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
// Establish the key property as early as possible.
|
|
1879
|
+
// When a framework (e.g. Svelte) inserts the element into the DOM before
|
|
1880
|
+
// setting its `value` property, both `getAttribute('value')` and
|
|
1881
|
+
// `getAttribute('key')` return null here. Setting `this.key = null`
|
|
1882
|
+
// would block the fallback in `updated()` that assigns key from the
|
|
1883
|
+
// value property (the guard checked `=== undefined`). Only assign key
|
|
1884
|
+
// if at least one source attribute is actually present so that the
|
|
1885
|
+
// `updated()` fallback can run when the value property arrives later.
|
|
1886
|
+
const valueAttr = this.getAttribute('value');
|
|
1887
|
+
const keyAttr = this.getAttribute('key');
|
|
1888
|
+
const resolvedKey = keyAttr !== null ? keyAttr : valueAttr;
|
|
1889
|
+
if (resolvedKey !== null) {
|
|
1890
|
+
this.key = resolvedKey;
|
|
1891
|
+
}
|
|
1531
1892
|
}
|
|
1532
1893
|
|
|
1533
1894
|
firstUpdated() {
|
|
1895
|
+
// Add the tag name as an attribute if it is different than the component name
|
|
1534
1896
|
this.runtimeUtils.handleComponentTagRename(this, 'auro-menuoption');
|
|
1535
1897
|
|
|
1536
|
-
//
|
|
1537
|
-
// render completed (e.g. dynamic-menu rebuilds clear and repopulate the
|
|
1538
|
-
// menu mid-cycle). parentElement is null in that case — fall back to
|
|
1539
|
-
// defaults; the next render after re-attachment will pick up real values.
|
|
1540
|
-
if (!this.hasAttribute('size')) {
|
|
1541
|
-
this.size = this.parentElement ? this.parentElement.getAttribute('size') || 'sm' : 'sm';
|
|
1542
|
-
}
|
|
1543
|
-
if (!this.hasAttribute('shape')) {
|
|
1544
|
-
this.shape = this.parentElement ? this.parentElement.getAttribute('shape') || 'box' : 'box';
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1898
|
+
// Generate unique ID if not already set (required for aria-activedescendant)
|
|
1547
1899
|
if (!this.id) {
|
|
1548
1900
|
menuOptionIdCounter += 1;
|
|
1549
1901
|
this.id = `menuoption-${menuOptionIdCounter}`;
|
|
@@ -1565,8 +1917,16 @@ class AuroMenuOption extends AuroElement {
|
|
|
1565
1917
|
updated(changedProperties) {
|
|
1566
1918
|
super.updated(changedProperties);
|
|
1567
1919
|
|
|
1920
|
+
// Update aria-selected attribute if selected changed
|
|
1568
1921
|
if (changedProperties.has('selected')) {
|
|
1922
|
+
|
|
1923
|
+
// Update aria-selected attribute
|
|
1569
1924
|
this.setAttribute('aria-selected', this.selected.toString());
|
|
1925
|
+
|
|
1926
|
+
// Update menu service selection state if this isn't an internal update
|
|
1927
|
+
if (this.internalUpdateInProgress !== true && this.menuService) {
|
|
1928
|
+
this.menuService[this.selected ? 'selectOption' : 'deselectOption'](this);
|
|
1929
|
+
}
|
|
1570
1930
|
}
|
|
1571
1931
|
|
|
1572
1932
|
if (changedProperties.has('disabled')) {
|
|
@@ -1576,48 +1936,231 @@ class AuroMenuOption extends AuroElement {
|
|
|
1576
1936
|
this.removeAttribute('aria-disabled');
|
|
1577
1937
|
}
|
|
1578
1938
|
}
|
|
1939
|
+
|
|
1940
|
+
if (changedProperties.has('active')) {
|
|
1941
|
+
this.updateActiveClasses();
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Update text highlight if matchWord changed
|
|
1945
|
+
if (changedProperties.has('matchWord')) {
|
|
1946
|
+
this.updateTextHighlight();
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// Set the key to be the passed value if no key is provided.
|
|
1950
|
+
// Loose equality (== null) is intentional: it catches both null AND
|
|
1951
|
+
// undefined. When a framework (e.g. Svelte, React) inserts the element
|
|
1952
|
+
// before setting its value property, connectedCallback skips key
|
|
1953
|
+
// assignment because both attributes are null at that point. The Lit
|
|
1954
|
+
// property default for `key` is undefined (not null), so strict
|
|
1955
|
+
// === null would miss the case and the fallback would never run.
|
|
1956
|
+
if (changedProperties.has('value') && this.key == null) { // eslint-disable-line eqeqeq, no-eq-null
|
|
1957
|
+
this.key = this.value;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
disconnectedCallback() {
|
|
1962
|
+
if (this.menuService) {
|
|
1963
|
+
this.menuService.unsubscribe(this.handleMenuChange);
|
|
1964
|
+
this.menuService.removeMenuOption(this);
|
|
1965
|
+
}
|
|
1579
1966
|
}
|
|
1580
1967
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1968
|
+
/**
|
|
1969
|
+
* Sets up event listeners for user interaction with the menu option.
|
|
1970
|
+
* This function enables click and mouse enter events to trigger selection and highlighting logic.
|
|
1971
|
+
*/
|
|
1972
|
+
bindEvents() {
|
|
1973
|
+
this.addEventListener('click', this.handleClick.bind(this));
|
|
1974
|
+
this.addEventListener('mouseenter', this.handleMouseEnter.bind(this));
|
|
1583
1975
|
}
|
|
1584
1976
|
|
|
1585
|
-
|
|
1586
|
-
|
|
1977
|
+
/**
|
|
1978
|
+
* Attaches this menu option to a menu service and subscribes to its events.
|
|
1979
|
+
* This method enables the option to participate in menu selection and highlighting logic.
|
|
1980
|
+
* @param {Object} service - The menu service instance to attach to.
|
|
1981
|
+
*/
|
|
1982
|
+
attachTo(service) {
|
|
1983
|
+
if (!service) {
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
this.menuService = service;
|
|
1987
|
+
this.menuService.addMenuOption(this);
|
|
1988
|
+
this.menuService.subscribe(this.handleMenuChange);
|
|
1587
1989
|
}
|
|
1588
1990
|
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1991
|
+
/**
|
|
1992
|
+
* Handles changes from the menu service and updates the option's state.
|
|
1993
|
+
* This function synchronizes the option's properties and selection/highlight state with menu events.
|
|
1994
|
+
* @param {Object} event - The event object from the menu service.
|
|
1995
|
+
*/
|
|
1996
|
+
handleMenuChange(event) {
|
|
1997
|
+
|
|
1998
|
+
// Ignore events without a type or property
|
|
1999
|
+
if (!event || (!event.type && !event.property)) {
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// Update reactive properties based on event type
|
|
2004
|
+
if (event.property && Object.keys(AuroMenuOption.properties).includes(event.property)) {
|
|
2005
|
+
this[event.property] = event.value;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// Handle highlight changes
|
|
2009
|
+
if (event.type === 'highlightChange') {
|
|
2010
|
+
const isActive = event.option === this;
|
|
2011
|
+
this.active = isActive;
|
|
2012
|
+
this.updateActiveClasses();
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
if (event.type === 'stateChange') {
|
|
2016
|
+
const isSelected = event.selectedOptions.includes(this);
|
|
2017
|
+
this.setInternalSelected(isSelected);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* Updates the internal selected state of the menu option bypassing 'updated' and triggers custom events if selected.
|
|
2023
|
+
* This function ensures the option's selection state is synchronized with menu logic and notifies listeners.
|
|
2024
|
+
* @param {boolean} isSelected - Whether the option should be marked as selected.
|
|
2025
|
+
*/
|
|
2026
|
+
setInternalSelected(isSelected) {
|
|
2027
|
+
this.internalUpdateInProgress = true;
|
|
2028
|
+
this.selected = isSelected;
|
|
2029
|
+
|
|
2030
|
+
// Fire custom event if selected
|
|
2031
|
+
if (isSelected) {
|
|
2032
|
+
this.handleCustomEvent();
|
|
1595
2033
|
}
|
|
2034
|
+
|
|
2035
|
+
setTimeout(() => {
|
|
2036
|
+
this.internalUpdateInProgress = false;
|
|
2037
|
+
}, 0);
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/**
|
|
2041
|
+
* Sets the selected state of the menu option.
|
|
2042
|
+
* This function updates whether the option is currently selected.
|
|
2043
|
+
* @param {boolean} isSelected - Whether the option should be marked as selected.
|
|
2044
|
+
* @deprecated Simply modify the `selected` property directly instead.
|
|
2045
|
+
*/
|
|
2046
|
+
setSelected(isSelected) {
|
|
2047
|
+
this.selected = isSelected;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Updates the active state and visual highlighting of the menu option.
|
|
2052
|
+
* This function toggles the option's active status and applies or removes the active CSS class.
|
|
2053
|
+
* @param {boolean} isActive - Whether the option should be marked as active.
|
|
2054
|
+
* @deprecated Simply modify the `active` property directly instead.
|
|
2055
|
+
*/
|
|
2056
|
+
updateActive(isActive) {
|
|
2057
|
+
|
|
2058
|
+
// Set active state
|
|
2059
|
+
this.active = isActive;
|
|
2060
|
+
this.updateActiveClasses();
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* Updates the CSS class for the menu option based on its active state.
|
|
2065
|
+
* This function adds or removes the 'active' class to visually indicate the option's active status.
|
|
2066
|
+
* @private
|
|
2067
|
+
*/
|
|
2068
|
+
updateActiveClasses() {
|
|
2069
|
+
// Update class based on active state
|
|
2070
|
+
if (this.active) this.classList.add('active');
|
|
2071
|
+
else this.classList.remove('active');
|
|
1596
2072
|
}
|
|
1597
2073
|
|
|
1598
|
-
|
|
1599
|
-
|
|
2074
|
+
|
|
2075
|
+
/**
|
|
2076
|
+
* Updates the visual highlighting of text within the menu option based on the current match word.
|
|
2077
|
+
* This function highlights matching text segments and manages nested spacers for display formatting.
|
|
2078
|
+
* @private
|
|
2079
|
+
*/
|
|
2080
|
+
updateTextHighlight() {
|
|
2081
|
+
|
|
2082
|
+
// Regex for matchWord if needed
|
|
2083
|
+
let regexWord = null;
|
|
2084
|
+
|
|
2085
|
+
if (this.matchWord && this.matchWord.length) {
|
|
2086
|
+
const escapedWord = this.matchWord.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
2087
|
+
regexWord = new RegExp(escapedWord, 'giu');
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// Update text highlighting if matchWord changed
|
|
2091
|
+
if (regexWord &&
|
|
2092
|
+
this.isActive && !this.hasAttribute('persistent')) {
|
|
2093
|
+
const nested = this.querySelectorAll('.nestingSpacer');
|
|
2094
|
+
|
|
2095
|
+
const displayValueEl = this.querySelector('[slot="displayValue"]');
|
|
2096
|
+
if (displayValueEl) {
|
|
2097
|
+
this.removeChild(displayValueEl);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// Create nested spacers
|
|
2101
|
+
const nestingSpacerBundle = [...nested].map(() => this.nestingSpacer).join('');
|
|
2102
|
+
|
|
2103
|
+
// Update with spacers and matchWord
|
|
2104
|
+
this.innerHTML = nestingSpacerBundle +
|
|
2105
|
+
this.textContent.replace(
|
|
2106
|
+
regexWord,
|
|
2107
|
+
(match) => `<strong>${match}</strong>`
|
|
2108
|
+
);
|
|
2109
|
+
if (displayValueEl) {
|
|
2110
|
+
this.append(displayValueEl);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
1600
2113
|
}
|
|
1601
2114
|
|
|
1602
2115
|
/**
|
|
1603
|
-
* Handles click events on the menu option.
|
|
2116
|
+
* Handles click events on the menu option, toggling its selected state.
|
|
2117
|
+
* This function dispatches a click event and updates selection if the option is not disabled.
|
|
1604
2118
|
* @private
|
|
1605
2119
|
*/
|
|
1606
2120
|
handleClick() {
|
|
2121
|
+
if (!this.disabled && !this.menuService?.disabled) {
|
|
2122
|
+
this.dispatchClickEvent();
|
|
2123
|
+
this.selected = !this.selected;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* Handles mouse enter events to highlight the menu option.
|
|
2129
|
+
* This function updates the menu service to set this option as the currently highlighted item if not disabled.
|
|
2130
|
+
* @private
|
|
2131
|
+
*/
|
|
2132
|
+
handleMouseEnter() {
|
|
1607
2133
|
if (!this.disabled) {
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
2134
|
+
this.menuService.setHighlightedOption(this);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/**
|
|
2139
|
+
* Dispatches custom events defined for this menu option.
|
|
2140
|
+
* This function notifies listeners when a custom event is triggered by the option.
|
|
2141
|
+
* @private
|
|
2142
|
+
*/
|
|
2143
|
+
handleCustomEvent() {
|
|
2144
|
+
if (this.event) {
|
|
2145
|
+
dispatchMenuEvent(this, this.event, { option: this });
|
|
2146
|
+
dispatchMenuEvent(this, 'auroMenu-customEventFired', { option: this });
|
|
1618
2147
|
}
|
|
1619
2148
|
}
|
|
1620
2149
|
|
|
2150
|
+
/**
|
|
2151
|
+
* Dispatches a click event for this menu option.
|
|
2152
|
+
* This function notifies listeners that the option has been clicked.
|
|
2153
|
+
* @private
|
|
2154
|
+
*/
|
|
2155
|
+
dispatchClickEvent() {
|
|
2156
|
+
this.dispatchEvent(new CustomEvent('auroMenuOption-click', {
|
|
2157
|
+
bubbles: true,
|
|
2158
|
+
cancelable: false,
|
|
2159
|
+
composed: true,
|
|
2160
|
+
detail: this
|
|
2161
|
+
}));
|
|
2162
|
+
}
|
|
2163
|
+
|
|
1621
2164
|
/**
|
|
1622
2165
|
* Generates an HTML element containing an SVG icon based on the provided `svgContent`.
|
|
1623
2166
|
*
|
|
@@ -1657,8 +2200,8 @@ class AuroMenuOption extends AuroElement {
|
|
|
1657
2200
|
return html$1`
|
|
1658
2201
|
<div class="${classes}">
|
|
1659
2202
|
${this.selected && !this.noCheckmark
|
|
1660
|
-
|
|
1661
|
-
|
|
2203
|
+
? this.generateIconHtml(checkmarkIcon.svg)
|
|
2204
|
+
: undefined}
|
|
1662
2205
|
<slot></slot>
|
|
1663
2206
|
</div>
|
|
1664
2207
|
`;
|