Skip to content

Commit ccd0e07

Browse files
authored
refactor(aria/toolbar): large rework of toolbar internals (#32236)
* refactor(multiple): make ListItem.element() optional * refactor(aria/toolbar): large rework of toolbar internals * refactor(aria/toolbar): dev-app demo changes
1 parent 68477cd commit ccd0e07

34 files changed

+2638
-1371
lines changed

src/aria/listbox/listbox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export class Listbox<V> {
185185
}
186186

187187
scrollActiveItemIntoView(options: ScrollIntoViewOptions = {block: 'nearest'}) {
188-
this._pattern.inputs.activeItem()?.element().scrollIntoView(options);
188+
this._pattern.inputs.activeItem()?.element()?.scrollIntoView(options);
189189
}
190190
}
191191

src/aria/private/behaviors/list-focus/list-focus.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface ListFocusItem {
1515
id: SignalLike<string>;
1616

1717
/** The html element that should receive focus. */
18-
element: SignalLike<HTMLElement>;
18+
element: SignalLike<HTMLElement | undefined>;
1919

2020
/** Whether an item is disabled. */
2121
disabled: SignalLike<boolean>;
@@ -112,7 +112,7 @@ export class ListFocus<T extends ListFocusItem> {
112112

113113
if (opts?.focusElement || opts?.focusElement === undefined) {
114114
this.inputs.focusMode() === 'roving'
115-
? item.element().focus()
115+
? item.element()?.focus()
116116
: this.inputs.element()?.focus();
117117
}
118118

src/aria/private/behaviors/list-navigation/list-navigation.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,19 +55,29 @@ export class ListNavigation<T extends ListNavigationItem> {
5555

5656
/** Navigates to the first item in the list. */
5757
first(opts?: {focusElement?: boolean}): boolean {
58-
const item = this.inputs.items().find(i => this.inputs.focusManager.isFocusable(i));
58+
const item = this.peekFirst();
5959
return item ? this.goto(item, opts) : false;
6060
}
6161

6262
/** Navigates to the last item in the list. */
6363
last(opts?: {focusElement?: boolean}): boolean {
64-
const items = this.inputs.items();
64+
const item = this.peekLast();
65+
return item ? this.goto(item, opts) : false;
66+
}
67+
68+
/** Gets the first focusable item from the given list of items. */
69+
peekFirst(items: T[] = this.inputs.items()): T | undefined {
70+
return items.find(i => this.inputs.focusManager.isFocusable(i));
71+
}
72+
73+
/** Gets the last focusable item from the given list of items. */
74+
peekLast(items: T[] = this.inputs.items()): T | undefined {
6575
for (let i = items.length - 1; i >= 0; i--) {
6676
if (this.inputs.focusManager.isFocusable(items[i])) {
67-
return this.goto(items[i], opts);
77+
return items[i];
6878
}
6979
}
70-
return false;
80+
return;
7181
}
7282

7383
/** Advances to the next or previous focusable item in the list based on the given delta. */

src/aria/private/behaviors/list/list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,8 @@ export class List<T extends ListItem<V>, V> {
161161
}
162162

163163
/** Deselects the currently active item in the list. */
164-
deselect() {
165-
this.selectionBehavior.deselect();
164+
deselect(item?: T) {
165+
this.selectionBehavior.deselect(item);
166166
}
167167

168168
/** Deselects all items in the list. */

src/aria/private/listbox/option.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export class OptionPattern<V> {
5757
tabIndex = computed(() => this.listbox()?.listBehavior.getItemTabindex(this));
5858

5959
/** The html element that should receive focus. */
60-
element: SignalLike<HTMLElement>;
60+
element: SignalLike<HTMLElement | undefined>;
6161

6262
constructor(args: OptionInputs<V>) {
6363
this.id = args.id;

src/aria/private/menu/menu.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export class MenuPattern<V> {
248248
if (parent instanceof MenuItemPattern) {
249249
const grandparent = parent.inputs.parent();
250250
const siblings = grandparent?.inputs.items().filter(i => i !== parent);
251-
const item = siblings?.find(i => i.element().contains(relatedTarget));
251+
const item = siblings?.find(i => i.element()?.contains(relatedTarget));
252252

253253
if (item) {
254254
return;
@@ -602,7 +602,7 @@ export class MenuItemPattern<V> implements ListItem<V> {
602602
searchTerm: SignalLike<string>;
603603

604604
/** The element of the menu item. */
605-
element: SignalLike<HTMLElement>;
605+
element: SignalLike<HTMLElement | undefined>;
606606

607607
/** Whether the menu item is active. */
608608
isActive = computed(() => this.inputs.parent()?.inputs.activeItem() === this);

src/aria/private/tabs/tabs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export class TabPattern {
4747
readonly disabled: SignalLike<boolean>;
4848

4949
/** The html element that should receive focus. */
50-
readonly element: SignalLike<HTMLElement>;
50+
readonly element: SignalLike<HTMLElement | undefined>;
5151

5252
/** Whether the tab is selectable. */
5353
readonly selectable = () => true;

src/aria/private/toolbar/toolbar-widget-group.ts

Lines changed: 18 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -6,103 +6,40 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed} from '@angular/core';
10-
import {SignalLike} from '../behaviors/signal-like/signal-like';
119
import {ListItem} from '../behaviors/list/list';
10+
import {SignalLike} from '../behaviors/signal-like/signal-like';
1211
import type {ToolbarPattern} from './toolbar';
1312

14-
/** An interface that allows sub patterns to expose the necessary controls for the toolbar. */
15-
export interface ToolbarWidgetGroupControls {
16-
/** Whether the widget group is currently on the first item. */
17-
isOnFirstItem(): boolean;
18-
19-
/** Whether the widget group is currently on the last item. */
20-
isOnLastItem(): boolean;
21-
22-
/** Navigates to the next widget in the group. */
23-
next(wrap: boolean): void;
24-
25-
/** Navigates to the previous widget in the group. */
26-
prev(wrap: boolean): void;
27-
28-
/** Navigates to the first widget in the group. */
29-
first(): void;
30-
31-
/** Navigates to the last widget in the group. */
32-
last(): void;
33-
34-
/** Removes focus from the widget group. */
35-
unfocus(): void;
36-
37-
/** Triggers the action of the currently active widget in the group. */
38-
trigger(): void;
39-
40-
/** Navigates to the widget targeted by a pointer event. */
41-
goto(event: PointerEvent): void;
42-
43-
/** Sets the widget group to its default initial state. */
44-
setDefaultState(): void;
45-
}
46-
4713
/** Represents the required inputs for a toolbar widget group. */
48-
export interface ToolbarWidgetGroupInputs<V>
49-
extends Omit<ListItem<V>, 'searchTerm' | 'value' | 'index' | 'selectable'> {
14+
export interface ToolbarWidgetGroupInputs<T extends ListItem<V>, V> {
5015
/** A reference to the parent toolbar. */
5116
toolbar: SignalLike<ToolbarPattern<V> | undefined>;
5217

53-
/** The controls for the sub patterns associated with the toolbar. */
54-
controls: SignalLike<ToolbarWidgetGroupControls | undefined>;
55-
}
18+
/** Whether the widget group is disabled. */
19+
disabled: SignalLike<boolean>;
5620

57-
/** A group of widgets within a toolbar that provides nested navigation. */
58-
export class ToolbarWidgetGroupPattern<V> implements ListItem<V> {
59-
/** A unique identifier for the widget. */
60-
readonly id: SignalLike<string>;
21+
/** The list of items within the widget group. */
22+
items: SignalLike<T[]>;
6123

62-
/** The html element that should receive focus. */
63-
readonly element: SignalLike<HTMLElement>;
24+
/** Whether the group allows multiple widgets to be selected. */
25+
multi: SignalLike<boolean>;
26+
}
6427

28+
/** A group of widgets within a toolbar that provides nested navigation. */
29+
export class ToolbarWidgetGroupPattern<T extends ListItem<V>, V> {
6530
/** Whether the widget is disabled. */
66-
readonly disabled: SignalLike<boolean>;
31+
readonly disabled = () => this.inputs.disabled();
6732

6833
/** A reference to the parent toolbar. */
69-
readonly toolbar: SignalLike<ToolbarPattern<V> | undefined>;
34+
readonly toolbar = () => this.inputs.toolbar();
7035

71-
/** The text used by the typeahead search. */
72-
readonly searchTerm = () => ''; // Unused because toolbar does not support typeahead.
36+
/** Whether the group allows multiple widgets to be selected. */
37+
readonly multi = () => this.inputs.multi();
7338

74-
/** The value associated with the widget. */
39+
readonly searchTerm = () => ''; // Unused because toolbar does not support typeahead.
7540
readonly value = () => '' as V; // Unused because toolbar does not support selection.
76-
77-
/** Whether the widget is selectable. */
7841
readonly selectable = () => true; // Unused because toolbar does not support selection.
42+
readonly element = () => undefined; // Unused because toolbar does not focus the group element.
7943

80-
/** The position of the widget within the toolbar. */
81-
readonly index = computed(() => this.toolbar()?.inputs.items().indexOf(this) ?? -1);
82-
83-
/** The actions that can be performed on the widget group. */
84-
readonly controls: SignalLike<ToolbarWidgetGroupControls> = computed(
85-
() => this.inputs.controls() ?? this._defaultControls,
86-
);
87-
88-
/** Default toolbar widget group controls when no controls provided. */
89-
private readonly _defaultControls: ToolbarWidgetGroupControls = {
90-
isOnFirstItem: () => true,
91-
isOnLastItem: () => true,
92-
next: () => {},
93-
prev: () => {},
94-
first: () => {},
95-
last: () => {},
96-
unfocus: () => {},
97-
trigger: () => {},
98-
goto: () => {},
99-
setDefaultState: () => {},
100-
};
101-
102-
constructor(readonly inputs: ToolbarWidgetGroupInputs<V>) {
103-
this.id = inputs.id;
104-
this.element = inputs.element;
105-
this.disabled = inputs.disabled;
106-
this.toolbar = inputs.toolbar;
107-
}
44+
constructor(readonly inputs: ToolbarWidgetGroupInputs<T, V>) {}
10845
}

src/aria/private/toolbar/toolbar-widget.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,49 +10,56 @@ import {computed} from '@angular/core';
1010
import {SignalLike} from '../behaviors/signal-like/signal-like';
1111
import {ListItem} from '../behaviors/list/list';
1212
import type {ToolbarPattern} from './toolbar';
13+
import {ToolbarWidgetGroupPattern} from './toolbar-widget-group';
1314

1415
/** Represents the required inputs for a toolbar widget in a toolbar. */
1516
export interface ToolbarWidgetInputs<V>
16-
extends Omit<ListItem<V>, 'searchTerm' | 'value' | 'index' | 'selectable'> {
17+
extends Omit<ListItem<V>, 'searchTerm' | 'index' | 'selectable'> {
1718
/** A reference to the parent toolbar. */
1819
toolbar: SignalLike<ToolbarPattern<V>>;
20+
21+
/** A reference to the parent widget group. */
22+
group: SignalLike<ToolbarWidgetGroupPattern<ToolbarWidgetPattern<V>, V> | undefined>;
1923
}
2024

2125
export class ToolbarWidgetPattern<V> implements ListItem<V> {
2226
/** A unique identifier for the widget. */
23-
readonly id: SignalLike<string>;
27+
readonly id = () => this.inputs.id();
2428

2529
/** The html element that should receive focus. */
26-
readonly element: SignalLike<HTMLElement>;
30+
readonly element = () => this.inputs.element();
2731

2832
/** Whether the widget is disabled. */
29-
readonly disabled: SignalLike<boolean>;
33+
readonly disabled = () => this.inputs.disabled() || this.group()?.disabled() || false;
3034

3135
/** A reference to the parent toolbar. */
32-
readonly toolbar: SignalLike<ToolbarPattern<V>>;
36+
readonly group = () => this.inputs.group();
37+
38+
/** A reference to the toolbar containing the widget. */
39+
readonly toolbar = () => this.inputs.toolbar();
3340

34-
/** The tab index of the widgdet. */
41+
/** The tabindex of the widget. */
3542
readonly tabIndex = computed(() => this.toolbar().listBehavior.getItemTabindex(this));
3643

3744
/** The text used by the typeahead search. */
3845
readonly searchTerm = () => ''; // Unused because toolbar does not support typeahead.
3946

4047
/** The value associated with the widget. */
41-
readonly value = () => '' as V; // Unused because toolbar does not support selection.
48+
readonly value = () => this.inputs.value();
4249

4350
/** Whether the widget is selectable. */
4451
readonly selectable = () => true; // Unused because toolbar does not support selection.
4552

4653
/** The position of the widget within the toolbar. */
4754
readonly index = computed(() => this.toolbar().inputs.items().indexOf(this) ?? -1);
4855

56+
/** Whether the widget is selected (only relevant in a selection group). */
57+
readonly selected = computed(() =>
58+
this.toolbar().listBehavior.inputs.value().includes(this.value()),
59+
);
60+
4961
/** Whether the widget is currently the active one (focused). */
50-
readonly active = computed(() => this.toolbar().inputs.activeItem() === this);
51-
52-
constructor(readonly inputs: ToolbarWidgetInputs<V>) {
53-
this.id = inputs.id;
54-
this.element = inputs.element;
55-
this.disabled = inputs.disabled;
56-
this.toolbar = inputs.toolbar;
57-
}
62+
readonly active: SignalLike<boolean> = computed(() => this.toolbar().activeItem() === this);
63+
64+
constructor(readonly inputs: ToolbarWidgetInputs<V>) {}
5865
}

0 commit comments

Comments
 (0)