Skip to content

Commit 36b886c

Browse files
committed
fix(aria/toolbar): support native form controls
1 parent de579cc commit 36b886c

13 files changed

Lines changed: 435 additions & 60 deletions

File tree

goldens/aria/private/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,7 @@ export class ToolbarWidgetGroupPattern<T extends ListItem<V>, V> {
701701
// @public
702702
export interface ToolbarWidgetInputs<V> extends Omit<ListItem<V>, 'searchTerm' | 'index' | 'selectable'> {
703703
group: SignalLike<ToolbarWidgetGroupPattern<ToolbarWidgetPattern<V>, V> | undefined>;
704+
selectable?: SignalLike<boolean>;
704705
toolbar: SignalLike<ToolbarPattern<V>>;
705706
}
706707

goldens/aria/toolbar/index.api.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,12 @@ export class ToolbarWidget<V> implements OnInit, OnDestroy {
4848
// (undocumented)
4949
ngOnInit(): void;
5050
readonly _pattern: ToolbarWidgetPattern<V>;
51+
readonly selectable: _angular_core.InputSignalWithTransform<boolean, unknown>;
5152
readonly selected: () => boolean;
5253
readonly _toolbarPattern: _angular_core.Signal<ToolbarPattern<V>>;
5354
readonly value: _angular_core.InputSignal<V>;
5455
// (undocumented)
55-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<ToolbarWidget<any>, "[ngToolbarWidget]", ["ngToolbarWidget"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>;
56+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<ToolbarWidget<any>, "[ngToolbarWidget]", ["ngToolbarWidget"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": true; "isSignal": true; }; "selectable": { "alias": "selectable"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
5657
// (undocumented)
5758
static ɵfac: _angular_core.ɵɵFactoryDeclaration<ToolbarWidget<any>, never>;
5859
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export interface ToolbarWidgetInputs<V> extends Omit<
2121

2222
/** A reference to the parent widget group. */
2323
group: SignalLike<ToolbarWidgetGroupPattern<ToolbarWidgetPattern<V>, V> | undefined>;
24+
25+
/** Whether the widget is selectable. Defaults to true. */
26+
selectable?: SignalLike<boolean>;
2427
}
2528

2629
export class ToolbarWidgetPattern<V> implements ListItem<V> {
@@ -49,7 +52,7 @@ export class ToolbarWidgetPattern<V> implements ListItem<V> {
4952
readonly value = () => this.inputs.value();
5053

5154
/** Whether the widget is selectable. */
52-
readonly selectable = () => true; // Unused because toolbar does not support selection.
55+
readonly selectable = () => (this.inputs.selectable ? this.inputs.selectable() : true);
5356

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

src/aria/private/toolbar/toolbar.spec.ts

Lines changed: 164 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,27 @@ function clickItem(item: ToolbarWidgetPattern<string>, mods?: ModifierKeys) {
5555
} as unknown as PointerEvent;
5656
}
5757

58+
function keyEvent(key: string, target?: Element): KeyboardEvent {
59+
const event = createKeyboardEvent('keydown', 0, key);
60+
if (target) {
61+
Object.defineProperty(event, 'target', {value: target, configurable: true});
62+
Object.defineProperty(event, 'composedPath', {value: () => [target], configurable: true});
63+
}
64+
return event;
65+
}
66+
67+
function pointerdownEvent(target: Element) {
68+
let defaultPrevented = false;
69+
return {
70+
target,
71+
composedPath: () => [target],
72+
defaultPrevented: () => defaultPrevented,
73+
preventDefault: () => {
74+
defaultPrevented = true;
75+
},
76+
} as unknown as PointerEvent & {defaultPrevented: () => boolean};
77+
}
78+
5879
function getToolbarPattern(
5980
inputs: Partial<{
6081
[K in keyof TestInputs]: TestInputs[K] extends WritableSignalLike<infer T> ? T : never;
@@ -96,15 +117,20 @@ function getWidgetPattern(
96117
value: string,
97118
toolbar: ToolbarPattern<string>,
98119
group?: ToolbarWidgetGroupPattern<ToolbarWidgetPattern<string>, string>,
120+
options?: {
121+
element?: HTMLElement;
122+
selectable?: SignalLike<boolean>;
123+
},
99124
): TestWidget {
100-
const element = signal(document.createElement('button'));
125+
const element = signal(options?.element ?? document.createElement('button'));
101126
const widget = new ToolbarWidgetPattern<string>({
102127
id: signal(`widget-${value}`),
103128
element,
104129
disabled: signal(false),
105130
value: signal(value),
106131
group: signal(group),
107132
toolbar: signal(toolbar),
133+
selectable: options?.selectable,
108134
});
109135
return widget as TestWidget;
110136
}
@@ -1296,22 +1322,28 @@ describe('Toolbar Pattern', () => {
12961322
});
12971323

12981324
describe('Selection', () => {
1299-
it('should toggle the active item on Enter (selection)', () => {
1325+
it('should toggle the active item in a group on Enter (selection)', () => {
13001326
const {toolbar} = getPatterns();
1301-
expect(getItem(toolbar, 'item 0').selected()).toBeFalse();
1327+
// Navigate to item 2 (in group 0)
1328+
toolbar.onKeydown(right());
1329+
toolbar.onKeydown(right());
1330+
expect(getItem(toolbar, 'item 2').selected()).toBeFalse();
13021331
toolbar.onKeydown(enter());
1303-
expect(getItem(toolbar, 'item 0').selected()).toBeTrue();
1332+
expect(getItem(toolbar, 'item 2').selected()).toBeTrue();
13041333
toolbar.onKeydown(enter());
1305-
expect(getItem(toolbar, 'item 0').selected()).toBeFalse();
1334+
expect(getItem(toolbar, 'item 2').selected()).toBeFalse();
13061335
});
13071336

1308-
it('should toggle the active item on Space (selection)', () => {
1337+
it('should toggle the active item in a group on Space (selection)', () => {
13091338
const {toolbar} = getPatterns();
1310-
expect(getItem(toolbar, 'item 0').selected()).toBeFalse();
1339+
// Navigate to item 2 (in group 0)
1340+
toolbar.onKeydown(right());
1341+
toolbar.onKeydown(right());
1342+
expect(getItem(toolbar, 'item 2').selected()).toBeFalse();
13111343
toolbar.onKeydown(space());
1312-
expect(getItem(toolbar, 'item 0').selected()).toBeTrue();
1344+
expect(getItem(toolbar, 'item 2').selected()).toBeTrue();
13131345
toolbar.onKeydown(space());
1314-
expect(getItem(toolbar, 'item 0').selected()).toBeFalse();
1346+
expect(getItem(toolbar, 'item 2').selected()).toBeFalse();
13151347
});
13161348

13171349
it('should toggle the active item on click (selection)', () => {
@@ -1323,19 +1355,18 @@ describe('Toolbar Pattern', () => {
13231355
expect(getItem(toolbar, 'item 0').selected()).toBeFalse();
13241356
});
13251357

1326-
it('should be able to select multiple items in the toolbar (selection)', () => {
1327-
const {toolbar} = getPatterns();
1358+
it('should be able to select multiple items via click (selection)', () => {
1359+
const {toolbar, items} = getPatterns();
13281360
expect(getItem(toolbar, 'item 0').selected()).toBeFalse();
13291361
expect(getItem(toolbar, 'item 1').selected()).toBeFalse();
13301362

13311363
// Select first item
1332-
toolbar.onKeydown(enter());
1364+
toolbar.onClick(clickItem(items[0]));
13331365
expect(getItem(toolbar, 'item 0').selected()).toBeTrue();
13341366
expect(getItem(toolbar, 'item 1').selected()).toBeFalse();
13351367

1336-
// Navigate to and select second item
1337-
toolbar.onKeydown(right());
1338-
toolbar.onKeydown(space());
1368+
// Select second item
1369+
toolbar.onClick(clickItem(items[1]));
13391370
expect(getItem(toolbar, 'item 0').selected()).toBeTrue();
13401371
expect(getItem(toolbar, 'item 1').selected()).toBeTrue();
13411372
});
@@ -1363,12 +1394,8 @@ describe('Toolbar Pattern', () => {
13631394
const {toolbar, items} = getPatterns();
13641395
items[1].inputs.disabled.set(true);
13651396

1366-
// Navigate to disabled item
1367-
toolbar.onKeydown(right());
1368-
expect(toolbar.activeItem()?.value()).toBe('item 1');
1369-
1370-
// Try to select disabled item
1371-
toolbar.onKeydown(enter());
1397+
// Try to click disabled item
1398+
toolbar.onClick(clickItem(items[1]));
13721399
expect(getItem(toolbar, 'item 1').selected()).toBeFalse();
13731400
});
13741401

@@ -1395,31 +1422,130 @@ describe('Toolbar Pattern', () => {
13951422
expect(toolbar.activeItem()?.value()).toBe('item 0'); // Should reset to item 0
13961423
});
13971424

1398-
it('should NOT set default state if keyboard interacted', () => {
1425+
it('should not set default state if already interacted', () => {
13991426
const {toolbar, items} = getPatterns();
1400-
toolbar.inputs.activeItem.set(items[0]);
1401-
toolbar.onKeydown(right()); // Interaction (ArrowRight moves to item 1)
1402-
1427+
toolbar.inputs.activeItem.set(items[1]); // Set to item 1
1428+
toolbar.onKeydown(right()); // Mark interacted
14031429
toolbar.setDefaultStateEffect();
1404-
expect(toolbar.activeItem()?.value()).toBe('item 1'); // Should stay on item 1 (interacted)
1430+
expect(toolbar.activeItem()?.value()).toBe('item 2'); // Retains navigation target
14051431
});
1432+
});
14061433

1407-
it('should NOT set default state if pointer interacted', () => {
1408-
const {toolbar, items} = getPatterns();
1409-
toolbar.inputs.activeItem.set(items[1]);
1410-
toolbar.onPointerdown(clickItem(items[1])); // Interaction
1434+
describe('Form Controls & Composite Widgets Support (Generic Handling)', () => {
1435+
describe('Pointerdown event handling', () => {
1436+
it('should mark interacted without preventDefault', () => {
1437+
const {toolbar} = getPatterns();
1438+
const divEl = document.createElement('div');
1439+
const event = pointerdownEvent(divEl);
1440+
toolbar.onPointerdown(event);
1441+
expect(event.defaultPrevented()).toBeFalse();
1442+
expect(toolbar.hasBeenInteracted()).toBeTrue();
1443+
});
1444+
});
14111445

1412-
toolbar.setDefaultStateEffect();
1413-
expect(toolbar.activeItem()?.value()).toBe('item 1'); // Should stay on item 1
1446+
describe('Standalone widget keyboard navigation (horizontal)', () => {
1447+
let toolbar: ToolbarPattern<string>;
1448+
let selectEl: HTMLSelectElement;
1449+
let selectWidget: TestWidget;
1450+
let inputEl: HTMLInputElement;
1451+
let inputWidget: TestWidget;
1452+
let nextWidget: TestWidget;
1453+
1454+
beforeEach(() => {
1455+
selectEl = document.createElement('select');
1456+
inputEl = document.createElement('input');
1457+
inputEl.type = 'number';
1458+
1459+
const items = signal<TestItem[]>([]);
1460+
const setup = getToolbarPattern({orientation: 'horizontal'}, items);
1461+
toolbar = setup.toolbar;
1462+
1463+
selectWidget = getWidgetPattern('select-widget', toolbar, undefined, {
1464+
element: selectEl,
1465+
});
1466+
inputWidget = getWidgetPattern('number-widget', toolbar, undefined, {
1467+
element: inputEl,
1468+
});
1469+
nextWidget = getWidgetPattern('button-next', toolbar);
1470+
1471+
items.set([selectWidget, inputWidget, nextWidget]);
1472+
toolbar.setDefaultState();
1473+
});
1474+
1475+
it('should NOT move toolbar focus on ArrowUp / ArrowDown for standalone widgets', () => {
1476+
expect(toolbar.activeItem()?.value()).toBe('select-widget');
1477+
1478+
toolbar.onKeydown(keyEvent('ArrowDown', selectEl));
1479+
expect(toolbar.activeItem()?.value()).toBe('select-widget');
1480+
1481+
toolbar.onKeydown(keyEvent('ArrowUp', selectEl));
1482+
expect(toolbar.activeItem()?.value()).toBe('select-widget');
1483+
});
1484+
1485+
it('should move toolbar focus on ArrowRight / ArrowLeft across standalone widgets', () => {
1486+
toolbar.onKeydown(keyEvent('ArrowRight', selectEl));
1487+
expect(toolbar.activeItem()?.value()).toBe('number-widget');
1488+
1489+
toolbar.onKeydown(keyEvent('ArrowRight', inputEl));
1490+
expect(toolbar.activeItem()?.value()).toBe('button-next');
1491+
1492+
toolbar.onKeydown(keyEvent('ArrowLeft', nextWidget.element()));
1493+
expect(toolbar.activeItem()?.value()).toBe('number-widget');
1494+
});
1495+
1496+
it('should navigate to first/last on Home/End', () => {
1497+
toolbar.onKeydown(keyEvent('End', selectEl));
1498+
expect(toolbar.activeItem()?.value()).toBe('button-next');
1499+
1500+
toolbar.onKeydown(keyEvent('Home', nextWidget.element()));
1501+
expect(toolbar.activeItem()?.value()).toBe('select-widget');
1502+
});
1503+
1504+
it('should NOT select widget when selectable is false', () => {
1505+
const nonSelectableWidget = getWidgetPattern('non-sel', toolbar, undefined, {
1506+
selectable: signal(false),
1507+
});
1508+
expect(nonSelectableWidget.selectable()).toBeFalse();
1509+
1510+
toolbar.onClick(clickItem(nonSelectableWidget));
1511+
expect(nonSelectableWidget.selected()).toBeFalse();
1512+
expect(toolbar.listBehavior.inputs.value()).toEqual([]);
1513+
});
14141514
});
14151515

1416-
it('should NOT set default state if focus-in occurred', () => {
1417-
const {toolbar, items} = getPatterns();
1418-
toolbar.inputs.activeItem.set(items[1]);
1419-
toolbar.onFocusIn(); // Interaction
1516+
describe('Non-selectable widgets inside widget group', () => {
1517+
it('should NOT toggle on Space or Enter when selectable is false in group', () => {
1518+
const items = signal<TestItem[]>([]);
1519+
const setup = getToolbarPattern({orientation: 'horizontal'}, items);
1520+
const toolbar = setup.toolbar;
1521+
1522+
const group = new ToolbarWidgetGroupPattern<TestWidget, string>({
1523+
disabled: signal(false),
1524+
multi: signal(false),
1525+
toolbar: () => toolbar,
1526+
items: signal([]),
1527+
});
14201528

1421-
toolbar.setDefaultStateEffect();
1422-
expect(toolbar.activeItem()?.value()).toBe('item 1'); // Should stay on item 1
1529+
const widget1 = getWidgetPattern('w1', toolbar, group, {selectable: signal(false)});
1530+
const widget2 = getWidgetPattern('w2', toolbar, group, {selectable: signal(true)});
1531+
(group.inputs.items as any).set([widget1, widget2]);
1532+
items.set([widget1, widget2]);
1533+
toolbar.setDefaultState();
1534+
1535+
expect(toolbar.activeItem()?.value()).toBe('w1');
1536+
toolbar.onKeydown(space());
1537+
expect(widget1.selected()).toBeFalse();
1538+
1539+
toolbar.onKeydown(enter());
1540+
expect(widget1.selected()).toBeFalse();
1541+
1542+
// Navigate to selectable widget2 in group
1543+
toolbar.onKeydown(right());
1544+
expect(toolbar.activeItem()?.value()).toBe('w2');
1545+
1546+
toolbar.onKeydown(enter());
1547+
expect(widget2.selected()).toBeTrue();
1548+
});
14231549
});
14241550
});
14251551
});

src/aria/private/toolbar/toolbar.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,25 @@ export class ToolbarPattern<V> {
8282
/** The keydown event manager for the toolbar. */
8383
private readonly _keydown = computed(() => {
8484
const manager = new KeyboardEventManager();
85+
const activeItem = this.inputs.activeItem();
8586

86-
return manager
87+
manager
8788
.on(this._nextKey, () => this.listBehavior.next(), {ignoreRepeat: false})
8889
.on(this._prevKey, () => this.listBehavior.prev(), {ignoreRepeat: false})
89-
.on(this._altNextKey, () => this._groupNext(), {ignoreRepeat: false})
90-
.on(this._altPrevKey, () => this._groupPrev(), {ignoreRepeat: false})
91-
.on(' ', () => this.select())
92-
.on('Enter', () => this.select())
9390
.on('Home', () => this.listBehavior.first())
9491
.on('End', () => this.listBehavior.last());
92+
93+
if (activeItem?.group()) {
94+
manager
95+
.on(this._altNextKey, () => this._groupNext(), {ignoreRepeat: false})
96+
.on(this._altPrevKey, () => this._groupPrev(), {ignoreRepeat: false});
97+
}
98+
99+
if (activeItem?.selectable() && activeItem?.group()) {
100+
manager.on(' ', () => this.select()).on('Enter', () => this.select());
101+
}
102+
103+
return manager;
95104
});
96105

97106
/** Navigates to the next widget in a widget group. */
@@ -144,15 +153,25 @@ export class ToolbarPattern<V> {
144153

145154
if (item) {
146155
this.listBehavior.goto(item);
147-
this.select();
156+
if (item.selectable()) {
157+
this.select();
158+
}
148159
}
149160
}
150161

151162
select() {
152-
const group = this.inputs.activeItem()?.group();
163+
const activeItem = this.inputs.activeItem();
164+
if (!activeItem || !activeItem.selectable()) {
165+
return;
166+
}
167+
const group = activeItem.group();
153168

154169
if (!group?.multi()) {
155-
group?.inputs.items().forEach(i => this.listBehavior.deselect(i));
170+
group?.inputs.items().forEach(i => {
171+
if (i !== activeItem) {
172+
this.listBehavior.deselect(i);
173+
}
174+
});
156175
}
157176

158177
this.listBehavior.toggle();
@@ -193,7 +212,6 @@ export class ToolbarPattern<V> {
193212

194213
onPointerdown(event: PointerEvent) {
195214
this.hasBeenInteracted.set(true);
196-
event.preventDefault();
197215
}
198216

199217
onFocusIn() {
@@ -202,7 +220,7 @@ export class ToolbarPattern<V> {
202220

203221
/** Handles click events for the toolbar. */
204222
onClick(event: MouseEvent) {
205-
if (this.disabled() || (event as PointerEvent).pointerType === '') return;
223+
if (this.disabled()) return;
206224
this._goto(event);
207225
}
208226

0 commit comments

Comments
 (0)