Skip to content

Commit 6ac0c18

Browse files
committed
refactor(aria/tree): visible item focus management
1 parent 8bf1daa commit 6ac0c18

File tree

3 files changed

+48
-29
lines changed

3 files changed

+48
-29
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
5353
!item ||
5454
item.disabled() ||
5555
!item.selectable() ||
56+
!this.inputs.focusManager.isFocusable(item as T) ||
5657
this.inputs.values().includes(item.value())
5758
) {
5859
return;
@@ -138,7 +139,7 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
138139
toggleAll() {
139140
const selectableValues = this.inputs
140141
.items()
141-
.filter(i => !i.disabled() && i.selectable())
142+
.filter(i => !i.disabled() && i.selectable() && this.inputs.focusManager.isFocusable(i))
142143
.map(i => i.value());
143144

144145
selectableValues.every(i => this.inputs.values().includes(i))

src/aria/private/combobox/combobox.spec.ts

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import {ComboboxInputs, ComboboxPattern} from './combobox';
1010
import {OptionPattern} from '../listbox/option';
1111
import {ComboboxListboxPattern} from '../listbox/combobox-listbox';
1212
import {createKeyboardEvent} from '@angular/cdk/testing/private';
13-
import {SignalLike, signal, WritableSignalLike} from '../behaviors/signal-like/signal-like';
13+
import {
14+
SignalLike,
15+
signal,
16+
WritableSignalLike,
17+
computed,
18+
} from '../behaviors/signal-like/signal-like';
1419
import {ModifierKeys} from '@angular/cdk/testing';
1520
import {TreeItemPattern} from '../tree/tree';
1621
import {ComboboxTreePattern} from '../tree/combobox-tree';
@@ -78,7 +83,7 @@ function _type(
7883
if (popup instanceof ComboboxListboxPattern) {
7984
(popup.inputs.items as WritableSignalLike<any[]>).set(options);
8085
} else if (popup instanceof ComboboxTreePattern) {
81-
(popup.inputs.allItems as WritableSignalLike<any[]>).set(options);
86+
(popup.inputs.items as WritableSignalLike<any[]>).set(options);
8287
}
8388
firstMatch.set(options[0]?.value());
8489
combobox.onFilter();
@@ -164,7 +169,7 @@ function getTreePattern(
164169

165170
const tree = new ComboboxTreePattern<string>({
166171
id: signal('tree-1'),
167-
allItems: items,
172+
items,
168173
values: signal(initialValue ? [initialValue] : []),
169174
combobox: signal(combobox) as any,
170175
activeItem: signal(undefined),
@@ -182,6 +187,10 @@ function getTreePattern(
182187
currentType: signal('false'),
183188
});
184189

190+
class TestTreeItemPattern extends TreeItemPattern<string> {
191+
override readonly focusable = computed(() => this.inputs.focusable?.() ?? true);
192+
}
193+
185194
// Recursive function to create tree items
186195
function createTreeItems(
187196
data: TreeItemData[],
@@ -190,9 +199,9 @@ function getTreePattern(
190199
return data.map((node, index) => {
191200
const element = document.createElement('div');
192201
element.role = 'treeitem';
193-
const treeItem = new TreeItemPattern<string>({
202+
const treeItem = new TestTreeItemPattern({
194203
value: signal(node.value),
195-
id: signal('tree-item-' + tree.inputs.allItems().length),
204+
id: signal('tree-item-' + tree.inputs.items().length),
196205
disabled: signal(false),
197206
selectable: signal(true),
198207
expanded: signal(false),
@@ -204,7 +213,7 @@ function getTreePattern(
204213
children: signal([]),
205214
});
206215

207-
(tree.inputs.allItems as WritableSignalLike<TreeItemPattern<string>[]>).update(items =>
216+
(tree.inputs.items as WritableSignalLike<TreeItemPattern<string>[]>).update(items =>
208217
items.concat(treeItem),
209218
);
210219

@@ -686,11 +695,17 @@ describe('Combobox with Tree Pattern', () => {
686695

687696
it('should expand a closed node on ArrowRight', () => {
688697
const {combobox, tree} = getPatterns();
689-
const before = tree.visibleItems().map(i => i.searchTerm());
698+
const before = tree.inputs
699+
.items()
700+
.filter(i => i.visible())
701+
.map(i => i.searchTerm());
690702
expect(before).toEqual(['Fruit', 'Vegetables', 'Grains']);
691703
combobox.onKeydown(down());
692704
combobox.onKeydown(right());
693-
const after = tree.visibleItems().map(i => i.searchTerm());
705+
const after = tree.inputs
706+
.items()
707+
.filter(i => i.visible())
708+
.map(i => i.searchTerm());
694709
expect(after).toEqual(['Fruit', 'Apple', 'Banana', 'Cantaloupe', 'Vegetables', 'Grains']);
695710
});
696711

@@ -707,7 +722,10 @@ describe('Combobox with Tree Pattern', () => {
707722
combobox.onKeydown(down());
708723
combobox.onKeydown(right());
709724
combobox.onKeydown(left());
710-
const after = tree.visibleItems().map(i => i.searchTerm());
725+
const after = tree.inputs
726+
.items()
727+
.filter(i => i.visible())
728+
.map(i => i.searchTerm());
711729
expect(after).toEqual(['Fruit', 'Vegetables', 'Grains']);
712730
expect(tree.inputs.activeItem()?.searchTerm()).toBe('Fruit');
713731
});
@@ -756,15 +774,15 @@ describe('Combobox with Tree Pattern', () => {
756774
});
757775

758776
it('should select and commit on click', () => {
759-
combobox.onClick(clickTreeItem(tree.inputs.allItems(), 0));
777+
combobox.onClick(clickTreeItem(tree.inputs.items(), 0));
760778
expect(tree.inputs.values()).toEqual(['Fruit']);
761779
expect(inputEl.value).toBe('Fruit');
762780
});
763781

764782
it('should select and commit to input on Enter', () => {
765783
combobox.onKeydown(down());
766784
combobox.onKeydown(enter());
767-
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]);
785+
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[0]);
768786
expect(tree.inputs.values()).toEqual(['Fruit']);
769787
expect(inputEl.value).toBe('Fruit');
770788
});
@@ -816,8 +834,8 @@ describe('Combobox with Tree Pattern', () => {
816834
});
817835

818836
it('should select and commit on click', () => {
819-
combobox.onClick(clickTreeItem(tree.inputs.allItems(), 2));
820-
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[2]);
837+
combobox.onClick(clickTreeItem(tree.inputs.items(), 2));
838+
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[2]);
821839
expect(tree.inputs.values()).toEqual(['Banana']);
822840
expect(inputEl.value).toBe('Banana');
823841
});
@@ -833,7 +851,7 @@ describe('Combobox with Tree Pattern', () => {
833851

834852
it('should select the first item on arrow down when collapsed', () => {
835853
combobox.onKeydown(down());
836-
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]);
854+
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[0]);
837855
expect(tree.inputs.values()).toEqual(['Fruit']);
838856
});
839857

@@ -873,8 +891,8 @@ describe('Combobox with Tree Pattern', () => {
873891
});
874892

875893
it('should select and commit on click', () => {
876-
combobox.onClick(clickTreeItem(tree.inputs.allItems(), 2));
877-
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[2]);
894+
combobox.onClick(clickTreeItem(tree.inputs.items(), 2));
895+
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[2]);
878896
expect(tree.inputs.values()).toEqual(['Banana']);
879897
expect(inputEl.value).toBe('Banana');
880898
});
@@ -890,7 +908,7 @@ describe('Combobox with Tree Pattern', () => {
890908

891909
it('should select the first item on arrow down when collapsed', () => {
892910
combobox.onKeydown(down());
893-
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]);
911+
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[0]);
894912
expect(tree.inputs.values()).toEqual(['Fruit']);
895913
});
896914

@@ -945,7 +963,7 @@ describe('Combobox with Tree Pattern', () => {
945963
const {combobox, tree, inputEl} = getPatterns({readonly: true});
946964
combobox.onClick(clickInput(inputEl));
947965
expect(combobox.expanded()).toBe(true);
948-
combobox.onClick(clickTreeItem(tree.inputs.allItems(), 0));
966+
combobox.onClick(clickTreeItem(tree.inputs.items(), 0));
949967
expect(tree.inputs.values()).toEqual(['Fruit']);
950968
expect(inputEl.value).toBe('Fruit');
951969
expect(combobox.expanded()).toBe(false);

src/aria/private/tree/tree.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export class TreeItemPattern<V> implements TreeItem<V, TreeItemPattern<V>>, Expa
6161
readonly children: SignalLike<TreeItemPattern<V>[]> = () => this.inputs.children();
6262

6363
/** The position of this item among its siblings. */
64-
readonly index = computed(() => this.tree().visibleItems().indexOf(this));
64+
readonly index = computed(() => this.tree().inputs.items().indexOf(this));
6565

6666
/** Controls expansion for child items. */
6767
readonly expansionBehavior: ListExpansion;
@@ -72,6 +72,9 @@ export class TreeItemPattern<V> implements TreeItem<V, TreeItemPattern<V>>, Expa
7272
/** Whether the item is selectable. */
7373
readonly selectable: SignalLike<boolean> = () => this.inputs.selectable();
7474

75+
/** Whether the item is focusable. */
76+
readonly focusable = computed(() => this.visible() && (this.inputs.focusable?.() ?? true));
77+
7578
/** Whether the item is expanded. */
7679
readonly expanded: WritableSignalLike<boolean>;
7780

@@ -142,7 +145,7 @@ export interface TreeInputs<V> extends Omit<TreeBehaviorInputs<TreeItemPattern<V
142145
id: SignalLike<string>;
143146

144147
/** All items in the tree, in document order (DFS-like, a flattened list). */
145-
allItems: SignalLike<TreeItemPattern<V>[]>;
148+
items: SignalLike<TreeItemPattern<V>[]>;
146149

147150
/** Whether the tree is in navigation mode. */
148151
nav: SignalLike<boolean>;
@@ -176,12 +179,9 @@ export class TreePattern<V> implements TreeInputs<V> {
176179

177180
/** The direct children of the root (top-level tree items). */
178181
readonly children = computed(() =>
179-
this.inputs.allItems().filter(item => item.level() === this.level() + 1),
182+
this.inputs.items().filter(item => item.level() === this.level() + 1),
180183
);
181184

182-
/** All currently visible tree items. An item is visible if their parent is expanded. */
183-
readonly visibleItems = computed(() => this.inputs.allItems().filter(item => item.visible()));
184-
185185
/** Whether the tree selection follows focus. */
186186
readonly followFocus = computed(() => this.inputs.selectionMode() === 'follow');
187187

@@ -331,7 +331,7 @@ export class TreePattern<V> implements TreeInputs<V> {
331331
> = () => this.inputs.currentType();
332332

333333
/** All items in the tree, in document order (DFS-like, a flattened list). */
334-
readonly allItems: SignalLike<TreeItemPattern<V>[]> = () => this.inputs.allItems();
334+
readonly items: SignalLike<TreeItemPattern<V>[]> = () => this.inputs.items();
335335

336336
/** The focus strategy used by the tree. */
337337
readonly focusMode: SignalLike<'roving' | 'activedescendant'> = () => this.inputs.focusMode();
@@ -372,7 +372,7 @@ export class TreePattern<V> implements TreeInputs<V> {
372372

373373
this.treeBehavior = new Tree({
374374
...inputs,
375-
items: this.visibleItems,
375+
items: this.inputs.items,
376376
multi: this.multi,
377377
});
378378

@@ -392,7 +392,7 @@ export class TreePattern<V> implements TreeInputs<V> {
392392
setDefaultState() {
393393
let firstItem: TreeItemPattern<V> | undefined;
394394

395-
for (const item of this.allItems()) {
395+
for (const item of this.inputs.items()) {
396396
if (!item.visible()) continue;
397397
if (!this.treeBehavior.isFocusable(item)) continue;
398398

@@ -487,6 +487,6 @@ export class TreePattern<V> implements TreeInputs<V> {
487487
return;
488488
}
489489
const element = event.target.closest('[role="treeitem"]');
490-
return this.inputs.allItems().find(i => i.element() === element);
490+
return this.inputs.items().find(i => i.element() === element);
491491
}
492492
}

0 commit comments

Comments
 (0)