Skip to content

Commit 829959d

Browse files
committed
refactor(multiple): add tabbable input to listbox and tree patterns
- Introduces a 'tabbable' input to Listbox and Tree to control whether the widget or its items are in the tab order. - Updates ListFocus and Tree behaviors to respect the 'tabbable' signal, defaulting tabIndex to -1 when false. - Updates simple-combobox examples to set [tabbable]="false" on internal widgets to ensure correct focus behavior. - Includes unit tests for the new tabbable behavior in ListFocus and Tree.
1 parent e52bc52 commit 829959d

17 files changed

Lines changed: 148 additions & 273 deletions

File tree

src/aria/listbox/listbox.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ export class Listbox<V> {
133133
/** Whether the listbox is readonly. */
134134
readonly readonly = input(false, {transform: booleanAttribute});
135135

136+
/** Whether the list is tabbable. */
137+
tabbable = input(true, {transform: booleanAttribute});
138+
136139
/** The values of the currently selected items. */
137140
readonly value = model<V[]>([]);
138141

@@ -146,6 +149,7 @@ export class Listbox<V> {
146149
items: this.items,
147150
activeItem: signal(undefined),
148151
textDirection: this.textDirection,
152+
tabbable: this.tabbable,
149153
element: () => this._elementRef.nativeElement,
150154
combobox: () => this._popup?.combobox?._pattern,
151155
};

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,26 @@ 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+
110130
describe('#isFocusable', () => {
111131
it('should return true for enabled items', () => {
112132
const focusManager = getListFocus({softDisabled: signal(false)});

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ 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>;
4245
}
4346

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

7780
/** The tab index for the list. */
7881
getListTabIndex(): -1 | 0 {
82+
if (this.inputs.tabbable !== undefined && !this.inputs.tabbable()) {
83+
return -1;
84+
}
7985
if (this.isListDisabled()) {
8086
return 0;
8187
}
@@ -84,6 +90,9 @@ export class ListFocus<T extends ListFocusItem> {
8490

8591
/** Returns the tab index for the given item. */
8692
getItemTabIndex(item: T): -1 | 0 {
93+
if (this.inputs.tabbable !== undefined && !this.inputs.tabbable()) {
94+
return -1;
95+
}
8796
if (this.isListDisabled()) {
8897
return -1;
8998
}

src/aria/private/behaviors/tree/tree.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ describe('Tree Behavior', () => {
118118
});
119119
});
120120

121+
describe('with tabbable: false', () => {
122+
it('should override tree container tabIndex to -1', () => {
123+
const {tree} = getDefaultPatterns({tabbable: signal(false)});
124+
expect(tree.tabIndex()).toBe(-1);
125+
});
126+
});
127+
121128
describe('with focusMode: "roving"', () => {
122129
it('should set the list tab index to -1', () => {
123130
const {tree} = getDefaultPatterns({focusMode: signal('roving')});

src/aria/tree/tree.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ export class Tree<V> {
130130
/** The delay in seconds before the typeahead search is reset. */
131131
readonly typeaheadDelay = input(500);
132132

133+
/** Whether the tree is tabbable. */
134+
readonly tabbable = input(true, {transform: booleanAttribute});
135+
133136
/** The values of the currently selected items. */
134137
readonly value = model<V[]>([]);
135138

Lines changed: 13 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,22 @@
11
<div class="example-combobox-container">
22
<div #origin class="example-combobox-input-container">
33
<span class="material-symbols-outlined example-icon example-search-icon">search</span>
4-
<input
5-
ngCombobox
6-
#combobox="ngCombobox"
7-
class="example-combobox-input"
8-
placeholder="Search states..."
9-
[(value)]="searchString"
10-
[(expanded)]="popupExpanded"
11-
/>
4+
<input ngCombobox #combobox="ngCombobox" class="example-combobox-input" placeholder="Search states..."
5+
[(value)]="searchString" [(expanded)]="popupExpanded" />
126
</div>
137

14-
<ng-template
15-
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
16-
[cdkConnectedOverlayOpen]="true"
17-
[cdkConnectedOverlayDisableClose]="true"
18-
>
8+
<ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true"
9+
[cdkConnectedOverlayDisableClose]="true">
1910
<ng-template ngComboboxPopup [combobox]="combobox">
20-
<div
21-
ngListbox
22-
ngComboboxWidget
23-
class="example-listbox example-popup"
24-
focusMode="activedescendant"
25-
[(value)]="selectedOption"
26-
(click)="onCommit()"
27-
(keydown.enter)="onCommit()"
28-
>
29-
@for (option of options(); track option) {
30-
<div
31-
class="example-option example-selectable example-stateful"
32-
ngOption
33-
[value]="option"
34-
[label]="option"
35-
>
36-
<span>{{option}}</span>
37-
<span
38-
aria-hidden="true"
39-
class="material-symbols-outlined example-icon example-selected-icon"
40-
>check</span
41-
>
42-
</div>
43-
}
11+
<div ngListbox ngComboboxWidget class="example-listbox example-popup" focusMode="activedescendant"
12+
[tabbable]="false" [(value)]="selectedOption" (click)="onCommit()" (keydown.enter)="onCommit()">
13+
@for (option of options(); track option) {
14+
<div class="example-option example-selectable example-stateful" ngOption [value]="option" [label]="option">
15+
<span>{{option}}</span>
16+
<span aria-hidden="true" class="material-symbols-outlined example-icon example-selected-icon">check</span>
4417
</div>
18+
}
19+
</div>
4520
</ng-template>
4621
</ng-template>
47-
</div>
22+
</div>

src/components-examples/aria/simple-combobox/simple-combobox-dialog/simple-combobox-dialog-example.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
(keydown.escape)="onSearchEscape($event)" />
2020
</div>
2121
<ng-template ngComboboxPopup [combobox]="innerCombobox">
22-
<div ngListbox ngComboboxWidget class="example-listbox" focusMode="activedescendant"
22+
<div ngListbox ngComboboxWidget class="example-listbox" focusMode="activedescendant" [tabbable]="false"
2323
selectionMode="explicit" [(value)]="selectedStates" (click)="onCommit()" (keydown.enter)="onCommit()">
2424
@for (option of options(); track option) {
2525
<div class="example-option example-selectable example-stateful" ngOption [value]="option"
Lines changed: 11 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,25 @@
11
<div class="example-combobox-container">
22
<div #origin class="example-combobox-input-container">
33
<span class="material-symbols-outlined example-icon example-search-icon">search</span>
4-
<input
5-
ngCombobox
6-
#combobox="ngCombobox"
7-
class="example-combobox-input"
8-
placeholder="Search states..."
9-
[(value)]="searchString"
10-
[(expanded)]="popupExpanded"
11-
[disabled]="true"
12-
/>
4+
<input ngCombobox #combobox="ngCombobox" class="example-combobox-input" placeholder="Search states..."
5+
[(value)]="searchString" [(expanded)]="popupExpanded" [disabled]="true" />
136
</div>
147

158
<!-- Overlay won't open since input is disabled, but can keep for integrity -->
16-
<ng-template
17-
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
18-
[cdkConnectedOverlayOpen]="popupExpanded()"
19-
[cdkConnectedOverlayDisableClose]="true"
20-
>
9+
<ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
10+
[cdkConnectedOverlayOpen]="popupExpanded()" [cdkConnectedOverlayDisableClose]="true">
2111
<ng-template ngComboboxPopup [combobox]="combobox">
2212
<div class="example-popover">
23-
<div
24-
ngListbox
25-
ngComboboxWidget
26-
class="example-listbox"
27-
focusMode="activedescendant"
28-
[(value)]="selectedOption"
29-
(click)="onCommit()"
30-
(keydown.enter)="onCommit()"
31-
>
13+
<div ngListbox ngComboboxWidget class="example-listbox" focusMode="activedescendant" [tabbable]="false"
14+
[(value)]="selectedOption" (click)="onCommit()" (keydown.enter)="onCommit()">
3215
@for (option of options(); track option) {
33-
<div
34-
class="example-option example-selectable example-stateful"
35-
ngOption
36-
[value]="option"
37-
[label]="option"
38-
>
39-
<span>{{option}}</span>
40-
<span
41-
aria-hidden="true"
42-
class="material-symbols-outlined example-icon example-selected-icon"
43-
>check</span
44-
>
45-
</div>
16+
<div class="example-option example-selectable example-stateful" ngOption [value]="option" [label]="option">
17+
<span>{{option}}</span>
18+
<span aria-hidden="true" class="material-symbols-outlined example-icon example-selected-icon">check</span>
19+
</div>
4620
}
4721
</div>
4822
</div>
4923
</ng-template>
5024
</ng-template>
51-
</div>
25+
</div>

src/components-examples/aria/simple-combobox/simple-combobox-grid/simple-combobox-grid-example.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true"
99
[cdkConnectedOverlayDisableClose]="true">
1010
<ng-template ngComboboxPopup [combobox]="combobox">
11-
<div ngComboboxWidget ngGrid focusMode="activedescendant" [tabIndex]="-1" class="example-popup"
11+
<div ngComboboxWidget ngGrid focusMode="activedescendant" [tabIndex]="-1" class="example-popup" [tabbable]="false"
1212
colWrap="continuous" #grid="ngGrid">
1313
@for (item of filteredItems(); track item.label; let i = $index) {
1414
<div ngGridRow class="example-grid-row" [attr.aria-selected]="item === selectedItem()">
Lines changed: 13 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,22 @@
11
<div class="example-combobox-container">
22
<div #origin class="example-combobox-input-container">
33
<span class="material-symbols-outlined example-icon example-search-icon">search</span>
4-
<input
5-
ngCombobox
6-
#combobox="ngCombobox"
7-
class="example-combobox-input"
8-
placeholder="Search states..."
9-
[(value)]="searchString"
10-
[(expanded)]="popupExpanded"
11-
[inlineSuggestion]="selectedOption()[0] || options()[0]"
12-
/>
4+
<input ngCombobox #combobox="ngCombobox" class="example-combobox-input" placeholder="Search states..."
5+
[(value)]="searchString" [(expanded)]="popupExpanded" [inlineSuggestion]="selectedOption()[0] || options()[0]" />
136
</div>
147

15-
<ng-template
16-
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
17-
[cdkConnectedOverlayOpen]="true"
18-
[cdkConnectedOverlayDisableClose]="true"
19-
>
8+
<ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true"
9+
[cdkConnectedOverlayDisableClose]="true">
2010
<ng-template ngComboboxPopup [combobox]="combobox">
21-
<div
22-
ngListbox
23-
ngComboboxWidget
24-
class="example-listbox example-popup"
25-
focusMode="activedescendant"
26-
[(value)]="selectedOption"
27-
(click)="onCommit()"
28-
(keydown.enter)="onCommit()"
29-
>
30-
@for (option of options(); track option) {
31-
<div
32-
class="example-option example-selectable example-stateful"
33-
ngOption
34-
[value]="option"
35-
[label]="option"
36-
>
37-
<span>{{option}}</span>
38-
<span
39-
aria-hidden="true"
40-
class="material-symbols-outlined example-icon example-selected-icon"
41-
>check</span
42-
>
43-
</div>
44-
}
11+
<div ngListbox ngComboboxWidget class="example-listbox example-popup" focusMode="activedescendant"
12+
[tabbable]="false" [(value)]="selectedOption" (click)="onCommit()" (keydown.enter)="onCommit()">
13+
@for (option of options(); track option) {
14+
<div class="example-option example-selectable example-stateful" ngOption [value]="option" [label]="option">
15+
<span>{{option}}</span>
16+
<span aria-hidden="true" class="material-symbols-outlined example-icon example-selected-icon">check</span>
4517
</div>
18+
}
19+
</div>
4620
</ng-template>
4721
</ng-template>
48-
</div>
22+
</div>

0 commit comments

Comments
 (0)