@angular/aria 0.0.1 → 21.0.0-next.9

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.
@@ -0,0 +1,2504 @@
1
+ import { signal, computed } from '@angular/core';
2
+
3
+ /** Bit flag representation of the possible modifier keys that can be present on an event. */
4
+ var Modifier;
5
+ (function (Modifier) {
6
+ Modifier[Modifier["None"] = 0] = "None";
7
+ Modifier[Modifier["Ctrl"] = 1] = "Ctrl";
8
+ Modifier[Modifier["Shift"] = 2] = "Shift";
9
+ Modifier[Modifier["Alt"] = 4] = "Alt";
10
+ Modifier[Modifier["Meta"] = 8] = "Meta";
11
+ Modifier["Any"] = "Any";
12
+ })(Modifier || (Modifier = {}));
13
+ /**
14
+ * Abstract base class for all event managers.
15
+ *
16
+ * Event managers are designed to normalize how event handlers are authored and create a safety net
17
+ * for common event handling gotchas like remembering to call preventDefault or stopPropagation.
18
+ */
19
+ class EventManager {
20
+ configs = [];
21
+ /** Runs the handlers that match with the given event. */
22
+ handle(event) {
23
+ for (const config of this.configs) {
24
+ if (config.matcher(event)) {
25
+ config.handler(event);
26
+ if (config.preventDefault) {
27
+ event.preventDefault();
28
+ }
29
+ if (config.stopPropagation) {
30
+ event.stopPropagation();
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ /** Gets bit flag representation of the modifier keys present on the given event. */
37
+ function getModifiers(event) {
38
+ return ((+event.ctrlKey && Modifier.Ctrl) |
39
+ (+event.shiftKey && Modifier.Shift) |
40
+ (+event.altKey && Modifier.Alt) |
41
+ (+event.metaKey && Modifier.Meta));
42
+ }
43
+ /**
44
+ * Checks if the given event has modifiers that are an exact match for any of the given modifier
45
+ * flag combinations.
46
+ */
47
+ function hasModifiers(event, modifiers) {
48
+ const eventModifiers = getModifiers(event);
49
+ const modifiersList = Array.isArray(modifiers) ? modifiers : [modifiers];
50
+ if (modifiersList.includes(Modifier.Any)) {
51
+ return true;
52
+ }
53
+ return modifiersList.some(modifiers => eventModifiers === modifiers);
54
+ }
55
+
56
+ /**
57
+ * An event manager that is specialized for handling keyboard events. By default this manager stops
58
+ * propagation and prevents default on all events it handles.
59
+ */
60
+ class KeyboardEventManager extends EventManager {
61
+ options = {
62
+ preventDefault: true,
63
+ stopPropagation: true,
64
+ };
65
+ on(...args) {
66
+ const { modifiers, key, handler } = this._normalizeInputs(...args);
67
+ this.configs.push({
68
+ handler: handler,
69
+ matcher: event => this._isMatch(event, key, modifiers),
70
+ ...this.options,
71
+ });
72
+ return this;
73
+ }
74
+ _normalizeInputs(...args) {
75
+ const key = args.length === 3 ? args[1] : args[0];
76
+ const handler = args.length === 3 ? args[2] : args[1];
77
+ const modifiers = args.length === 3 ? args[0] : Modifier.None;
78
+ return {
79
+ key: key,
80
+ handler: handler,
81
+ modifiers: modifiers,
82
+ };
83
+ }
84
+ _isMatch(event, key, modifiers) {
85
+ if (!hasModifiers(event, modifiers)) {
86
+ return false;
87
+ }
88
+ if (key instanceof RegExp) {
89
+ return key.test(event.key);
90
+ }
91
+ const keyStr = typeof key === 'string' ? key : key();
92
+ return keyStr.toLowerCase() === event.key.toLowerCase();
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The different mouse buttons that may appear on a pointer event.
98
+ */
99
+ var MouseButton;
100
+ (function (MouseButton) {
101
+ MouseButton[MouseButton["Main"] = 0] = "Main";
102
+ MouseButton[MouseButton["Auxiliary"] = 1] = "Auxiliary";
103
+ MouseButton[MouseButton["Secondary"] = 2] = "Secondary";
104
+ })(MouseButton || (MouseButton = {}));
105
+ /** An event manager that is specialized for handling pointer events. */
106
+ class PointerEventManager extends EventManager {
107
+ options = {
108
+ preventDefault: false,
109
+ stopPropagation: false,
110
+ };
111
+ on(...args) {
112
+ const { button, handler, modifiers } = this._normalizeInputs(...args);
113
+ this.configs.push({
114
+ handler,
115
+ matcher: event => this._isMatch(event, button, modifiers),
116
+ ...this.options,
117
+ });
118
+ return this;
119
+ }
120
+ _normalizeInputs(...args) {
121
+ if (args.length === 3) {
122
+ return {
123
+ button: args[0],
124
+ modifiers: args[1],
125
+ handler: args[2],
126
+ };
127
+ }
128
+ if (typeof args[0] === 'number' && typeof args[1] === 'function') {
129
+ return {
130
+ button: MouseButton.Main,
131
+ modifiers: args[0],
132
+ handler: args[1],
133
+ };
134
+ }
135
+ return {
136
+ button: MouseButton.Main,
137
+ modifiers: Modifier.None,
138
+ handler: args[0],
139
+ };
140
+ }
141
+ _isMatch(event, button, modifiers) {
142
+ return button === (event.button ?? 0) && hasModifiers(event, modifiers);
143
+ }
144
+ }
145
+
146
+ /** Controls the state of a combobox. */
147
+ class ComboboxPattern {
148
+ inputs;
149
+ /** Whether the combobox is expanded. */
150
+ expanded = signal(false);
151
+ /** The ID of the active item in the combobox. */
152
+ activedescendant = computed(() => this.inputs.popupControls()?.activeId() ?? null);
153
+ /** The currently highlighted item in the combobox. */
154
+ highlightedItem = signal(undefined);
155
+ /** Whether the most recent input event was a deletion. */
156
+ isDeleting = false;
157
+ /** Whether the combobox is focused. */
158
+ isFocused = signal(false);
159
+ /** The key used to navigate to the previous item in the list. */
160
+ expandKey = computed(() => 'ArrowRight'); // TODO: RTL support.
161
+ /** The key used to navigate to the next item in the list. */
162
+ collapseKey = computed(() => 'ArrowLeft'); // TODO: RTL support.
163
+ /** The ID of the popup associated with the combobox. */
164
+ popupId = computed(() => this.inputs.popupControls()?.id() || null);
165
+ /** The autocomplete behavior of the combobox. */
166
+ autocomplete = computed(() => (this.inputs.filterMode() === 'highlight' ? 'both' : 'list'));
167
+ /** The ARIA role of the popup associated with the combobox. */
168
+ hasPopup = computed(() => this.inputs.popupControls()?.role() || null);
169
+ /** The keydown event manager for the combobox. */
170
+ keydown = computed(() => {
171
+ if (!this.expanded()) {
172
+ return new KeyboardEventManager()
173
+ .on('ArrowDown', () => this.open({ first: true }))
174
+ .on('ArrowUp', () => this.open({ last: true }));
175
+ }
176
+ const popupControls = this.inputs.popupControls();
177
+ if (!popupControls) {
178
+ return new KeyboardEventManager();
179
+ }
180
+ const manager = new KeyboardEventManager()
181
+ .on('ArrowDown', () => this.next())
182
+ .on('ArrowUp', () => this.prev())
183
+ .on('Home', () => this.first())
184
+ .on('End', () => this.last())
185
+ .on('Escape', () => {
186
+ // TODO(wagnermaciel): We may want to fold this logic into the close() method.
187
+ if (this.inputs.filterMode() === 'highlight' && popupControls.activeId()) {
188
+ popupControls.unfocus();
189
+ popupControls.clearSelection();
190
+ const inputEl = this.inputs.inputEl();
191
+ if (inputEl) {
192
+ inputEl.value = this.inputs.inputValue();
193
+ }
194
+ }
195
+ else {
196
+ this.close();
197
+ this.inputs.popupControls()?.clearSelection();
198
+ }
199
+ }) // TODO: When filter mode is 'highlight', escape should revert to the last committed value.
200
+ .on('Enter', () => this.select({ commit: true, close: true }));
201
+ if (popupControls.role() === 'tree') {
202
+ const treeControls = popupControls;
203
+ if (treeControls.isItemExpandable() || treeControls.isItemCollapsible()) {
204
+ manager.on(this.collapseKey(), () => this.collapseItem());
205
+ }
206
+ if (treeControls.isItemExpandable()) {
207
+ manager.on(this.expandKey(), () => this.expandItem());
208
+ }
209
+ }
210
+ return manager;
211
+ });
212
+ /** The pointerup event manager for the combobox. */
213
+ pointerup = computed(() => new PointerEventManager().on(e => {
214
+ const item = this.inputs.popupControls()?.getItem(e);
215
+ if (item) {
216
+ this.select({ item, commit: true, close: true });
217
+ this.inputs.inputEl()?.focus(); // Return focus to the input after selecting.
218
+ }
219
+ if (e.target === this.inputs.inputEl()) {
220
+ this.open();
221
+ }
222
+ }));
223
+ constructor(inputs) {
224
+ this.inputs = inputs;
225
+ }
226
+ /** Handles keydown events for the combobox. */
227
+ onKeydown(event) {
228
+ this.keydown().handle(event);
229
+ }
230
+ /** Handles pointerup events for the combobox. */
231
+ onPointerup(event) {
232
+ this.pointerup().handle(event);
233
+ }
234
+ /** Handles input events for the combobox. */
235
+ onInput(event) {
236
+ const inputEl = this.inputs.inputEl();
237
+ if (!inputEl) {
238
+ return;
239
+ }
240
+ this.open();
241
+ this.inputs.inputValue?.set(inputEl.value);
242
+ this.isDeleting = event instanceof InputEvent && !!event.inputType.match(/^delete/);
243
+ if (this.inputs.filterMode() === 'manual') {
244
+ const searchTerm = this.inputs.popupControls()?.getSelectedItem()?.searchTerm();
245
+ if (searchTerm && this.inputs.inputValue() !== searchTerm) {
246
+ this.inputs.popupControls()?.clearSelection();
247
+ }
248
+ }
249
+ }
250
+ onFocusIn() {
251
+ this.isFocused.set(true);
252
+ }
253
+ /** Handles focus out events for the combobox. */
254
+ onFocusOut(event) {
255
+ if (!(event.relatedTarget instanceof HTMLElement) ||
256
+ !this.inputs.containerEl()?.contains(event.relatedTarget)) {
257
+ this.isFocused.set(false);
258
+ if (this.inputs.filterMode() !== 'manual') {
259
+ this.commit();
260
+ }
261
+ else {
262
+ const item = this.inputs
263
+ .popupControls()
264
+ ?.items()
265
+ .find(i => i.searchTerm() === this.inputs.inputEl()?.value);
266
+ if (item) {
267
+ this.select({ item });
268
+ }
269
+ }
270
+ this.close();
271
+ }
272
+ }
273
+ firstMatch = computed(() => {
274
+ // TODO(wagnermaciel): Consider whether we should not provide this default behavior for the
275
+ // listbox. Instead, we may want to allow users to have no match so that typing does not focus
276
+ // any option.
277
+ if (this.inputs.popupControls()?.role() === 'listbox') {
278
+ return this.inputs.popupControls()?.items()[0];
279
+ }
280
+ return this.inputs
281
+ .popupControls()
282
+ ?.items()
283
+ .find(i => i.value() === this.inputs.firstMatch());
284
+ });
285
+ onFilter() {
286
+ // TODO(wagnermaciel)
287
+ // When the user first interacts with the combobox, the popup will lazily render for the first
288
+ // time. This is a simple way to detect this and avoid auto-focus & selection logic, but this
289
+ // should probably be moved to the component layer instead.
290
+ const isInitialRender = !this.inputs.inputValue?.().length && !this.isDeleting;
291
+ if (isInitialRender) {
292
+ return;
293
+ }
294
+ // Avoid refocusing the input if a filter event occurs after focus has left the combobox.
295
+ if (!this.isFocused()) {
296
+ return;
297
+ }
298
+ if (this.inputs.popupControls()?.role() === 'tree') {
299
+ const treeControls = this.inputs.popupControls();
300
+ this.inputs.inputValue?.().length ? treeControls.expandAll() : treeControls.collapseAll();
301
+ }
302
+ const item = this.firstMatch();
303
+ if (!item) {
304
+ this.inputs.popupControls()?.clearSelection();
305
+ this.inputs.popupControls()?.unfocus();
306
+ return;
307
+ }
308
+ this.inputs.popupControls()?.focus(item);
309
+ if (this.inputs.filterMode() !== 'manual') {
310
+ this.select({ item });
311
+ }
312
+ if (this.inputs.filterMode() === 'highlight' && !this.isDeleting) {
313
+ this.highlight();
314
+ }
315
+ }
316
+ highlight() {
317
+ const inputEl = this.inputs.inputEl();
318
+ const item = this.inputs.popupControls()?.getSelectedItem();
319
+ if (!inputEl || !item) {
320
+ return;
321
+ }
322
+ const isHighlightable = item
323
+ .searchTerm()
324
+ .toLowerCase()
325
+ .startsWith(this.inputs.inputValue().toLowerCase());
326
+ if (isHighlightable) {
327
+ inputEl.value =
328
+ this.inputs.inputValue() + item.searchTerm().slice(this.inputs.inputValue().length);
329
+ inputEl.setSelectionRange(this.inputs.inputValue().length, item.searchTerm().length);
330
+ this.highlightedItem.set(item);
331
+ }
332
+ }
333
+ /** Closes the combobox. */
334
+ close() {
335
+ this.expanded.set(false);
336
+ this.inputs.popupControls()?.unfocus();
337
+ }
338
+ /** Opens the combobox. */
339
+ open(nav) {
340
+ this.expanded.set(true);
341
+ if (nav?.first) {
342
+ this.first();
343
+ }
344
+ if (nav?.last) {
345
+ this.last();
346
+ }
347
+ }
348
+ /** Navigates to the next focusable item in the combobox popup. */
349
+ next() {
350
+ this._navigate(() => this.inputs.popupControls()?.next());
351
+ }
352
+ /** Navigates to the previous focusable item in the combobox popup. */
353
+ prev() {
354
+ this._navigate(() => this.inputs.popupControls()?.prev());
355
+ }
356
+ /** Navigates to the first focusable item in the combobox popup. */
357
+ first() {
358
+ this._navigate(() => this.inputs.popupControls()?.first());
359
+ }
360
+ /** Navigates to the last focusable item in the combobox popup. */
361
+ last() {
362
+ this._navigate(() => this.inputs.popupControls()?.last());
363
+ }
364
+ collapseItem() {
365
+ const controls = this.inputs.popupControls();
366
+ this._navigate(() => controls?.collapseItem());
367
+ }
368
+ expandItem() {
369
+ const controls = this.inputs.popupControls();
370
+ this._navigate(() => controls?.expandItem());
371
+ }
372
+ /** Selects an item in the combobox popup. */
373
+ select(opts = {}) {
374
+ this.inputs.popupControls()?.select(opts.item);
375
+ if (opts.commit) {
376
+ this.commit();
377
+ }
378
+ if (opts.close) {
379
+ this.close();
380
+ }
381
+ }
382
+ /** Updates the value of the input based on the currently selected item. */
383
+ commit() {
384
+ const inputEl = this.inputs.inputEl();
385
+ const item = this.inputs.popupControls()?.getSelectedItem();
386
+ if (inputEl && item) {
387
+ inputEl.value = item.searchTerm();
388
+ this.inputs.inputValue?.set(item.searchTerm());
389
+ if (this.inputs.filterMode() === 'highlight') {
390
+ const length = inputEl.value.length;
391
+ inputEl.setSelectionRange(length, length);
392
+ }
393
+ }
394
+ }
395
+ /** Navigates and handles additional actions based on filter mode. */
396
+ _navigate(operation) {
397
+ operation();
398
+ if (this.inputs.filterMode() !== 'manual') {
399
+ this.select();
400
+ }
401
+ if (this.inputs.filterMode() === 'highlight') {
402
+ // This is to handle when the user navigates back to the originally highlighted item.
403
+ // E.g. User types "Al", highlights "Alice", then navigates down and back up to "Alice".
404
+ const selectedItem = this.inputs.popupControls()?.getSelectedItem();
405
+ if (!selectedItem) {
406
+ return;
407
+ }
408
+ if (selectedItem === this.highlightedItem()) {
409
+ this.highlight();
410
+ }
411
+ else {
412
+ const inputEl = this.inputs.inputEl();
413
+ inputEl.value = selectedItem?.searchTerm();
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ /** Controls focus for a list of items. */
420
+ class ListFocus {
421
+ inputs;
422
+ /** The last item that was active. */
423
+ prevActiveItem = signal(undefined);
424
+ /** The index of the last item that was active. */
425
+ prevActiveIndex = computed(() => {
426
+ return this.prevActiveItem() ? this.inputs.items().indexOf(this.prevActiveItem()) : -1;
427
+ });
428
+ /** The current active index in the list. */
429
+ activeIndex = computed(() => {
430
+ return this.inputs.activeItem() ? this.inputs.items().indexOf(this.inputs.activeItem()) : -1;
431
+ });
432
+ constructor(inputs) {
433
+ this.inputs = inputs;
434
+ }
435
+ /** Whether the list is in a disabled state. */
436
+ isListDisabled() {
437
+ return this.inputs.disabled() || this.inputs.items().every(i => i.disabled());
438
+ }
439
+ /** The id of the current active item. */
440
+ getActiveDescendant() {
441
+ if (this.isListDisabled()) {
442
+ return undefined;
443
+ }
444
+ if (this.inputs.focusMode() === 'roving') {
445
+ return undefined;
446
+ }
447
+ return this.inputs.activeItem()?.id() ?? undefined;
448
+ }
449
+ /** The tabindex for the list. */
450
+ getListTabindex() {
451
+ if (this.isListDisabled()) {
452
+ return 0;
453
+ }
454
+ return this.inputs.focusMode() === 'activedescendant' ? 0 : -1;
455
+ }
456
+ /** Returns the tabindex for the given item. */
457
+ getItemTabindex(item) {
458
+ if (this.isListDisabled()) {
459
+ return -1;
460
+ }
461
+ if (this.inputs.focusMode() === 'activedescendant') {
462
+ return -1;
463
+ }
464
+ return this.inputs.activeItem() === item ? 0 : -1;
465
+ }
466
+ /** Moves focus to the given item if it is focusable. */
467
+ focus(item) {
468
+ if (this.isListDisabled() || !this.isFocusable(item)) {
469
+ return false;
470
+ }
471
+ this.prevActiveItem.set(this.inputs.activeItem());
472
+ this.inputs.activeItem.set(item);
473
+ this.inputs.focusMode() === 'roving' ? item.element().focus() : this.inputs.element()?.focus();
474
+ return true;
475
+ }
476
+ /** Returns true if the given item can be navigated to. */
477
+ isFocusable(item) {
478
+ return !item.disabled() || !this.inputs.skipDisabled();
479
+ }
480
+ }
481
+
482
+ /** Controls navigation for a list of items. */
483
+ class ListNavigation {
484
+ inputs;
485
+ constructor(inputs) {
486
+ this.inputs = inputs;
487
+ }
488
+ /** Navigates to the given item. */
489
+ goto(item) {
490
+ return item ? this.inputs.focusManager.focus(item) : false;
491
+ }
492
+ /** Navigates to the next item in the list. */
493
+ next() {
494
+ return this._advance(1);
495
+ }
496
+ /** Peeks the next item in the list. */
497
+ peekNext() {
498
+ return this._peek(1);
499
+ }
500
+ /** Navigates to the previous item in the list. */
501
+ prev() {
502
+ return this._advance(-1);
503
+ }
504
+ /** Peeks the previous item in the list. */
505
+ peekPrev() {
506
+ return this._peek(-1);
507
+ }
508
+ /** Navigates to the first item in the list. */
509
+ first() {
510
+ const item = this.inputs.items().find(i => this.inputs.focusManager.isFocusable(i));
511
+ return item ? this.goto(item) : false;
512
+ }
513
+ /** Navigates to the last item in the list. */
514
+ last() {
515
+ const items = this.inputs.items();
516
+ for (let i = items.length - 1; i >= 0; i--) {
517
+ if (this.inputs.focusManager.isFocusable(items[i])) {
518
+ return this.goto(items[i]);
519
+ }
520
+ }
521
+ return false;
522
+ }
523
+ /** Advances to the next or previous focusable item in the list based on the given delta. */
524
+ _advance(delta) {
525
+ const item = this._peek(delta);
526
+ return item ? this.goto(item) : false;
527
+ }
528
+ /** Peeks the next or previous focusable item in the list based on the given delta. */
529
+ _peek(delta) {
530
+ const items = this.inputs.items();
531
+ const itemCount = items.length;
532
+ const startIndex = this.inputs.focusManager.activeIndex();
533
+ const step = (i) => this.inputs.wrap() ? (i + delta + itemCount) % itemCount : i + delta;
534
+ // If wrapping is enabled, this loop ultimately terminates when `i` gets back to `startIndex`
535
+ // in the case that all options are disabled. If wrapping is disabled, the loop terminates
536
+ // when the index goes out of bounds.
537
+ for (let i = step(startIndex); i !== startIndex && i < itemCount && i >= 0; i = step(i)) {
538
+ if (this.inputs.focusManager.isFocusable(items[i])) {
539
+ return items[i];
540
+ }
541
+ }
542
+ return;
543
+ }
544
+ }
545
+
546
+ /** Controls selection for a list of items. */
547
+ class ListSelection {
548
+ inputs;
549
+ /** The start index to use for range selection. */
550
+ rangeStartIndex = signal(0);
551
+ /** The end index to use for range selection. */
552
+ rangeEndIndex = signal(0);
553
+ /** The currently selected items. */
554
+ selectedItems = computed(() => this.inputs.items().filter(item => this.inputs.value().includes(item.value())));
555
+ constructor(inputs) {
556
+ this.inputs = inputs;
557
+ }
558
+ /** Selects the item at the current active index. */
559
+ select(item, opts = { anchor: true }) {
560
+ item = item ?? this.inputs.focusManager.inputs.activeItem();
561
+ if (!item || item.disabled() || this.inputs.value().includes(item.value())) {
562
+ return;
563
+ }
564
+ if (!this.inputs.multi()) {
565
+ this.deselectAll();
566
+ }
567
+ const index = this.inputs.items().findIndex(i => i === item);
568
+ if (opts.anchor) {
569
+ this.beginRangeSelection(index);
570
+ }
571
+ this.inputs.value.update(values => values.concat(item.value()));
572
+ }
573
+ /** Deselects the item at the current active index. */
574
+ deselect(item) {
575
+ item = item ?? this.inputs.focusManager.inputs.activeItem();
576
+ if (item && !item.disabled()) {
577
+ this.inputs.value.update(values => values.filter(value => value !== item.value()));
578
+ }
579
+ }
580
+ /** Toggles the item at the current active index. */
581
+ toggle() {
582
+ const item = this.inputs.focusManager.inputs.activeItem();
583
+ if (item) {
584
+ this.inputs.value().includes(item.value()) ? this.deselect() : this.select();
585
+ }
586
+ }
587
+ /** Toggles only the item at the current active index. */
588
+ toggleOne() {
589
+ const item = this.inputs.focusManager.inputs.activeItem();
590
+ if (item) {
591
+ this.inputs.value().includes(item.value()) ? this.deselect() : this.selectOne();
592
+ }
593
+ }
594
+ /** Selects all items in the list. */
595
+ selectAll() {
596
+ if (!this.inputs.multi()) {
597
+ return; // Should we log a warning?
598
+ }
599
+ for (const item of this.inputs.items()) {
600
+ this.select(item, { anchor: false });
601
+ }
602
+ this.beginRangeSelection();
603
+ }
604
+ /** Deselects all items in the list. */
605
+ deselectAll() {
606
+ // If an item is not in the list, it forcefully gets deselected.
607
+ // This actually creates a bug for the following edge case:
608
+ //
609
+ // Setup: An item is not in the list (maybe it's lazily loaded), and it is disabled & selected.
610
+ // Expected: If deselectAll() is called, it should NOT get deselected (because it is disabled).
611
+ // Actual: Calling deselectAll() will still deselect the item.
612
+ //
613
+ // Why? Because we can't check if the item is disabled if it's not in the list.
614
+ //
615
+ // Alternatively, we could NOT deselect items that are not in the list, but this has the
616
+ // inverse (and more common) effect of keeping enabled items selected when they aren't in the
617
+ // list.
618
+ for (const value of this.inputs.value()) {
619
+ const item = this.inputs.items().find(i => i.value() === value);
620
+ item
621
+ ? this.deselect(item)
622
+ : this.inputs.value.update(values => values.filter(v => v !== value));
623
+ }
624
+ }
625
+ /**
626
+ * Selects all items in the list or deselects all
627
+ * items in the list if all items are already selected.
628
+ */
629
+ toggleAll() {
630
+ const selectableValues = this.inputs
631
+ .items()
632
+ .filter(i => !i.disabled())
633
+ .map(i => i.value());
634
+ selectableValues.every(i => this.inputs.value().includes(i))
635
+ ? this.deselectAll()
636
+ : this.selectAll();
637
+ }
638
+ /** Sets the selection to only the current active item. */
639
+ selectOne() {
640
+ const item = this.inputs.focusManager.inputs.activeItem();
641
+ if (item && item.disabled()) {
642
+ return;
643
+ }
644
+ this.deselectAll();
645
+ if (this.inputs.value().length > 0 && !this.inputs.multi()) {
646
+ return;
647
+ }
648
+ this.select();
649
+ }
650
+ /**
651
+ * Selects all items in the list up to the anchor item.
652
+ *
653
+ * Deselects all items that were previously within the
654
+ * selected range that are now outside of the selected range
655
+ */
656
+ selectRange(opts = { anchor: true }) {
657
+ const isStartOfRange = this.inputs.focusManager.prevActiveIndex() === this.rangeStartIndex();
658
+ if (isStartOfRange && opts.anchor) {
659
+ this.beginRangeSelection(this.inputs.focusManager.prevActiveIndex());
660
+ }
661
+ const itemsInRange = this._getItemsFromIndex(this.rangeStartIndex());
662
+ const itemsOutOfRange = this._getItemsFromIndex(this.rangeEndIndex()).filter(i => !itemsInRange.includes(i));
663
+ for (const item of itemsOutOfRange) {
664
+ this.deselect(item);
665
+ }
666
+ for (const item of itemsInRange) {
667
+ this.select(item, { anchor: false });
668
+ }
669
+ if (itemsInRange.length) {
670
+ const item = itemsInRange.pop();
671
+ const index = this.inputs.items().findIndex(i => i === item);
672
+ this.rangeEndIndex.set(index);
673
+ }
674
+ }
675
+ /** Marks the given index as the start of a range selection. */
676
+ beginRangeSelection(index = this.inputs.focusManager.activeIndex()) {
677
+ this.rangeStartIndex.set(index);
678
+ this.rangeEndIndex.set(index);
679
+ }
680
+ /** Returns the items in the list starting from the given index. */
681
+ _getItemsFromIndex(index) {
682
+ if (index === -1) {
683
+ return [];
684
+ }
685
+ const upper = Math.max(this.inputs.focusManager.activeIndex(), index);
686
+ const lower = Math.min(this.inputs.focusManager.activeIndex(), index);
687
+ const items = [];
688
+ for (let i = lower; i <= upper; i++) {
689
+ items.push(this.inputs.items()[i]);
690
+ }
691
+ if (this.inputs.focusManager.activeIndex() < index) {
692
+ return items.reverse();
693
+ }
694
+ return items;
695
+ }
696
+ }
697
+
698
+ /** Controls typeahead for a list of items. */
699
+ class ListTypeahead {
700
+ inputs;
701
+ /** A reference to the timeout for resetting the typeahead search. */
702
+ timeout;
703
+ /** The focus controller of the parent list. */
704
+ focusManager;
705
+ /** Whether the user is actively typing a typeahead search query. */
706
+ isTyping = computed(() => this._query().length > 0);
707
+ /** Keeps track of the characters that typeahead search is being called with. */
708
+ _query = signal('');
709
+ /** The index where that the typeahead search was initiated from. */
710
+ _startIndex = signal(undefined);
711
+ constructor(inputs) {
712
+ this.inputs = inputs;
713
+ this.focusManager = inputs.focusManager;
714
+ }
715
+ /** Performs a typeahead search, appending the given character to the search string. */
716
+ search(char) {
717
+ if (char.length !== 1) {
718
+ return false;
719
+ }
720
+ if (!this.isTyping() && char === ' ') {
721
+ return false;
722
+ }
723
+ if (this._startIndex() === undefined) {
724
+ this._startIndex.set(this.focusManager.activeIndex());
725
+ }
726
+ clearTimeout(this.timeout);
727
+ this._query.update(q => q + char.toLowerCase());
728
+ const item = this._getItem();
729
+ if (item) {
730
+ this.focusManager.focus(item);
731
+ }
732
+ this.timeout = setTimeout(() => {
733
+ this._query.set('');
734
+ this._startIndex.set(undefined);
735
+ }, this.inputs.typeaheadDelay() * 1000);
736
+ return true;
737
+ }
738
+ /**
739
+ * Returns the first item whose search term matches the
740
+ * current query starting from the the current anchor index.
741
+ */
742
+ _getItem() {
743
+ let items = this.focusManager.inputs.items();
744
+ const after = items.slice(this._startIndex() + 1);
745
+ const before = items.slice(0, this._startIndex());
746
+ items = after.concat(before);
747
+ items.push(this.inputs.items()[this._startIndex()]);
748
+ const focusableItems = [];
749
+ for (const item of items) {
750
+ if (this.focusManager.isFocusable(item)) {
751
+ focusableItems.push(item);
752
+ }
753
+ }
754
+ return focusableItems.find(i => i.searchTerm().toLowerCase().startsWith(this._query()));
755
+ }
756
+ }
757
+
758
+ /** Controls the state of a list. */
759
+ class List {
760
+ inputs;
761
+ /** Controls navigation for the list. */
762
+ navigationBehavior;
763
+ /** Controls selection for the list. */
764
+ selectionBehavior;
765
+ /** Controls typeahead for the list. */
766
+ typeaheadBehavior;
767
+ /** Controls focus for the list. */
768
+ focusBehavior;
769
+ /** Whether the list is disabled. */
770
+ disabled = computed(() => this.focusBehavior.isListDisabled());
771
+ /** The id of the current active item. */
772
+ activedescendant = computed(() => this.focusBehavior.getActiveDescendant());
773
+ /** The tabindex of the list. */
774
+ tabindex = computed(() => this.focusBehavior.getListTabindex());
775
+ /** The index of the currently active item in the list. */
776
+ activeIndex = computed(() => this.focusBehavior.activeIndex());
777
+ /**
778
+ * The uncommitted index for selecting a range of options.
779
+ *
780
+ * NOTE: This is subtly distinct from the "rangeStartIndex" in the ListSelection behavior.
781
+ * The anchorIndex does not necessarily represent the start of a range, but represents the most
782
+ * recent index where the user showed intent to begin a range selection. Usually, this is wherever
783
+ * the user most recently pressed the "Shift" key, but if the user presses shift + space to select
784
+ * from the anchor, the user is not intending to start a new range from this index.
785
+ *
786
+ * In other words, "rangeStartIndex" is only set when a user commits to starting a range selection
787
+ * while "anchorIndex" is set whenever a user indicates they may be starting a range selection.
788
+ */
789
+ _anchorIndex = signal(0);
790
+ /** Whether the list should wrap. Used to disable wrapping while range selecting. */
791
+ _wrap = signal(true);
792
+ constructor(inputs) {
793
+ this.inputs = inputs;
794
+ this.focusBehavior = new ListFocus(inputs);
795
+ this.selectionBehavior = new ListSelection({ ...inputs, focusManager: this.focusBehavior });
796
+ this.typeaheadBehavior = new ListTypeahead({ ...inputs, focusManager: this.focusBehavior });
797
+ this.navigationBehavior = new ListNavigation({
798
+ ...inputs,
799
+ focusManager: this.focusBehavior,
800
+ wrap: computed(() => this._wrap() && this.inputs.wrap()),
801
+ });
802
+ }
803
+ /** Returns the tabindex for the given item. */
804
+ getItemTabindex(item) {
805
+ return this.focusBehavior.getItemTabindex(item);
806
+ }
807
+ /** Navigates to the first option in the list. */
808
+ first(opts) {
809
+ this._navigate(opts, () => this.navigationBehavior.first());
810
+ }
811
+ /** Navigates to the last option in the list. */
812
+ last(opts) {
813
+ this._navigate(opts, () => this.navigationBehavior.last());
814
+ }
815
+ /** Navigates to the next option in the list. */
816
+ next(opts) {
817
+ this._navigate(opts, () => this.navigationBehavior.next());
818
+ }
819
+ /** Navigates to the previous option in the list. */
820
+ prev(opts) {
821
+ this._navigate(opts, () => this.navigationBehavior.prev());
822
+ }
823
+ /** Navigates to the given item in the list. */
824
+ goto(item, opts) {
825
+ this._navigate(opts, () => this.navigationBehavior.goto(item));
826
+ }
827
+ /** Removes focus from the list. */
828
+ unfocus() {
829
+ this.inputs.activeItem.set(undefined);
830
+ }
831
+ /** Marks the given index as the potential start of a range selection. */
832
+ anchor(index) {
833
+ this._anchorIndex.set(index);
834
+ }
835
+ /** Handles typeahead search navigation for the list. */
836
+ search(char, opts) {
837
+ this._navigate(opts, () => this.typeaheadBehavior.search(char));
838
+ }
839
+ /** Checks if the list is currently typing for typeahead search. */
840
+ isTyping() {
841
+ return this.typeaheadBehavior.isTyping();
842
+ }
843
+ /** Selects the currently active item in the list. */
844
+ select(item) {
845
+ this.selectionBehavior.select(item);
846
+ }
847
+ /** Sets the selection to only the current active item. */
848
+ selectOne() {
849
+ this.selectionBehavior.selectOne();
850
+ }
851
+ /** Deselects the currently active item in the list. */
852
+ deselect() {
853
+ this.selectionBehavior.deselect();
854
+ }
855
+ /** Deselects all items in the list. */
856
+ deselectAll() {
857
+ this.selectionBehavior.deselectAll();
858
+ }
859
+ /** Toggles the currently active item in the list. */
860
+ toggle() {
861
+ this.selectionBehavior.toggle();
862
+ }
863
+ /** Toggles the currently active item in the list, deselecting all other items. */
864
+ toggleOne() {
865
+ this.selectionBehavior.toggleOne();
866
+ }
867
+ /** Toggles the selection of all items in the list. */
868
+ toggleAll() {
869
+ this.selectionBehavior.toggleAll();
870
+ }
871
+ /** Checks if the given item is able to receive focus. */
872
+ isFocusable(item) {
873
+ return this.focusBehavior.isFocusable(item);
874
+ }
875
+ /** Handles updating selection for the list. */
876
+ updateSelection(opts = { anchor: true }) {
877
+ if (opts.toggle) {
878
+ this.selectionBehavior.toggle();
879
+ }
880
+ if (opts.select) {
881
+ this.selectionBehavior.select();
882
+ }
883
+ if (opts.selectOne) {
884
+ this.selectionBehavior.selectOne();
885
+ }
886
+ if (opts.selectRange) {
887
+ this.selectionBehavior.selectRange();
888
+ }
889
+ if (!opts.anchor) {
890
+ this.anchor(this.selectionBehavior.rangeStartIndex());
891
+ }
892
+ }
893
+ /**
894
+ * Safely performs a navigation operation.
895
+ *
896
+ * Handles conditionally disabling wrapping for when a navigation
897
+ * operation is occurring while the user is selecting a range of options.
898
+ *
899
+ * Handles boilerplate calling of focus & selection operations. Also ensures these
900
+ * additional operations are only called if the navigation operation moved focus to a new option.
901
+ */
902
+ _navigate(opts = {}, operation) {
903
+ if (opts?.selectRange) {
904
+ this._wrap.set(false);
905
+ this.selectionBehavior.rangeStartIndex.set(this._anchorIndex());
906
+ }
907
+ const moved = operation();
908
+ if (moved) {
909
+ this.updateSelection(opts);
910
+ }
911
+ this._wrap.set(true);
912
+ }
913
+ }
914
+
915
+ /** Controls the state of a listbox. */
916
+ class ListboxPattern {
917
+ inputs;
918
+ listBehavior;
919
+ /** Whether the list is vertically or horizontally oriented. */
920
+ orientation;
921
+ /** Whether the listbox is disabled. */
922
+ disabled = computed(() => this.listBehavior.disabled());
923
+ /** Whether the listbox is readonly. */
924
+ readonly;
925
+ /** The tabindex of the listbox. */
926
+ tabindex = computed(() => this.listBehavior.tabindex());
927
+ /** The id of the current active item. */
928
+ activedescendant = computed(() => this.listBehavior.activedescendant());
929
+ /** Whether multiple items in the list can be selected at once. */
930
+ multi;
931
+ /** The number of items in the listbox. */
932
+ setsize = computed(() => this.inputs.items().length);
933
+ /** Whether the listbox selection follows focus. */
934
+ followFocus = computed(() => this.inputs.selectionMode() === 'follow');
935
+ /** Whether the listbox should wrap. Used to disable wrapping while range selecting. */
936
+ wrap = signal(true);
937
+ /** The key used to navigate to the previous item in the list. */
938
+ prevKey = computed(() => {
939
+ if (this.inputs.orientation() === 'vertical') {
940
+ return 'ArrowUp';
941
+ }
942
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
943
+ });
944
+ /** The key used to navigate to the next item in the list. */
945
+ nextKey = computed(() => {
946
+ if (this.inputs.orientation() === 'vertical') {
947
+ return 'ArrowDown';
948
+ }
949
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
950
+ });
951
+ /** Represents the space key. Does nothing when the user is actively using typeahead. */
952
+ dynamicSpaceKey = computed(() => (this.listBehavior.isTyping() ? '' : ' '));
953
+ /** The regexp used to decide if a key should trigger typeahead. */
954
+ typeaheadRegexp = /^.$/; // TODO: Ignore spaces?
955
+ /** The keydown event manager for the listbox. */
956
+ keydown = computed(() => {
957
+ const manager = new KeyboardEventManager();
958
+ if (this.readonly()) {
959
+ return manager
960
+ .on(this.prevKey, () => this.listBehavior.prev())
961
+ .on(this.nextKey, () => this.listBehavior.next())
962
+ .on('Home', () => this.listBehavior.first())
963
+ .on('End', () => this.listBehavior.last())
964
+ .on(this.typeaheadRegexp, e => this.listBehavior.search(e.key));
965
+ }
966
+ if (!this.followFocus()) {
967
+ manager
968
+ .on(this.prevKey, () => this.listBehavior.prev())
969
+ .on(this.nextKey, () => this.listBehavior.next())
970
+ .on('Home', () => this.listBehavior.first())
971
+ .on('End', () => this.listBehavior.last())
972
+ .on(this.typeaheadRegexp, e => this.listBehavior.search(e.key));
973
+ }
974
+ if (this.followFocus()) {
975
+ manager
976
+ .on(this.prevKey, () => this.listBehavior.prev({ selectOne: true }))
977
+ .on(this.nextKey, () => this.listBehavior.next({ selectOne: true }))
978
+ .on('Home', () => this.listBehavior.first({ selectOne: true }))
979
+ .on('End', () => this.listBehavior.last({ selectOne: true }))
980
+ .on(this.typeaheadRegexp, e => this.listBehavior.search(e.key, { selectOne: true }));
981
+ }
982
+ if (this.inputs.multi()) {
983
+ manager
984
+ .on(Modifier.Any, 'Shift', () => this.listBehavior.anchor(this.listBehavior.activeIndex()))
985
+ .on(Modifier.Shift, this.prevKey, () => this.listBehavior.prev({ selectRange: true }))
986
+ .on(Modifier.Shift, this.nextKey, () => this.listBehavior.next({ selectRange: true }))
987
+ .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => this.listBehavior.first({ selectRange: true, anchor: false }))
988
+ .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'End', () => this.listBehavior.last({ selectRange: true, anchor: false }))
989
+ .on(Modifier.Shift, 'Enter', () => this.listBehavior.updateSelection({ selectRange: true, anchor: false }))
990
+ .on(Modifier.Shift, this.dynamicSpaceKey, () => this.listBehavior.updateSelection({ selectRange: true, anchor: false }));
991
+ }
992
+ if (!this.followFocus() && this.inputs.multi()) {
993
+ manager
994
+ .on(this.dynamicSpaceKey, () => this.listBehavior.toggle())
995
+ .on('Enter', () => this.listBehavior.toggle())
996
+ .on([Modifier.Ctrl, Modifier.Meta], 'A', () => this.listBehavior.toggleAll());
997
+ }
998
+ if (!this.followFocus() && !this.inputs.multi()) {
999
+ manager.on(this.dynamicSpaceKey, () => this.listBehavior.toggleOne());
1000
+ manager.on('Enter', () => this.listBehavior.toggleOne());
1001
+ }
1002
+ if (this.inputs.multi() && this.followFocus()) {
1003
+ manager
1004
+ .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => this.listBehavior.prev())
1005
+ .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => this.listBehavior.next())
1006
+ .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this.listBehavior.toggle())
1007
+ .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => this.listBehavior.toggle())
1008
+ .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => this.listBehavior.first())
1009
+ .on([Modifier.Ctrl, Modifier.Meta], 'End', () => this.listBehavior.last())
1010
+ .on([Modifier.Ctrl, Modifier.Meta], 'A', () => {
1011
+ this.listBehavior.toggleAll();
1012
+ this.listBehavior.select(); // Ensure the currect option remains selected.
1013
+ });
1014
+ }
1015
+ return manager;
1016
+ });
1017
+ /** The pointerdown event manager for the listbox. */
1018
+ pointerdown = computed(() => {
1019
+ const manager = new PointerEventManager();
1020
+ if (this.readonly()) {
1021
+ return manager.on(e => this.listBehavior.goto(this._getItem(e)));
1022
+ }
1023
+ if (this.multi()) {
1024
+ manager.on(Modifier.Shift, e => this.listBehavior.goto(this._getItem(e), { selectRange: true }));
1025
+ }
1026
+ if (!this.multi() && this.followFocus()) {
1027
+ return manager.on(e => this.listBehavior.goto(this._getItem(e), { selectOne: true }));
1028
+ }
1029
+ if (!this.multi() && !this.followFocus()) {
1030
+ return manager.on(e => this.listBehavior.goto(this._getItem(e), { toggle: true }));
1031
+ }
1032
+ if (this.multi() && this.followFocus()) {
1033
+ return manager
1034
+ .on(e => this.listBehavior.goto(this._getItem(e), { selectOne: true }))
1035
+ .on(Modifier.Ctrl, e => this.listBehavior.goto(this._getItem(e), { toggle: true }));
1036
+ }
1037
+ if (this.multi() && !this.followFocus()) {
1038
+ return manager.on(e => this.listBehavior.goto(this._getItem(e), { toggle: true }));
1039
+ }
1040
+ return manager;
1041
+ });
1042
+ constructor(inputs) {
1043
+ this.inputs = inputs;
1044
+ this.readonly = inputs.readonly;
1045
+ this.orientation = inputs.orientation;
1046
+ this.multi = inputs.multi;
1047
+ this.listBehavior = new List(inputs);
1048
+ }
1049
+ /** Returns a set of violations */
1050
+ validate() {
1051
+ const violations = [];
1052
+ if (!this.inputs.multi() && this.inputs.value().length > 1) {
1053
+ violations.push(`A single-select listbox should not have multiple selected options. Selected options: ${this.inputs.value().join(', ')}`);
1054
+ }
1055
+ return violations;
1056
+ }
1057
+ /** Handles keydown events for the listbox. */
1058
+ onKeydown(event) {
1059
+ if (!this.disabled()) {
1060
+ this.keydown().handle(event);
1061
+ }
1062
+ }
1063
+ onPointerdown(event) {
1064
+ if (!this.disabled()) {
1065
+ this.pointerdown().handle(event);
1066
+ }
1067
+ }
1068
+ /**
1069
+ * Sets the listbox to it's default initial state.
1070
+ *
1071
+ * Sets the active index of the listbox to the first focusable selected
1072
+ * item if one exists. Otherwise, sets focus to the first focusable item.
1073
+ *
1074
+ * This method should be called once the listbox and it's options are properly initialized,
1075
+ * meaning the ListboxPattern and OptionPatterns should have references to each other before this
1076
+ * is called.
1077
+ */
1078
+ setDefaultState() {
1079
+ let firstItem = null;
1080
+ for (const item of this.inputs.items()) {
1081
+ if (this.listBehavior.isFocusable(item)) {
1082
+ if (!firstItem) {
1083
+ firstItem = item;
1084
+ }
1085
+ if (item.selected()) {
1086
+ this.inputs.activeItem.set(item);
1087
+ return;
1088
+ }
1089
+ }
1090
+ }
1091
+ if (firstItem) {
1092
+ this.inputs.activeItem.set(firstItem);
1093
+ }
1094
+ }
1095
+ _getItem(e) {
1096
+ if (!(e.target instanceof HTMLElement)) {
1097
+ return;
1098
+ }
1099
+ const element = e.target.closest('[role="option"]');
1100
+ return this.inputs.items().find(i => i.element() === element);
1101
+ }
1102
+ }
1103
+
1104
+ /** Represents an option in a listbox. */
1105
+ class OptionPattern {
1106
+ /** A unique identifier for the option. */
1107
+ id;
1108
+ /** The value of the option. */
1109
+ value;
1110
+ /** The position of the option in the list. */
1111
+ index = computed(() => this.listbox()?.inputs.items().indexOf(this) ?? -1);
1112
+ /** Whether the option is active. */
1113
+ active = computed(() => this.listbox()?.inputs.activeItem() === this);
1114
+ /** Whether the option is selected. */
1115
+ selected = computed(() => this.listbox()?.inputs.value().includes(this.value()));
1116
+ /** Whether the option is disabled. */
1117
+ disabled;
1118
+ /** The text used by the typeahead search. */
1119
+ searchTerm;
1120
+ /** A reference to the parent listbox. */
1121
+ listbox;
1122
+ /** The tabindex of the option. */
1123
+ tabindex = computed(() => this.listbox()?.listBehavior.getItemTabindex(this));
1124
+ /** The html element that should receive focus. */
1125
+ element;
1126
+ constructor(args) {
1127
+ this.id = args.id;
1128
+ this.value = args.value;
1129
+ this.listbox = args.listbox;
1130
+ this.element = args.element;
1131
+ this.disabled = args.disabled;
1132
+ this.searchTerm = args.searchTerm;
1133
+ }
1134
+ }
1135
+
1136
+ class ComboboxListboxPattern extends ListboxPattern {
1137
+ inputs;
1138
+ /** A unique identifier for the popup. */
1139
+ id = computed(() => this.inputs.id());
1140
+ /** The ARIA role for the listbox. */
1141
+ role = computed(() => 'listbox');
1142
+ /** The id of the active (focused) item in the listbox. */
1143
+ activeId = computed(() => this.listBehavior.activedescendant());
1144
+ /** The list of options in the listbox. */
1145
+ items = computed(() => this.inputs.items());
1146
+ /** The tabindex for the listbox. Always -1 because the combobox handles focus. */
1147
+ tabindex = () => -1;
1148
+ constructor(inputs) {
1149
+ if (inputs.combobox()) {
1150
+ inputs.multi = () => false;
1151
+ inputs.focusMode = () => 'activedescendant';
1152
+ inputs.element = inputs.combobox().inputs.inputEl;
1153
+ }
1154
+ super(inputs);
1155
+ this.inputs = inputs;
1156
+ }
1157
+ /** Noop. The combobox handles keydown events. */
1158
+ onKeydown(_) { }
1159
+ /** Noop. The combobox handles pointerdown events. */
1160
+ onPointerdown(_) { }
1161
+ /** Noop. The combobox controls the open state. */
1162
+ setDefaultState() { }
1163
+ /** Navigates to the specified item in the listbox. */
1164
+ focus = (item) => this.listBehavior.goto(item);
1165
+ /** Navigates to the next focusable item in the listbox. */
1166
+ next = () => this.listBehavior.next();
1167
+ /** Navigates to the previous focusable item in the listbox. */
1168
+ prev = () => this.listBehavior.prev();
1169
+ /** Navigates to the last focusable item in the listbox. */
1170
+ last = () => this.listBehavior.last();
1171
+ /** Navigates to the first focusable item in the listbox. */
1172
+ first = () => this.listBehavior.first();
1173
+ /** Unfocuses the currently focused item in the listbox. */
1174
+ unfocus = () => this.listBehavior.unfocus();
1175
+ /** Selects the specified item in the listbox. */
1176
+ select = (item) => this.listBehavior.select(item);
1177
+ /** Clears the selection in the listbox. */
1178
+ clearSelection = () => this.listBehavior.deselectAll();
1179
+ /** Retrieves the OptionPattern associated with a pointer event. */
1180
+ getItem = (e) => this._getItem(e);
1181
+ /** Retrieves the currently selected item in the listbox. */
1182
+ getSelectedItem = () => this.inputs.items().find(i => i.selected());
1183
+ /** Sets the value of the combobox listbox. */
1184
+ setValue = (value) => this.inputs.value.set(value ? [value] : []);
1185
+ }
1186
+
1187
+ /** Controls the state of a radio group. */
1188
+ class RadioGroupPattern {
1189
+ inputs;
1190
+ /** The list behavior for the radio group. */
1191
+ listBehavior;
1192
+ /** Whether the radio group is vertically or horizontally oriented. */
1193
+ orientation;
1194
+ /** Whether focus should wrap when navigating. */
1195
+ wrap = signal(false);
1196
+ /** The selection strategy used by the radio group. */
1197
+ selectionMode = signal('follow');
1198
+ /** Whether the radio group is disabled. */
1199
+ disabled = computed(() => this.inputs.disabled() || this.listBehavior.disabled());
1200
+ /** The currently selected radio button. */
1201
+ selectedItem = computed(() => this.listBehavior.selectionBehavior.selectedItems()[0]);
1202
+ /** Whether the radio group is readonly. */
1203
+ readonly = computed(() => this.selectedItem()?.disabled() || this.inputs.readonly());
1204
+ /** The tabindex of the radio group. */
1205
+ tabindex = computed(() => this.listBehavior.tabindex());
1206
+ /** The id of the current active radio button (if using activedescendant). */
1207
+ activedescendant = computed(() => this.listBehavior.activedescendant());
1208
+ /** The key used to navigate to the previous radio button. */
1209
+ _prevKey = computed(() => {
1210
+ if (this.inputs.orientation() === 'vertical') {
1211
+ return 'ArrowUp';
1212
+ }
1213
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
1214
+ });
1215
+ /** The key used to navigate to the next radio button. */
1216
+ _nextKey = computed(() => {
1217
+ if (this.inputs.orientation() === 'vertical') {
1218
+ return 'ArrowDown';
1219
+ }
1220
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
1221
+ });
1222
+ /** The keydown event manager for the radio group. */
1223
+ keydown = computed(() => {
1224
+ const manager = new KeyboardEventManager();
1225
+ // Readonly mode allows navigation but not selection changes.
1226
+ if (this.readonly()) {
1227
+ return manager
1228
+ .on(this._prevKey, () => this.listBehavior.prev())
1229
+ .on(this._nextKey, () => this.listBehavior.next())
1230
+ .on('Home', () => this.listBehavior.first())
1231
+ .on('End', () => this.listBehavior.last());
1232
+ }
1233
+ // Default behavior: navigate and select on arrow keys, home, end.
1234
+ // Space/Enter also select the focused item.
1235
+ return manager
1236
+ .on(this._prevKey, () => this.listBehavior.prev({ selectOne: true }))
1237
+ .on(this._nextKey, () => this.listBehavior.next({ selectOne: true }))
1238
+ .on('Home', () => this.listBehavior.first({ selectOne: true }))
1239
+ .on('End', () => this.listBehavior.last({ selectOne: true }))
1240
+ .on(' ', () => this.listBehavior.selectOne())
1241
+ .on('Enter', () => this.listBehavior.selectOne());
1242
+ });
1243
+ /** The pointerdown event manager for the radio group. */
1244
+ pointerdown = computed(() => {
1245
+ const manager = new PointerEventManager();
1246
+ if (this.readonly()) {
1247
+ // Navigate focus only in readonly mode.
1248
+ return manager.on(e => this.listBehavior.goto(this.inputs.getItem(e)));
1249
+ }
1250
+ // Default behavior: navigate and select on click.
1251
+ return manager.on(e => this.listBehavior.goto(this.inputs.getItem(e), { selectOne: true }));
1252
+ });
1253
+ constructor(inputs) {
1254
+ this.inputs = inputs;
1255
+ this.orientation = inputs.orientation;
1256
+ this.listBehavior = new List({
1257
+ ...inputs,
1258
+ wrap: this.wrap,
1259
+ selectionMode: this.selectionMode,
1260
+ multi: () => false,
1261
+ typeaheadDelay: () => 0, // Radio groups do not support typeahead.
1262
+ });
1263
+ }
1264
+ /** Handles keydown events for the radio group. */
1265
+ onKeydown(event) {
1266
+ if (!this.disabled()) {
1267
+ this.keydown().handle(event);
1268
+ }
1269
+ }
1270
+ /** Handles pointerdown events for the radio group. */
1271
+ onPointerdown(event) {
1272
+ if (!this.disabled()) {
1273
+ this.pointerdown().handle(event);
1274
+ }
1275
+ }
1276
+ /**
1277
+ * Sets the radio group to its default initial state.
1278
+ *
1279
+ * Sets the active index to the selected radio button if one exists and is focusable.
1280
+ * Otherwise, sets the active index to the first focusable radio button.
1281
+ */
1282
+ setDefaultState() {
1283
+ let firstItem = null;
1284
+ for (const item of this.inputs.items()) {
1285
+ if (this.listBehavior.isFocusable(item)) {
1286
+ if (!firstItem) {
1287
+ firstItem = item;
1288
+ }
1289
+ if (item.selected()) {
1290
+ this.inputs.activeItem.set(item);
1291
+ return;
1292
+ }
1293
+ }
1294
+ }
1295
+ if (firstItem) {
1296
+ this.inputs.activeItem.set(firstItem);
1297
+ }
1298
+ }
1299
+ /** Validates the state of the radio group and returns a list of accessibility violations. */
1300
+ validate() {
1301
+ const violations = [];
1302
+ if (this.selectedItem()?.disabled() && this.inputs.skipDisabled()) {
1303
+ violations.push("Accessibility Violation: The selected radio button is disabled while 'skipDisabled' is true, making the selection unreachable via keyboard.");
1304
+ }
1305
+ return violations;
1306
+ }
1307
+ }
1308
+
1309
+ /** Represents a radio button within a radio group. */
1310
+ class RadioButtonPattern {
1311
+ inputs;
1312
+ /** A unique identifier for the radio button. */
1313
+ id;
1314
+ /** The value associated with the radio button. */
1315
+ value;
1316
+ /** The position of the radio button within the group. */
1317
+ index = computed(() => this.group()?.listBehavior.inputs.items().indexOf(this) ?? -1);
1318
+ /** Whether the radio button is currently the active one (focused). */
1319
+ active = computed(() => this.group()?.listBehavior.inputs.activeItem() === this);
1320
+ /** Whether the radio button is selected. */
1321
+ selected = computed(() => !!this.group()?.listBehavior.inputs.value().includes(this.value()));
1322
+ /** Whether the radio button is disabled. */
1323
+ disabled;
1324
+ /** A reference to the parent radio group. */
1325
+ group;
1326
+ /** The tabindex of the radio button. */
1327
+ tabindex = computed(() => this.group()?.listBehavior.getItemTabindex(this));
1328
+ /** The HTML element associated with the radio button. */
1329
+ element;
1330
+ /** The search term for typeahead. */
1331
+ searchTerm = () => ''; // Radio groups do not support typeahead.
1332
+ constructor(inputs) {
1333
+ this.inputs = inputs;
1334
+ this.id = inputs.id;
1335
+ this.value = inputs.value;
1336
+ this.group = inputs.group;
1337
+ this.element = inputs.element;
1338
+ this.disabled = inputs.disabled;
1339
+ }
1340
+ }
1341
+
1342
+ /** Controls the state of a radio group in a toolbar. */
1343
+ class ToolbarRadioGroupPattern extends RadioGroupPattern {
1344
+ inputs;
1345
+ constructor(inputs) {
1346
+ if (!!inputs.toolbar()) {
1347
+ inputs.orientation = inputs.toolbar().orientation;
1348
+ inputs.skipDisabled = inputs.toolbar().skipDisabled;
1349
+ }
1350
+ super(inputs);
1351
+ this.inputs = inputs;
1352
+ }
1353
+ /** Noop. The toolbar handles keydown events. */
1354
+ onKeydown(_) { }
1355
+ /** Noop. The toolbar handles pointerdown events. */
1356
+ onPointerdown(_) { }
1357
+ /** Whether the radio group is currently on the first item. */
1358
+ isOnFirstItem() {
1359
+ return this.listBehavior.navigationBehavior.peekPrev() === undefined;
1360
+ }
1361
+ /** Whether the radio group is currently on the last item. */
1362
+ isOnLastItem() {
1363
+ return this.listBehavior.navigationBehavior.peekNext() === undefined;
1364
+ }
1365
+ /** Navigates to the next radio button in the group. */
1366
+ next(wrap) {
1367
+ this.wrap.set(wrap);
1368
+ this.listBehavior.next();
1369
+ this.wrap.set(false);
1370
+ }
1371
+ /** Navigates to the previous radio button in the group. */
1372
+ prev(wrap) {
1373
+ this.wrap.set(wrap);
1374
+ this.listBehavior.prev();
1375
+ this.wrap.set(false);
1376
+ }
1377
+ /** Navigates to the first radio button in the group. */
1378
+ first() {
1379
+ this.listBehavior.first();
1380
+ }
1381
+ /** Navigates to the last radio button in the group. */
1382
+ last() {
1383
+ this.listBehavior.last();
1384
+ }
1385
+ /** Removes focus from the radio group. */
1386
+ unfocus() {
1387
+ this.inputs.activeItem.set(undefined);
1388
+ }
1389
+ /** Triggers the action of the currently active radio button in the group. */
1390
+ trigger() {
1391
+ if (this.readonly())
1392
+ return;
1393
+ this.listBehavior.selectOne();
1394
+ }
1395
+ /** Navigates to the radio button targeted by a pointer event. */
1396
+ goto(e) {
1397
+ this.listBehavior.goto(this.inputs.getItem(e), {
1398
+ selectOne: !this.readonly(),
1399
+ });
1400
+ }
1401
+ }
1402
+
1403
+ /** Converts a getter setter style signal to a WritableSignalLike. */
1404
+ function convertGetterSetterToWritableSignalLike(getter, setter) {
1405
+ // tslint:disable-next-line:ban Have to use `Object.assign` to preserve the getter function.
1406
+ return Object.assign(getter, {
1407
+ set: setter,
1408
+ update: (updateCallback) => setter(updateCallback(getter())),
1409
+ });
1410
+ }
1411
+
1412
+ /**
1413
+ * Controls a single item's expansion state and interactions,
1414
+ * delegating actual state changes to an Expansion manager.
1415
+ */
1416
+ class ExpansionControl {
1417
+ inputs;
1418
+ /** Whether this specific item is currently expanded. Derived from the Expansion manager. */
1419
+ isExpanded = computed(() => this.inputs.expansionManager.isExpanded(this));
1420
+ /** Whether this item can be expanded. */
1421
+ isExpandable = computed(() => this.inputs.expansionManager.isExpandable(this));
1422
+ constructor(inputs) {
1423
+ this.inputs = inputs;
1424
+ this.expansionId = inputs.expansionId;
1425
+ this.expandable = inputs.expandable;
1426
+ this.disabled = inputs.disabled;
1427
+ }
1428
+ /** Requests the Expansion manager to open this item. */
1429
+ open() {
1430
+ this.inputs.expansionManager.open(this);
1431
+ }
1432
+ /** Requests the Expansion manager to close this item. */
1433
+ close() {
1434
+ this.inputs.expansionManager.close(this);
1435
+ }
1436
+ /** Requests the Expansion manager to toggle this item. */
1437
+ toggle() {
1438
+ this.inputs.expansionManager.toggle(this);
1439
+ }
1440
+ }
1441
+ /** Manages the expansion state of a list of items. */
1442
+ class ListExpansion {
1443
+ inputs;
1444
+ /** A signal holding an array of ids of the currently expanded items. */
1445
+ expandedIds;
1446
+ constructor(inputs) {
1447
+ this.inputs = inputs;
1448
+ this.expandedIds = inputs.expandedIds;
1449
+ }
1450
+ /** Opens the specified item. */
1451
+ open(item) {
1452
+ if (!this.isExpandable(item))
1453
+ return;
1454
+ if (this.isExpanded(item))
1455
+ return;
1456
+ if (!this.inputs.multiExpandable()) {
1457
+ this.closeAll();
1458
+ }
1459
+ this.expandedIds.update(ids => ids.concat(item.expansionId()));
1460
+ }
1461
+ /** Closes the specified item. */
1462
+ close(item) {
1463
+ if (this.isExpandable(item)) {
1464
+ this.expandedIds.update(ids => ids.filter(id => id !== item.expansionId()));
1465
+ }
1466
+ }
1467
+ /** Toggles the expansion state of the specified item. */
1468
+ toggle(item) {
1469
+ this.expandedIds().includes(item.expansionId()) ? this.close(item) : this.open(item);
1470
+ }
1471
+ /** Opens all focusable items in the list. */
1472
+ openAll() {
1473
+ if (this.inputs.multiExpandable()) {
1474
+ for (const item of this.inputs.items()) {
1475
+ this.open(item);
1476
+ }
1477
+ }
1478
+ }
1479
+ /** Closes all focusable items in the list. */
1480
+ closeAll() {
1481
+ for (const item of this.inputs.items()) {
1482
+ this.close(item);
1483
+ }
1484
+ }
1485
+ /** Checks whether the specified item is expandable / collapsible. */
1486
+ isExpandable(item) {
1487
+ return !this.inputs.disabled() && !item.disabled() && item.expandable();
1488
+ }
1489
+ /** Checks whether the specified item is currently expanded. */
1490
+ isExpanded(item) {
1491
+ return this.expandedIds().includes(item.expansionId());
1492
+ }
1493
+ }
1494
+
1495
+ /** Controls label and description of an element. */
1496
+ class LabelControl {
1497
+ inputs;
1498
+ /** The `aria-label`. */
1499
+ label = computed(() => this.inputs.label?.());
1500
+ /** The `aria-labelledby` ids. */
1501
+ labelledBy = computed(() => {
1502
+ const label = this.label();
1503
+ const labelledBy = this.inputs.labelledBy?.();
1504
+ const defaultLabelledBy = this.inputs.defaultLabelledBy();
1505
+ if (labelledBy && labelledBy.length > 0) {
1506
+ return labelledBy;
1507
+ }
1508
+ // If an aria-label is provided by developers, do not set aria-labelledby with the
1509
+ // defaultLabelledBy value because if both attributes are set, aria-labelledby will be used.
1510
+ if (label) {
1511
+ return [];
1512
+ }
1513
+ return defaultLabelledBy;
1514
+ });
1515
+ constructor(inputs) {
1516
+ this.inputs = inputs;
1517
+ }
1518
+ }
1519
+
1520
+ /** A tab in a tablist. */
1521
+ class TabPattern {
1522
+ inputs;
1523
+ /** Controls expansion for this tab. */
1524
+ expansion;
1525
+ /** A global unique identifier for the tab. */
1526
+ id;
1527
+ /** The index of the tab. */
1528
+ index = computed(() => this.inputs.tablist().inputs.items().indexOf(this));
1529
+ /** A local unique identifier for the tab. */
1530
+ value;
1531
+ /** Whether the tab is disabled. */
1532
+ disabled;
1533
+ /** The html element that should receive focus. */
1534
+ element;
1535
+ /** The text used by the typeahead search. */
1536
+ searchTerm = () => ''; // Unused because tabs do not support typeahead.
1537
+ /** Whether this tab has expandable content. */
1538
+ expandable = computed(() => this.expansion.expandable());
1539
+ /** The unique identifier used by the expansion behavior. */
1540
+ expansionId = computed(() => this.expansion.expansionId());
1541
+ /** Whether the tab is expanded. */
1542
+ expanded = computed(() => this.expansion.isExpanded());
1543
+ /** Whether the tab is active. */
1544
+ active = computed(() => this.inputs.tablist().inputs.activeItem() === this);
1545
+ /** Whether the tab is selected. */
1546
+ selected = computed(() => !!this.inputs.tablist().inputs.value().includes(this.value()));
1547
+ /** The tabindex of the tab. */
1548
+ tabindex = computed(() => this.inputs.tablist().listBehavior.getItemTabindex(this));
1549
+ /** The id of the tabpanel associated with the tab. */
1550
+ controls = computed(() => this.inputs.tabpanel()?.id());
1551
+ constructor(inputs) {
1552
+ this.inputs = inputs;
1553
+ this.id = inputs.id;
1554
+ this.value = inputs.value;
1555
+ this.disabled = inputs.disabled;
1556
+ this.element = inputs.element;
1557
+ this.expansion = new ExpansionControl({
1558
+ ...inputs,
1559
+ expansionId: inputs.value,
1560
+ expandable: () => true,
1561
+ expansionManager: inputs.tablist().expansionManager,
1562
+ });
1563
+ }
1564
+ }
1565
+ /** A tabpanel associated with a tab. */
1566
+ class TabPanelPattern {
1567
+ inputs;
1568
+ /** A global unique identifier for the tabpanel. */
1569
+ id;
1570
+ /** A local unique identifier for the tabpanel. */
1571
+ value;
1572
+ /** Controls label for this tabpanel. */
1573
+ labelManager;
1574
+ /** Whether the tabpanel is hidden. */
1575
+ hidden = computed(() => this.inputs.tab()?.expanded() === false);
1576
+ /** The tabindex of this tabpanel. */
1577
+ tabindex = computed(() => (this.hidden() ? -1 : 0));
1578
+ /** The aria-labelledby value for this tabpanel. */
1579
+ labelledBy = computed(() => this.labelManager.labelledBy().length > 0
1580
+ ? this.labelManager.labelledBy().join(' ')
1581
+ : undefined);
1582
+ constructor(inputs) {
1583
+ this.inputs = inputs;
1584
+ this.id = inputs.id;
1585
+ this.value = inputs.value;
1586
+ this.labelManager = new LabelControl({
1587
+ ...inputs,
1588
+ defaultLabelledBy: computed(() => (this.inputs.tab() ? [this.inputs.tab().id()] : [])),
1589
+ });
1590
+ }
1591
+ }
1592
+ /** Controls the state of a tablist. */
1593
+ class TabListPattern {
1594
+ inputs;
1595
+ /** The list behavior for the tablist. */
1596
+ listBehavior;
1597
+ /** Controls expansion for the tablist. */
1598
+ expansionManager;
1599
+ /** Whether the tablist is vertically or horizontally oriented. */
1600
+ orientation;
1601
+ /** Whether the tablist is disabled. */
1602
+ disabled;
1603
+ /** The tabindex of the tablist. */
1604
+ tabindex = computed(() => this.listBehavior.tabindex());
1605
+ /** The id of the current active tab. */
1606
+ activedescendant = computed(() => this.listBehavior.activedescendant());
1607
+ /** Whether selection should follow focus. */
1608
+ followFocus = computed(() => this.inputs.selectionMode() === 'follow');
1609
+ /** The key used to navigate to the previous tab in the tablist. */
1610
+ prevKey = computed(() => {
1611
+ if (this.inputs.orientation() === 'vertical') {
1612
+ return 'ArrowUp';
1613
+ }
1614
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
1615
+ });
1616
+ /** The key used to navigate to the next item in the list. */
1617
+ nextKey = computed(() => {
1618
+ if (this.inputs.orientation() === 'vertical') {
1619
+ return 'ArrowDown';
1620
+ }
1621
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
1622
+ });
1623
+ /** The keydown event manager for the tablist. */
1624
+ keydown = computed(() => {
1625
+ return new KeyboardEventManager()
1626
+ .on(this.prevKey, () => this.listBehavior.prev({ select: this.followFocus() }))
1627
+ .on(this.nextKey, () => this.listBehavior.next({ select: this.followFocus() }))
1628
+ .on('Home', () => this.listBehavior.first({ select: this.followFocus() }))
1629
+ .on('End', () => this.listBehavior.last({ select: this.followFocus() }))
1630
+ .on(' ', () => this.listBehavior.select())
1631
+ .on('Enter', () => this.listBehavior.select());
1632
+ });
1633
+ /** The pointerdown event manager for the tablist. */
1634
+ pointerdown = computed(() => {
1635
+ return new PointerEventManager().on(e => this.listBehavior.goto(this._getItem(e), { select: true }));
1636
+ });
1637
+ constructor(inputs) {
1638
+ this.inputs = inputs;
1639
+ this.disabled = inputs.disabled;
1640
+ this.orientation = inputs.orientation;
1641
+ this.listBehavior = new List({
1642
+ ...inputs,
1643
+ multi: () => false,
1644
+ typeaheadDelay: () => 0, // Tabs do not support typeahead.
1645
+ });
1646
+ this.expansionManager = new ListExpansion({
1647
+ ...inputs,
1648
+ multiExpandable: () => false,
1649
+ expandedIds: this.inputs.value,
1650
+ });
1651
+ }
1652
+ /**
1653
+ * Sets the tablist to its default initial state.
1654
+ *
1655
+ * Sets the active index of the tablist to the first focusable selected
1656
+ * tab if one exists. Otherwise, sets focus to the first focusable tab.
1657
+ *
1658
+ * This method should be called once the tablist and its tabs are properly initialized.
1659
+ */
1660
+ setDefaultState() {
1661
+ let firstItem;
1662
+ for (const item of this.inputs.items()) {
1663
+ if (!this.listBehavior.isFocusable(item))
1664
+ continue;
1665
+ if (firstItem === undefined) {
1666
+ firstItem = item;
1667
+ }
1668
+ if (item.selected()) {
1669
+ this.inputs.activeItem.set(item);
1670
+ return;
1671
+ }
1672
+ }
1673
+ if (firstItem !== undefined) {
1674
+ this.inputs.activeItem.set(firstItem);
1675
+ }
1676
+ }
1677
+ /** Handles keydown events for the tablist. */
1678
+ onKeydown(event) {
1679
+ if (!this.disabled()) {
1680
+ this.keydown().handle(event);
1681
+ }
1682
+ }
1683
+ /** The pointerdown event manager for the tablist. */
1684
+ onPointerdown(event) {
1685
+ if (!this.disabled()) {
1686
+ this.pointerdown().handle(event);
1687
+ }
1688
+ }
1689
+ /** Returns the tab item associated with the given pointer event. */
1690
+ _getItem(e) {
1691
+ if (!(e.target instanceof HTMLElement)) {
1692
+ return;
1693
+ }
1694
+ const element = e.target.closest('[role="tab"]');
1695
+ return this.inputs.items().find(i => i.element() === element);
1696
+ }
1697
+ }
1698
+
1699
+ class ToolbarWidgetPattern {
1700
+ inputs;
1701
+ /** A unique identifier for the widget. */
1702
+ id;
1703
+ /** The html element that should receive focus. */
1704
+ element;
1705
+ /** Whether the widget is disabled. */
1706
+ disabled;
1707
+ /** A reference to the parent toolbar. */
1708
+ toolbar;
1709
+ /** The tabindex of the widgdet. */
1710
+ tabindex = computed(() => this.toolbar().listBehavior.getItemTabindex(this));
1711
+ /** The text used by the typeahead search. */
1712
+ searchTerm = () => ''; // Unused because toolbar does not support typeahead.
1713
+ /** The value associated with the widget. */
1714
+ value = () => ''; // Unused because toolbar does not support selection.
1715
+ /** The position of the widget within the toolbar. */
1716
+ index = computed(() => this.toolbar().inputs.items().indexOf(this) ?? -1);
1717
+ /** Whether the widget is currently the active one (focused). */
1718
+ active = computed(() => this.toolbar().inputs.activeItem() === this);
1719
+ constructor(inputs) {
1720
+ this.inputs = inputs;
1721
+ this.id = inputs.id;
1722
+ this.element = inputs.element;
1723
+ this.disabled = inputs.disabled;
1724
+ this.toolbar = inputs.toolbar;
1725
+ }
1726
+ }
1727
+
1728
+ /** A group of widgets within a toolbar that provides nested navigation. */
1729
+ class ToolbarWidgetGroupPattern {
1730
+ inputs;
1731
+ /** A unique identifier for the widget. */
1732
+ id;
1733
+ /** The html element that should receive focus. */
1734
+ element;
1735
+ /** Whether the widget is disabled. */
1736
+ disabled;
1737
+ /** A reference to the parent toolbar. */
1738
+ toolbar;
1739
+ /** The text used by the typeahead search. */
1740
+ searchTerm = () => ''; // Unused because toolbar does not support typeahead.
1741
+ /** The value associated with the widget. */
1742
+ value = () => ''; // Unused because toolbar does not support selection.
1743
+ /** The position of the widget within the toolbar. */
1744
+ index = computed(() => this.toolbar()?.inputs.items().indexOf(this) ?? -1);
1745
+ /** The actions that can be performed on the widget group. */
1746
+ controls = computed(() => this.inputs.controls() ?? this._defaultControls);
1747
+ /** Default toolbar widget group controls when no controls provided. */
1748
+ _defaultControls = {
1749
+ isOnFirstItem: () => true,
1750
+ isOnLastItem: () => true,
1751
+ next: () => { },
1752
+ prev: () => { },
1753
+ first: () => { },
1754
+ last: () => { },
1755
+ unfocus: () => { },
1756
+ trigger: () => { },
1757
+ goto: () => { },
1758
+ setDefaultState: () => { },
1759
+ };
1760
+ constructor(inputs) {
1761
+ this.inputs = inputs;
1762
+ this.id = inputs.id;
1763
+ this.element = inputs.element;
1764
+ this.disabled = inputs.disabled;
1765
+ this.toolbar = inputs.toolbar;
1766
+ }
1767
+ }
1768
+
1769
+ /** Controls the state of a toolbar. */
1770
+ class ToolbarPattern {
1771
+ inputs;
1772
+ /** The list behavior for the toolbar. */
1773
+ listBehavior;
1774
+ /** Whether the tablist is vertically or horizontally oriented. */
1775
+ orientation;
1776
+ /** Whether disabled items in the group should be skipped when navigating. */
1777
+ skipDisabled;
1778
+ /** Whether the toolbar is disabled. */
1779
+ disabled = computed(() => this.listBehavior.disabled());
1780
+ /** The tabindex of the toolbar (if using activedescendant). */
1781
+ tabindex = computed(() => this.listBehavior.tabindex());
1782
+ /** The id of the current active widget (if using activedescendant). */
1783
+ activedescendant = computed(() => this.listBehavior.activedescendant());
1784
+ /** The key used to navigate to the previous widget. */
1785
+ _prevKey = computed(() => {
1786
+ if (this.inputs.orientation() === 'vertical') {
1787
+ return 'ArrowUp';
1788
+ }
1789
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
1790
+ });
1791
+ /** The key used to navigate to the next widget. */
1792
+ _nextKey = computed(() => {
1793
+ if (this.inputs.orientation() === 'vertical') {
1794
+ return 'ArrowDown';
1795
+ }
1796
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
1797
+ });
1798
+ /** The alternate key used to navigate to the previous widget. */
1799
+ _altPrevKey = computed(() => {
1800
+ if (this.inputs.orientation() === 'vertical') {
1801
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
1802
+ }
1803
+ return 'ArrowUp';
1804
+ });
1805
+ /** The alternate key used to navigate to the next widget. */
1806
+ _altNextKey = computed(() => {
1807
+ if (this.inputs.orientation() === 'vertical') {
1808
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
1809
+ }
1810
+ return 'ArrowDown';
1811
+ });
1812
+ /** The keydown event manager for the toolbar. */
1813
+ _keydown = computed(() => {
1814
+ const manager = new KeyboardEventManager();
1815
+ return manager
1816
+ .on(this._nextKey, () => this._next())
1817
+ .on(this._prevKey, () => this._prev())
1818
+ .on(this._altNextKey, () => this._groupNext())
1819
+ .on(this._altPrevKey, () => this._groupPrev())
1820
+ .on(' ', () => this._trigger())
1821
+ .on('Enter', () => this._trigger())
1822
+ .on('Home', () => this._first())
1823
+ .on('End', () => this._last());
1824
+ });
1825
+ /** The pointerdown event manager for the toolbar. */
1826
+ _pointerdown = computed(() => new PointerEventManager().on(e => this._goto(e)));
1827
+ /** Navigates to the next widget in the toolbar. */
1828
+ _next() {
1829
+ const item = this.inputs.activeItem();
1830
+ if (item instanceof ToolbarWidgetGroupPattern) {
1831
+ if (!item.disabled() && !item.controls().isOnLastItem()) {
1832
+ item.controls().next(false);
1833
+ return;
1834
+ }
1835
+ item.controls().unfocus();
1836
+ }
1837
+ this.listBehavior.next();
1838
+ const newItem = this.inputs.activeItem();
1839
+ if (newItem instanceof ToolbarWidgetGroupPattern) {
1840
+ newItem.controls().first();
1841
+ }
1842
+ }
1843
+ /** Navigates to the previous widget in the toolbar. */
1844
+ _prev() {
1845
+ const item = this.inputs.activeItem();
1846
+ if (item instanceof ToolbarWidgetGroupPattern) {
1847
+ if (!item.disabled() && !item.controls().isOnFirstItem()) {
1848
+ item.controls().prev(false);
1849
+ return;
1850
+ }
1851
+ item.controls().unfocus();
1852
+ }
1853
+ this.listBehavior.prev();
1854
+ const newItem = this.inputs.activeItem();
1855
+ if (newItem instanceof ToolbarWidgetGroupPattern) {
1856
+ newItem.controls().last();
1857
+ }
1858
+ }
1859
+ _groupNext() {
1860
+ const item = this.inputs.activeItem();
1861
+ if (item instanceof ToolbarWidgetPattern)
1862
+ return;
1863
+ item?.controls().next(true);
1864
+ }
1865
+ _groupPrev() {
1866
+ const item = this.inputs.activeItem();
1867
+ if (item instanceof ToolbarWidgetPattern)
1868
+ return;
1869
+ item?.controls().prev(true);
1870
+ }
1871
+ /** Triggers the action of the currently active widget. */
1872
+ _trigger() {
1873
+ const item = this.inputs.activeItem();
1874
+ if (item instanceof ToolbarWidgetGroupPattern) {
1875
+ item.controls().trigger();
1876
+ }
1877
+ }
1878
+ /** Navigates to the first widget in the toolbar. */
1879
+ _first() {
1880
+ const item = this.inputs.activeItem();
1881
+ if (item instanceof ToolbarWidgetGroupPattern) {
1882
+ item.controls().unfocus();
1883
+ }
1884
+ this.listBehavior.first();
1885
+ const newItem = this.inputs.activeItem();
1886
+ if (newItem instanceof ToolbarWidgetGroupPattern) {
1887
+ newItem.controls().first();
1888
+ }
1889
+ }
1890
+ /** Navigates to the last widget in the toolbar. */
1891
+ _last() {
1892
+ const item = this.inputs.activeItem();
1893
+ if (item instanceof ToolbarWidgetGroupPattern) {
1894
+ item.controls().unfocus();
1895
+ }
1896
+ this.listBehavior.last();
1897
+ const newItem = this.inputs.activeItem();
1898
+ if (newItem instanceof ToolbarWidgetGroupPattern) {
1899
+ newItem.controls().last();
1900
+ }
1901
+ }
1902
+ /** Navigates to the widget targeted by a pointer event. */
1903
+ _goto(e) {
1904
+ const item = this.inputs.getItem(e.target);
1905
+ if (!item)
1906
+ return;
1907
+ this.listBehavior.goto(item);
1908
+ if (item instanceof ToolbarWidgetGroupPattern) {
1909
+ item.controls().goto(e);
1910
+ }
1911
+ }
1912
+ constructor(inputs) {
1913
+ this.inputs = inputs;
1914
+ this.orientation = inputs.orientation;
1915
+ this.skipDisabled = inputs.skipDisabled;
1916
+ this.listBehavior = new List({
1917
+ ...inputs,
1918
+ multi: () => false,
1919
+ focusMode: () => 'roving',
1920
+ selectionMode: () => 'explicit',
1921
+ value: signal([]),
1922
+ typeaheadDelay: () => 0, // Toolbar widgets do not support typeahead.
1923
+ });
1924
+ }
1925
+ /** Handles keydown events for the toolbar. */
1926
+ onKeydown(event) {
1927
+ if (this.disabled())
1928
+ return;
1929
+ this._keydown().handle(event);
1930
+ }
1931
+ /** Handles pointerdown events for the toolbar. */
1932
+ onPointerdown(event) {
1933
+ if (this.disabled())
1934
+ return;
1935
+ this._pointerdown().handle(event);
1936
+ }
1937
+ /**
1938
+ * Sets the toolbar to its default initial state.
1939
+ *
1940
+ * Sets the active index to the selected widget if one exists and is focusable.
1941
+ * Otherwise, sets the active index to the first focusable widget.
1942
+ */
1943
+ setDefaultState() {
1944
+ let firstItem = null;
1945
+ for (const item of this.inputs.items()) {
1946
+ if (this.listBehavior.isFocusable(item)) {
1947
+ if (!firstItem) {
1948
+ firstItem = item;
1949
+ }
1950
+ }
1951
+ }
1952
+ if (firstItem) {
1953
+ this.inputs.activeItem.set(firstItem);
1954
+ }
1955
+ if (firstItem instanceof ToolbarWidgetGroupPattern) {
1956
+ firstItem.controls().setDefaultState();
1957
+ }
1958
+ }
1959
+ /** Validates the state of the toolbar and returns a list of accessibility violations. */
1960
+ validate() {
1961
+ const violations = [];
1962
+ return violations;
1963
+ }
1964
+ }
1965
+
1966
+ const focusMode = () => 'roving';
1967
+ /** A pattern controls the nested Accordions. */
1968
+ class AccordionGroupPattern {
1969
+ inputs;
1970
+ /** Controls navigation for the group. */
1971
+ navigation;
1972
+ /** Controls focus for the group. */
1973
+ focusManager;
1974
+ /** Controls expansion for the group. */
1975
+ expansionManager;
1976
+ constructor(inputs) {
1977
+ this.inputs = inputs;
1978
+ this.wrap = inputs.wrap;
1979
+ this.orientation = inputs.orientation;
1980
+ this.textDirection = inputs.textDirection;
1981
+ this.activeItem = inputs.activeItem;
1982
+ this.disabled = inputs.disabled;
1983
+ this.multiExpandable = inputs.multiExpandable;
1984
+ this.items = inputs.items;
1985
+ this.expandedIds = inputs.expandedIds;
1986
+ this.skipDisabled = inputs.skipDisabled;
1987
+ this.focusManager = new ListFocus({
1988
+ ...inputs,
1989
+ focusMode,
1990
+ });
1991
+ this.navigation = new ListNavigation({
1992
+ ...inputs,
1993
+ focusMode,
1994
+ focusManager: this.focusManager,
1995
+ });
1996
+ this.expansionManager = new ListExpansion({
1997
+ ...inputs,
1998
+ });
1999
+ }
2000
+ }
2001
+ /** A pattern controls the expansion state of an accordion. */
2002
+ class AccordionTriggerPattern {
2003
+ inputs;
2004
+ /** Whether this tab has expandable content. */
2005
+ expandable;
2006
+ /** The unique identifier used by the expansion behavior. */
2007
+ expansionId;
2008
+ /** Whether an accordion is expanded. */
2009
+ expanded;
2010
+ /** Controls the expansion state for the trigger. */
2011
+ expansionControl;
2012
+ /** Whether the trigger is active. */
2013
+ active = computed(() => this.inputs.accordionGroup().activeItem() === this);
2014
+ /** Id of the accordion panel controlled by the trigger. */
2015
+ controls = computed(() => this.inputs.accordionPanel()?.id());
2016
+ /** The tabindex of the trigger. */
2017
+ tabindex = computed(() => (this.inputs.accordionGroup().focusManager.isFocusable(this) ? 0 : -1));
2018
+ /** Whether the trigger is disabled. Disabling an accordion group disables all the triggers. */
2019
+ disabled = computed(() => this.inputs.disabled() || this.inputs.accordionGroup().disabled());
2020
+ /** The index of the trigger within its accordion group. */
2021
+ index = computed(() => this.inputs.accordionGroup().items().indexOf(this));
2022
+ constructor(inputs) {
2023
+ this.inputs = inputs;
2024
+ this.id = inputs.id;
2025
+ this.element = inputs.element;
2026
+ this.value = inputs.value;
2027
+ this.accordionGroup = inputs.accordionGroup;
2028
+ this.accordionPanel = inputs.accordionPanel;
2029
+ this.expansionControl = new ExpansionControl({
2030
+ ...inputs,
2031
+ expansionId: inputs.value,
2032
+ expandable: () => true,
2033
+ expansionManager: inputs.accordionGroup().expansionManager,
2034
+ });
2035
+ this.expandable = this.expansionControl.isExpandable;
2036
+ this.expansionId = this.expansionControl.expansionId;
2037
+ this.expanded = this.expansionControl.isExpanded;
2038
+ }
2039
+ /** The key used to navigate to the previous accordion trigger. */
2040
+ prevKey = computed(() => {
2041
+ if (this.inputs.accordionGroup().orientation() === 'vertical') {
2042
+ return 'ArrowUp';
2043
+ }
2044
+ return this.inputs.accordionGroup().textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
2045
+ });
2046
+ /** The key used to navigate to the next accordion trigger. */
2047
+ nextKey = computed(() => {
2048
+ if (this.inputs.accordionGroup().orientation() === 'vertical') {
2049
+ return 'ArrowDown';
2050
+ }
2051
+ return this.inputs.accordionGroup().textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
2052
+ });
2053
+ /** The keydown event manager for the accordion trigger. */
2054
+ keydown = computed(() => {
2055
+ return new KeyboardEventManager()
2056
+ .on(this.prevKey, () => this.accordionGroup().navigation.prev())
2057
+ .on(this.nextKey, () => this.accordionGroup().navigation.next())
2058
+ .on('Home', () => this.accordionGroup().navigation.first())
2059
+ .on('End', () => this.accordionGroup().navigation.last())
2060
+ .on(' ', () => this.expansionControl.toggle())
2061
+ .on('Enter', () => this.expansionControl.toggle());
2062
+ });
2063
+ /** The pointerdown event manager for the accordion trigger. */
2064
+ pointerdown = computed(() => {
2065
+ return new PointerEventManager().on(e => {
2066
+ const item = this._getItem(e);
2067
+ if (item) {
2068
+ this.accordionGroup().navigation.goto(item);
2069
+ this.expansionControl.toggle();
2070
+ }
2071
+ });
2072
+ });
2073
+ /** Handles keydown events on the trigger, delegating to the group if not disabled. */
2074
+ onKeydown(event) {
2075
+ this.keydown().handle(event);
2076
+ }
2077
+ /** Handles pointerdown events on the trigger, delegating to the group if not disabled. */
2078
+ onPointerdown(event) {
2079
+ this.pointerdown().handle(event);
2080
+ }
2081
+ /** Handles focus events on the trigger. This ensures the tabbing changes the active index. */
2082
+ onFocus(event) {
2083
+ const item = this._getItem(event);
2084
+ if (item && this.inputs.accordionGroup().focusManager.isFocusable(item)) {
2085
+ this.accordionGroup().focusManager.focus(item);
2086
+ }
2087
+ }
2088
+ _getItem(e) {
2089
+ if (!(e.target instanceof HTMLElement)) {
2090
+ return;
2091
+ }
2092
+ const element = e.target.closest('[role="button"]');
2093
+ return this.accordionGroup()
2094
+ .items()
2095
+ .find(i => i.element() === element);
2096
+ }
2097
+ }
2098
+ /** Represents an accordion panel. */
2099
+ class AccordionPanelPattern {
2100
+ inputs;
2101
+ /** Whether the accordion panel is hidden. True if the associated trigger is not expanded. */
2102
+ hidden;
2103
+ constructor(inputs) {
2104
+ this.inputs = inputs;
2105
+ this.id = inputs.id;
2106
+ this.value = inputs.value;
2107
+ this.accordionTrigger = inputs.accordionTrigger;
2108
+ this.hidden = computed(() => inputs.accordionTrigger()?.expanded() === false);
2109
+ }
2110
+ }
2111
+
2112
+ /**
2113
+ * Represents an item in a Tree.
2114
+ */
2115
+ class TreeItemPattern {
2116
+ inputs;
2117
+ /** The position of this item among its siblings. */
2118
+ index = computed(() => this.tree().visibleItems().indexOf(this));
2119
+ /** The unique identifier used by the expansion behavior. */
2120
+ expansionId;
2121
+ /** Controls expansion for child items. */
2122
+ expansionManager;
2123
+ /** Controls expansion for this item. */
2124
+ expansion;
2125
+ /** Whether the item is expandable. It's expandable if children item exist. */
2126
+ expandable;
2127
+ /** The level of the current item in a tree. */
2128
+ level = computed(() => this.parent().level() + 1);
2129
+ /** Whether this item is currently expanded. */
2130
+ expanded = computed(() => this.expansion.isExpanded());
2131
+ /** Whether this item is visible. */
2132
+ visible = computed(() => this.parent().expanded());
2133
+ /** The number of items under the same parent at the same level. */
2134
+ setsize = computed(() => this.parent().children().length);
2135
+ /** The position of this item among its siblings (1-based). */
2136
+ posinset = computed(() => this.parent().children().indexOf(this) + 1);
2137
+ /** Whether the item is active. */
2138
+ active = computed(() => this.tree().activeItem() === this);
2139
+ /** The tabindex of the item. */
2140
+ tabindex = computed(() => this.tree().listBehavior.getItemTabindex(this));
2141
+ /** Whether the item is selected. */
2142
+ selected = computed(() => {
2143
+ if (this.tree().nav()) {
2144
+ return undefined;
2145
+ }
2146
+ return this.tree().value().includes(this.value());
2147
+ });
2148
+ /** The current type of this item. */
2149
+ current = computed(() => {
2150
+ if (!this.tree().nav()) {
2151
+ return undefined;
2152
+ }
2153
+ return this.tree().value().includes(this.value()) ? this.tree().currentType() : undefined;
2154
+ });
2155
+ constructor(inputs) {
2156
+ this.inputs = inputs;
2157
+ this.id = inputs.id;
2158
+ this.value = inputs.value;
2159
+ this.element = inputs.element;
2160
+ this.disabled = inputs.disabled;
2161
+ this.searchTerm = inputs.searchTerm;
2162
+ this.expansionId = inputs.id;
2163
+ this.tree = inputs.tree;
2164
+ this.parent = inputs.parent;
2165
+ this.children = inputs.children;
2166
+ this.expandable = inputs.hasChildren;
2167
+ this.expansion = new ExpansionControl({
2168
+ ...inputs,
2169
+ expandable: this.expandable,
2170
+ expansionId: this.expansionId,
2171
+ expansionManager: this.parent().expansionManager,
2172
+ });
2173
+ this.expansionManager = new ListExpansion({
2174
+ ...inputs,
2175
+ multiExpandable: () => true,
2176
+ // TODO(ok7sai): allow pre-expanded tree items.
2177
+ expandedIds: signal([]),
2178
+ items: this.children,
2179
+ disabled: computed(() => this.tree()?.disabled() ?? false),
2180
+ });
2181
+ }
2182
+ }
2183
+ /** Controls the state and interactions of a tree view. */
2184
+ class TreePattern {
2185
+ inputs;
2186
+ /** The list behavior for the tree. */
2187
+ listBehavior;
2188
+ /** Controls expansion for direct children of the tree root (top-level items). */
2189
+ expansionManager;
2190
+ /** The root level is 0. */
2191
+ level = () => 0;
2192
+ /** The root is always expanded. */
2193
+ expanded = () => true;
2194
+ /** The tabindex of the tree. */
2195
+ tabindex = computed(() => this.listBehavior.tabindex());
2196
+ /** The id of the current active item. */
2197
+ activedescendant = computed(() => this.listBehavior.activedescendant());
2198
+ /** The direct children of the root (top-level tree items). */
2199
+ children = computed(() => this.inputs.allItems().filter(item => item.level() === this.level() + 1));
2200
+ /** All currently visible tree items. An item is visible if their parent is expanded. */
2201
+ visibleItems = computed(() => this.inputs.allItems().filter(item => item.visible()));
2202
+ /** Whether the tree selection follows focus. */
2203
+ followFocus = computed(() => this.inputs.selectionMode() === 'follow');
2204
+ /** The key for navigating to the previous item. */
2205
+ prevKey = computed(() => {
2206
+ if (this.inputs.orientation() === 'vertical') {
2207
+ return 'ArrowUp';
2208
+ }
2209
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
2210
+ });
2211
+ /** The key for navigating to the next item. */
2212
+ nextKey = computed(() => {
2213
+ if (this.inputs.orientation() === 'vertical') {
2214
+ return 'ArrowDown';
2215
+ }
2216
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
2217
+ });
2218
+ /** The key for collapsing an item or moving to its parent. */
2219
+ collapseKey = computed(() => {
2220
+ if (this.inputs.orientation() === 'horizontal') {
2221
+ return 'ArrowUp';
2222
+ }
2223
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
2224
+ });
2225
+ /** The key for expanding an item or moving to its first child. */
2226
+ expandKey = computed(() => {
2227
+ if (this.inputs.orientation() === 'horizontal') {
2228
+ return 'ArrowDown';
2229
+ }
2230
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
2231
+ });
2232
+ /** Represents the space key. Does nothing when the user is actively using typeahead. */
2233
+ dynamicSpaceKey = computed(() => (this.listBehavior.isTyping() ? '' : ' '));
2234
+ /** Regular expression to match characters for typeahead. */
2235
+ typeaheadRegexp = /^.$/;
2236
+ /** The keydown event manager for the tree. */
2237
+ keydown = computed(() => {
2238
+ const manager = new KeyboardEventManager();
2239
+ const list = this.listBehavior;
2240
+ manager
2241
+ .on(this.prevKey, () => list.prev({ selectOne: this.followFocus() }))
2242
+ .on(this.nextKey, () => list.next({ selectOne: this.followFocus() }))
2243
+ .on('Home', () => list.first({ selectOne: this.followFocus() }))
2244
+ .on('End', () => list.last({ selectOne: this.followFocus() }))
2245
+ .on(this.typeaheadRegexp, e => list.search(e.key, { selectOne: this.followFocus() }))
2246
+ .on(this.expandKey, () => this.expand({ selectOne: this.followFocus() }))
2247
+ .on(this.collapseKey, () => this.collapse({ selectOne: this.followFocus() }))
2248
+ .on(Modifier.Shift, '*', () => this.expandSiblings());
2249
+ if (this.inputs.multi()) {
2250
+ manager
2251
+ // TODO: Tracking the anchor by index can break if the
2252
+ // tree is expanded or collapsed causing the index to change.
2253
+ .on(Modifier.Any, 'Shift', () => list.anchor(this.listBehavior.activeIndex()))
2254
+ .on(Modifier.Shift, this.prevKey, () => list.prev({ selectRange: true }))
2255
+ .on(Modifier.Shift, this.nextKey, () => list.next({ selectRange: true }))
2256
+ .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => list.first({ selectRange: true, anchor: false }))
2257
+ .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'End', () => list.last({ selectRange: true, anchor: false }))
2258
+ .on(Modifier.Shift, 'Enter', () => list.updateSelection({ selectRange: true, anchor: false }))
2259
+ .on(Modifier.Shift, this.dynamicSpaceKey, () => list.updateSelection({ selectRange: true, anchor: false }));
2260
+ }
2261
+ if (!this.followFocus() && this.inputs.multi()) {
2262
+ manager
2263
+ .on(this.dynamicSpaceKey, () => list.toggle())
2264
+ .on('Enter', () => list.toggle())
2265
+ .on([Modifier.Ctrl, Modifier.Meta], 'A', () => list.toggleAll());
2266
+ }
2267
+ if (!this.followFocus() && !this.inputs.multi()) {
2268
+ manager.on(this.dynamicSpaceKey, () => list.selectOne());
2269
+ manager.on('Enter', () => list.selectOne());
2270
+ }
2271
+ if (this.inputs.multi() && this.followFocus()) {
2272
+ manager
2273
+ .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => list.prev())
2274
+ .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => list.next())
2275
+ .on([Modifier.Ctrl, Modifier.Meta], this.expandKey, () => this.expand())
2276
+ .on([Modifier.Ctrl, Modifier.Meta], this.collapseKey, () => this.collapse())
2277
+ .on([Modifier.Ctrl, Modifier.Meta], ' ', () => list.toggle())
2278
+ .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => list.toggle())
2279
+ .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => list.first())
2280
+ .on([Modifier.Ctrl, Modifier.Meta], 'End', () => list.last())
2281
+ .on([Modifier.Ctrl, Modifier.Meta], 'A', () => {
2282
+ list.toggleAll();
2283
+ list.select(); // Ensure the currect item remains selected.
2284
+ });
2285
+ }
2286
+ return manager;
2287
+ });
2288
+ /** The pointerdown event manager for the tree. */
2289
+ pointerdown = computed(() => {
2290
+ const manager = new PointerEventManager();
2291
+ if (this.multi()) {
2292
+ manager.on(Modifier.Shift, e => this.goto(e, { selectRange: true }));
2293
+ }
2294
+ if (!this.multi()) {
2295
+ return manager.on(e => this.goto(e, { selectOne: true }));
2296
+ }
2297
+ if (this.multi() && this.followFocus()) {
2298
+ return manager
2299
+ .on(e => this.goto(e, { selectOne: true }))
2300
+ .on(Modifier.Ctrl, e => this.goto(e, { toggle: true }));
2301
+ }
2302
+ if (this.multi() && !this.followFocus()) {
2303
+ return manager.on(e => this.goto(e, { toggle: true }));
2304
+ }
2305
+ return manager;
2306
+ });
2307
+ constructor(inputs) {
2308
+ this.inputs = inputs;
2309
+ this.id = inputs.id;
2310
+ this.nav = inputs.nav;
2311
+ this.currentType = inputs.currentType;
2312
+ this.allItems = inputs.allItems;
2313
+ this.focusMode = inputs.focusMode;
2314
+ this.disabled = inputs.disabled;
2315
+ this.activeItem = inputs.activeItem;
2316
+ this.skipDisabled = inputs.skipDisabled;
2317
+ this.wrap = inputs.wrap;
2318
+ this.orientation = inputs.orientation;
2319
+ this.textDirection = inputs.textDirection;
2320
+ this.multi = computed(() => (this.nav() ? false : this.inputs.multi()));
2321
+ this.selectionMode = inputs.selectionMode;
2322
+ this.typeaheadDelay = inputs.typeaheadDelay;
2323
+ this.value = inputs.value;
2324
+ this.listBehavior = new List({
2325
+ ...inputs,
2326
+ items: this.visibleItems,
2327
+ multi: this.multi,
2328
+ });
2329
+ this.expansionManager = new ListExpansion({
2330
+ multiExpandable: () => true,
2331
+ // TODO(ok7sai): allow pre-expanded tree items.
2332
+ expandedIds: signal([]),
2333
+ items: this.children,
2334
+ disabled: this.disabled,
2335
+ });
2336
+ }
2337
+ /**
2338
+ * Sets the tree to it's default initial state.
2339
+ *
2340
+ * Sets the active index of the tree to the first focusable selected tree item if one exists.
2341
+ * Otherwise, sets focus to the first focusable tree item.
2342
+ */
2343
+ setDefaultState() {
2344
+ let firstItem;
2345
+ for (const item of this.allItems()) {
2346
+ if (!item.visible())
2347
+ continue;
2348
+ if (!this.listBehavior.isFocusable(item))
2349
+ continue;
2350
+ if (firstItem === undefined) {
2351
+ firstItem = item;
2352
+ }
2353
+ if (item.selected()) {
2354
+ this.activeItem.set(item);
2355
+ return;
2356
+ }
2357
+ }
2358
+ if (firstItem !== undefined) {
2359
+ this.activeItem.set(firstItem);
2360
+ }
2361
+ }
2362
+ /** Handles keydown events on the tree. */
2363
+ onKeydown(event) {
2364
+ if (!this.disabled()) {
2365
+ this.keydown().handle(event);
2366
+ }
2367
+ }
2368
+ /** Handles pointerdown events on the tree. */
2369
+ onPointerdown(event) {
2370
+ if (!this.disabled()) {
2371
+ this.pointerdown().handle(event);
2372
+ }
2373
+ }
2374
+ /** Navigates to the given tree item in the tree. */
2375
+ goto(e, opts) {
2376
+ const item = this._getItem(e);
2377
+ if (!item)
2378
+ return;
2379
+ this.listBehavior.goto(item, opts);
2380
+ this.toggleExpansion(item);
2381
+ }
2382
+ /** Toggles to expand or collapse a tree item. */
2383
+ toggleExpansion(item) {
2384
+ item ??= this.activeItem();
2385
+ if (!item || !this.listBehavior.isFocusable(item))
2386
+ return;
2387
+ if (!item.expandable())
2388
+ return;
2389
+ if (item.expanded()) {
2390
+ this.collapse();
2391
+ }
2392
+ else {
2393
+ item.expansion.open();
2394
+ }
2395
+ }
2396
+ /** Expands a tree item. */
2397
+ expand(opts) {
2398
+ const item = this.activeItem();
2399
+ if (!item || !this.listBehavior.isFocusable(item))
2400
+ return;
2401
+ if (item.expandable() && !item.expanded()) {
2402
+ item.expansion.open();
2403
+ }
2404
+ else if (item.expanded() &&
2405
+ item.children().some(item => this.listBehavior.isFocusable(item))) {
2406
+ this.listBehavior.next(opts);
2407
+ }
2408
+ }
2409
+ /** Expands all sibling tree items including itself. */
2410
+ expandSiblings(item) {
2411
+ item ??= this.activeItem();
2412
+ const siblings = item?.parent()?.children();
2413
+ siblings?.forEach(item => item.expansion.open());
2414
+ }
2415
+ /** Collapses a tree item. */
2416
+ collapse(opts) {
2417
+ const item = this.activeItem();
2418
+ if (!item || !this.listBehavior.isFocusable(item))
2419
+ return;
2420
+ if (item.expandable() && item.expanded()) {
2421
+ item.expansion.close();
2422
+ }
2423
+ else if (item.parent() && item.parent() !== this) {
2424
+ const parentItem = item.parent();
2425
+ if (parentItem instanceof TreeItemPattern && this.listBehavior.isFocusable(parentItem)) {
2426
+ this.listBehavior.goto(parentItem, opts);
2427
+ }
2428
+ }
2429
+ }
2430
+ /** Retrieves the TreeItemPattern associated with a DOM event, if any. */
2431
+ _getItem(event) {
2432
+ if (!(event.target instanceof HTMLElement)) {
2433
+ return;
2434
+ }
2435
+ const element = event.target.closest('[role="treeitem"]');
2436
+ return this.inputs.allItems().find(i => i.element() === element);
2437
+ }
2438
+ }
2439
+
2440
+ class ComboboxTreePattern extends TreePattern {
2441
+ inputs;
2442
+ /** Whether the currently focused item is collapsible. */
2443
+ isItemCollapsible = () => this.activeItem()?.parent() instanceof TreeItemPattern;
2444
+ /** The ARIA role for the tree. */
2445
+ role = () => 'tree';
2446
+ /* The id of the active (focused) item in the tree. */
2447
+ activeId = computed(() => this.listBehavior.activedescendant());
2448
+ /** The list of items in the tree. */
2449
+ items = computed(() => this.inputs.allItems());
2450
+ /** The tabindex for the tree. Always -1 because the combobox handles focus. */
2451
+ tabindex = () => -1;
2452
+ constructor(inputs) {
2453
+ if (inputs.combobox()) {
2454
+ inputs.multi = () => false;
2455
+ inputs.focusMode = () => 'activedescendant';
2456
+ inputs.element = inputs.combobox().inputs.inputEl;
2457
+ }
2458
+ super(inputs);
2459
+ this.inputs = inputs;
2460
+ }
2461
+ /** Noop. The combobox handles keydown events. */
2462
+ onKeydown(_) { }
2463
+ /** Noop. The combobox handles pointerdown events. */
2464
+ onPointerdown(_) { }
2465
+ /** Noop. The combobox controls the open state. */
2466
+ setDefaultState() { }
2467
+ /** Navigates to the specified item in the tree. */
2468
+ focus = (item) => this.listBehavior.goto(item);
2469
+ /** Navigates to the next focusable item in the tree. */
2470
+ next = () => this.listBehavior.next();
2471
+ /** Navigates to the previous focusable item in the tree. */
2472
+ prev = () => this.listBehavior.prev();
2473
+ /** Navigates to the last focusable item in the tree. */
2474
+ last = () => this.listBehavior.last();
2475
+ /** Navigates to the first focusable item in the tree. */
2476
+ first = () => this.listBehavior.first();
2477
+ /** Unfocuses the currently focused item in the tree. */
2478
+ unfocus = () => this.listBehavior.unfocus();
2479
+ /** Selects the specified item in the tree or the current active item if not provided. */
2480
+ select = (item) => this.listBehavior.select(item);
2481
+ /** Clears the selection in the tree. */
2482
+ clearSelection = () => this.listBehavior.deselectAll();
2483
+ /** Retrieves the TreeItemPattern associated with a pointer event. */
2484
+ getItem = (e) => this._getItem(e);
2485
+ /** Retrieves the currently selected item in the tree */
2486
+ getSelectedItem = () => this.inputs.allItems().find(i => i.selected());
2487
+ /** Sets the value of the combobox tree. */
2488
+ setValue = (value) => this.inputs.value.set(value ? [value] : []);
2489
+ /** Expands the currently focused item if it is expandable. */
2490
+ expandItem = () => this.expand();
2491
+ /** Collapses the currently focused item if it is expandable. */
2492
+ collapseItem = () => this.collapse();
2493
+ /** Whether the specified item or the currently active item is expandable. */
2494
+ isItemExpandable(item = this.activeItem()) {
2495
+ return item ? item.expandable() : false;
2496
+ }
2497
+ /** Expands all of the tree items. */
2498
+ expandAll = () => this.items().forEach(item => item.expansion.open());
2499
+ /** Collapses all of the tree items. */
2500
+ collapseAll = () => this.items().forEach(item => item.expansion.close());
2501
+ }
2502
+
2503
+ export { AccordionGroupPattern, AccordionPanelPattern, AccordionTriggerPattern, ComboboxListboxPattern, ComboboxPattern, ComboboxTreePattern, ListboxPattern, OptionPattern, RadioButtonPattern, RadioGroupPattern, TabListPattern, TabPanelPattern, TabPattern, ToolbarPattern, ToolbarRadioGroupPattern, ToolbarWidgetGroupPattern, ToolbarWidgetPattern, TreeItemPattern, TreePattern, convertGetterSetterToWritableSignalLike };
2504
+ //# sourceMappingURL=ui-patterns.mjs.map