Skip to content

Commit 7366412

Browse files
committed
refactor(multiple): replace tabbable with tabIndex in aria components
1 parent dbb8861 commit 7366412

26 files changed

Lines changed: 80 additions & 83 deletions

File tree

goldens/aria/grid/index.api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ export class Grid {
2525
scrollActiveCellIntoView(options?: ScrollIntoViewOptions): void;
2626
readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">;
2727
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
28-
readonly tabbable: _angular_core.InputSignal<boolean | undefined>;
28+
readonly tabIndex: _angular_core.InputSignalWithTransform<number | undefined, string | number | undefined>;
2929
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
3030
// (undocumented)
31-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Grid, "[ngGrid]", ["ngGrid"], { "enableSelection": { "alias": "enableSelection"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "rowWrap": { "alias": "rowWrap"; "required": false; "isSignal": true; }; "colWrap": { "alias": "colWrap"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "enableRangeSelection": { "alias": "enableRangeSelection"; "required": false; "isSignal": true; }; "tabbable": { "alias": "tabbable"; "required": false; "isSignal": true; }; }, {}, ["_rows"], never, true, never>;
31+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Grid, "[ngGrid]", ["ngGrid"], { "enableSelection": { "alias": "enableSelection"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "rowWrap": { "alias": "rowWrap"; "required": false; "isSignal": true; }; "colWrap": { "alias": "colWrap"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "enableRangeSelection": { "alias": "enableRangeSelection"; "required": false; "isSignal": true; }; "tabIndex": { "alias": "tabIndex"; "required": false; "isSignal": true; }; }, {}, ["_rows"], never, true, never>;
3232
// (undocumented)
3333
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Grid, never>;
3434
}

goldens/aria/listbox/index.api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@ export class Listbox<V> {
2626
scrollActiveItemIntoView(options?: ScrollIntoViewOptions): void;
2727
readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">;
2828
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
29-
tabbable: _angular_core.InputSignalWithTransform<boolean, unknown>;
29+
readonly tabIndex: _angular_core.InputSignalWithTransform<number | undefined, string | number | undefined>;
3030
protected readonly textDirection: Signal<_angular_cdk_bidi.Direction>;
3131
readonly typeaheadDelay: _angular_core.InputSignal<number>;
3232
readonly value: _angular_core.ModelSignal<V[]>;
3333
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
3434
// (undocumented)
35-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Listbox<any>, "[ngListbox]", ["ngListbox"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "tabbable": { "alias": "tabbable"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, ["_options"], never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>;
35+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Listbox<any>, "[ngListbox]", ["ngListbox"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "tabIndex": { "alias": "tabIndex"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, ["_options"], never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>;
3636
// (undocumented)
3737
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Listbox<any>, never>;
3838
}

goldens/aria/tree/index.api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ export class Tree<V> {
2929
scrollActiveItemIntoView(options?: ScrollIntoViewOptions): void;
3030
readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">;
3131
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
32-
readonly tabbable: _angular_core.InputSignalWithTransform<boolean, unknown>;
32+
readonly tabIndex: _angular_core.InputSignalWithTransform<number | undefined, string | number | undefined>;
3333
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
3434
readonly typeaheadDelay: _angular_core.InputSignal<number>;
3535
// (undocumented)
3636
_unregister(child: TreeItem<V>): void;
3737
readonly value: _angular_core.ModelSignal<V[]>;
3838
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
3939
// (undocumented)
40-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Tree<any>, "[ngTree]", ["ngTree"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "tabbable": { "alias": "tabbable"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "nav": { "alias": "nav"; "required": false; "isSignal": true; }; "currentType": { "alias": "currentType"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, never, never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>;
40+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Tree<any>, "[ngTree]", ["ngTree"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "tabIndex": { "alias": "tabIndex"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "nav": { "alias": "nav"; "required": false; "isSignal": true; }; "currentType": { "alias": "currentType"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, never, never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>;
4141
// (undocumented)
4242
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Tree<any>, never>;
4343
}

src/aria/grid/grid.spec.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ describe('Grid directives', () => {
9696
enableSelection?: boolean;
9797
selectionMode?: 'follow' | 'explicit';
9898
gridData?: RowConfig[];
99+
tabIndex?: number;
99100
}) {
100101
TestBed.resetTestingModule();
101102
TestBed.configureTestingModule({});
@@ -111,6 +112,7 @@ describe('Grid directives', () => {
111112
if (opts?.enableSelection !== undefined)
112113
testComponent.enableSelection.set(opts.enableSelection);
113114
if (opts?.selectionMode !== undefined) testComponent.selectionMode.set(opts.selectionMode);
115+
if (opts?.tabIndex !== undefined) testComponent.tabIndex.set(opts.tabIndex);
114116

115117
if (opts?.gridData !== undefined) {
116118
testComponent.gridData.set(opts.gridData);
@@ -161,14 +163,25 @@ describe('Grid directives', () => {
161163
});
162164

163165
describe('focus management', () => {
164-
it('should set tabindex based on the pattern tabIndex', () => {
166+
it('should set tabindex based on the pattern tabIndex', async () => {
165167
setupGrid({focusMode: 'roving'});
168+
gridInstance._pattern.setDefaultStateEffect();
169+
fixture.detectChanges();
170+
await fixture.whenStable();
166171
expect(gridElement.getAttribute('tabindex')).toBe('-1'); // roving defaults to -1 on host
167172

168173
setupGrid({focusMode: 'activedescendant'});
174+
gridInstance._pattern.setDefaultStateEffect();
175+
fixture.detectChanges();
176+
await fixture.whenStable();
169177
expect(gridElement.getAttribute('tabindex')).toBe('0'); // activedescendant defaults to 0 on host
170178
});
171179

180+
it('should be able to override tabindex', () => {
181+
setupGrid({focusMode: 'activedescendant', tabIndex: -1});
182+
expect(gridElement.getAttribute('tabindex')).toBe('-1');
183+
});
184+
172185
it('should activate the cell when the grid receives focusin', () => {
173186
setupGrid();
174187

@@ -960,7 +973,8 @@ describe('Grid directives', () => {
960973
[focusMode]="focusMode()"
961974
[softDisabled]="softDisabled()"
962975
[enableSelection]="enableSelection()"
963-
[selectionMode]="selectionMode()">
976+
[selectionMode]="selectionMode()"
977+
[tabIndex]="tabIndex()">
964978
@for (row of gridData(); track $index; let rIndex = $index) {
965979
<tr ngGridRow [rowIndex]="row.rowIndex">
966980
@for (cell of row.cells; track $index; let cIndex = $index) {
@@ -1018,6 +1032,7 @@ class GridTestComponent {
10181032
readonly enableSelection = signal(false);
10191033
readonly selectionMode = signal<'follow' | 'explicit'>('follow');
10201034
readonly gridData = signal<RowConfig[]>(createGridData());
1035+
readonly tabIndex = signal<number | undefined>(undefined);
10211036

10221037
onActivated = jasmine.createSpy('activated');
10231038
onDeactivated = jasmine.createSpy('deactivated');

src/aria/grid/grid.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ElementRef,
1616
inject,
1717
input,
18+
numberAttribute,
1819
Signal,
1920
} from '@angular/core';
2021
import {Directionality} from '@angular/cdk/bidi';
@@ -49,7 +50,7 @@ import {GRID_ROW} from './grid-tokens';
4950
exportAs: 'ngGrid',
5051
host: {
5152
'role': 'grid',
52-
'[tabindex]': '_pattern.tabIndex()',
53+
'[tabindex]': 'tabIndex() !== undefined ? tabIndex() : _pattern.tabIndex()',
5354
'[attr.aria-disabled]': '_pattern.disabled()',
5455
'[attr.aria-multiselectable]': '_pattern.multiSelectable()',
5556
'[attr.aria-activedescendant]': '_pattern.activeDescendant()',
@@ -123,8 +124,11 @@ export class Grid {
123124
/** Whether enable range selections (with modifier keys or dragging). */
124125
readonly enableRangeSelection = input(false, {transform: booleanAttribute});
125126

126-
/** Whether the grid is tabbable. */
127-
readonly tabbable = input<boolean | undefined>(undefined);
127+
/** The tabindex of the grid. */
128+
readonly tabIndex = input(undefined, {
129+
transform: (v: string | number | undefined) =>
130+
v === undefined ? undefined : numberAttribute(v),
131+
});
128132

129133
/** The UI pattern for the grid. */
130134
readonly _pattern = new GridPattern({

src/aria/listbox/listbox.spec.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ describe('Listbox', () => {
6666
disabledOptions?: number[];
6767
options?: TestOption[];
6868
textDirection?: Direction;
69+
tabIndex?: number;
6970
}) {
7071
TestBed.configureTestingModule({
7172
providers: [provideFakeDirectionality(opts?.textDirection ?? 'ltr')],
@@ -85,6 +86,7 @@ describe('Listbox', () => {
8586
if (opts?.selectionMode !== undefined) testComponent.selectionMode = opts.selectionMode;
8687
if (opts?.typeaheadDelay !== undefined) testComponent.typeaheadDelay = opts.typeaheadDelay;
8788
if (opts?.options !== undefined) testComponent.options.set(opts.options);
89+
if (opts?.tabIndex !== undefined) testComponent.tabIndex = opts.tabIndex;
8890

8991
if (opts?.disabledOptions !== undefined) {
9092
const currentOptions = testComponent.options();
@@ -176,6 +178,11 @@ describe('Listbox', () => {
176178
expect(listboxElement.getAttribute('aria-multiselectable')).toBe('true');
177179
});
178180

181+
it('should be able to override tabindex', () => {
182+
setupListbox({tabIndex: -1});
183+
expect(listboxElement.getAttribute('tabindex')).toBe('-1');
184+
});
185+
179186
it('should set aria-selected to "true" for selected options', () => {
180187
setupListbox({multi: true, value: [1, 3]});
181188
expect(optionElements[0].getAttribute('aria-selected')).toBe('false');
@@ -795,7 +802,8 @@ interface TestOption {
795802
[multi]="multi"
796803
[wrap]="wrap"
797804
[selectionMode]="selectionMode"
798-
[typeaheadDelay]="typeaheadDelay">
805+
[typeaheadDelay]="typeaheadDelay"
806+
[tabIndex]="tabIndex">
799807
@for (option of options(); track option.value) {
800808
<li ngOption [value]="option.value" [disabled]="option.disabled" [label]="option.label">{{ option.label }}</li>
801809
}
@@ -823,6 +831,7 @@ class ListboxExample {
823831
wrap = true;
824832
selectionMode: 'follow' | 'explicit' = 'explicit';
825833
typeaheadDelay = 500;
834+
tabIndex: number | undefined = undefined;
826835
}
827836

828837
@Component({

src/aria/listbox/listbox.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
inject,
1717
input,
1818
model,
19+
numberAttribute,
1920
signal,
2021
Signal,
2122
untracked,
@@ -57,7 +58,7 @@ import {LISTBOX} from './tokens';
5758
host: {
5859
'role': 'listbox',
5960
'[attr.id]': 'id()',
60-
'[attr.tabindex]': '_pattern.tabIndex()',
61+
'[attr.tabindex]': 'tabIndex() !== undefined ? tabIndex() : _pattern.tabIndex()',
6162
'[attr.aria-readonly]': '_pattern.readonly()',
6263
'[attr.aria-disabled]': '_pattern.disabled()',
6364
'[attr.aria-orientation]': '_pattern.orientation()',
@@ -93,7 +94,7 @@ export class Listbox<V> {
9394

9495
/** The Option UIPatterns of the child Options. */
9596
protected readonly items = computed<OptionPattern<V>[]>(() =>
96-
this._options().map(option => option._pattern),
97+
this._options().map((option: Option<V>) => option._pattern),
9798
);
9899

99100
/** Whether the list is vertically or horizontally oriented. */
@@ -134,8 +135,11 @@ export class Listbox<V> {
134135
/** Whether the listbox is readonly. */
135136
readonly readonly = input(false, {transform: booleanAttribute});
136137

137-
/** Whether the list is tabbable. */
138-
tabbable = input(true, {transform: booleanAttribute});
138+
/** The tabindex of the listbox. */
139+
readonly tabIndex = input(undefined, {
140+
transform: (v: string | number | undefined) =>
141+
v === undefined ? undefined : numberAttribute(v),
142+
});
139143

140144
/** The values of the currently selected items. */
141145
readonly value = model<V[]>([]);
@@ -153,7 +157,6 @@ export class Listbox<V> {
153157
items: this.items,
154158
activeItem: signal(undefined),
155159
textDirection: this.textDirection,
156-
tabbable: this.tabbable,
157160
element: () => this._elementRef.nativeElement,
158161
combobox: () => this._popup?.combobox?._pattern,
159162
};

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

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,6 @@ export interface GridFocusInputs {
3131

3232
/** Whether disabled cells in the grid should be focusable. */
3333
softDisabled: SignalLike<boolean>;
34-
35-
/** Whether the grid is tabbable. */
36-
tabbable?: SignalLike<boolean | undefined>;
3734
}
3835

3936
/** Dependencies for the `GridFocus` class. */
@@ -99,14 +96,6 @@ export class GridFocus<T extends GridFocusCell> {
9996

10097
/** The tab index for the grid container. */
10198
readonly gridTabIndex = computed<-1 | 0>(() => {
102-
const isTabbable = this.inputs.tabbable?.();
103-
if (isTabbable === false) {
104-
return -1;
105-
}
106-
if (isTabbable === true) {
107-
return 0;
108-
}
109-
11099
if (this.gridDisabled()) {
111100
return 0;
112101
}

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

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -107,26 +107,6 @@ describe('List Focus', () => {
107107
});
108108
});
109109

110-
describe('tabbable', () => {
111-
it('should override getListTabIndex to -1 when tabbable is explicitly false', () => {
112-
const focusManager = getListFocus({
113-
focusMode: signal('activedescendant'),
114-
tabbable: signal(false),
115-
});
116-
expect(focusManager.getListTabIndex()).toBe(-1);
117-
});
118-
119-
it('should override getItemTabIndex to -1 when tabbable is explicitly false', () => {
120-
const focusManager = getListFocus({
121-
focusMode: signal('roving'),
122-
tabbable: signal(false),
123-
});
124-
const items = focusManager.inputs.items();
125-
focusManager.inputs.activeItem.set(items[0]);
126-
expect(focusManager.getItemTabIndex(items[0])).toBe(-1);
127-
});
128-
});
129-
130110
describe('#isFocusable', () => {
131111
it('should return true for enabled items', () => {
132112
const focusManager = getListFocus({softDisabled: signal(false)});

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

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,6 @@ export interface ListFocusInputs<T extends ListFocusItem> {
3939

4040
/** The html element that should receive focus. */
4141
element: SignalLike<HTMLElement | undefined>;
42-
43-
/** Whether the list is tabbable. */
44-
tabbable?: SignalLike<boolean>;
4542
}
4643

4744
/** Controls focus for a list of items. */
@@ -79,9 +76,6 @@ export class ListFocus<T extends ListFocusItem> {
7976

8077
/** The tab index for the list. */
8178
getListTabIndex(): -1 | 0 {
82-
if (this.inputs.tabbable !== undefined && !this.inputs.tabbable()) {
83-
return -1;
84-
}
8579
if (this.isListDisabled()) {
8680
return 0;
8781
}
@@ -90,9 +84,6 @@ export class ListFocus<T extends ListFocusItem> {
9084

9185
/** Returns the tab index for the given item. */
9286
getItemTabIndex(item: T): -1 | 0 {
93-
if (this.inputs.tabbable !== undefined && !this.inputs.tabbable()) {
94-
return -1;
95-
}
9687
if (this.isListDisabled()) {
9788
return -1;
9889
}

0 commit comments

Comments
 (0)