Skip to content

Commit b16eea5

Browse files
committed
refactor(aria/ui-patterns): allow non-selectable items
1 parent 7cf8f5f commit b16eea5

File tree

13 files changed

+345
-38
lines changed

13 files changed

+345
-38
lines changed

src/aria/tree/tree.spec.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ describe('Tree', () => {
114114
if (config.label !== undefined) node.label = config.label;
115115
if (config.children !== undefined) node.children = config.children;
116116
if (config.disabled !== undefined) node.disabled = config.disabled;
117+
if (config.selectable !== undefined) node.selectable = config.selectable;
117118
updateTree({nodes: newNodes});
118119
return;
119120
}
@@ -309,6 +310,16 @@ describe('Tree', () => {
309310
expect(appleItem.getAttribute('aria-current')).toBe('location');
310311
});
311312

313+
it('should not set aria-current when not selectable', () => {
314+
expandAll();
315+
updateTree({nav: true, value: ['apple']});
316+
const appleItem = getTreeItemElementByValue('apple')!;
317+
expect(appleItem.getAttribute('aria-current')).toBe('page');
318+
319+
updateTreeItemByValue('apple', {selectable: false});
320+
expect(appleItem.hasAttribute('aria-current')).toBe(false);
321+
});
322+
312323
it('should not set aria-selected when nav="true"', () => {
313324
expandAll();
314325

@@ -319,6 +330,16 @@ describe('Tree', () => {
319330
updateTree({nav: false});
320331
expect(appleItem.getAttribute('aria-selected')).toBe('true');
321332
});
333+
334+
it('should not set aria-selected when not selectable', () => {
335+
expandAll();
336+
updateTree({value: ['apple']});
337+
const appleItem = getTreeItemElementByValue('apple')!;
338+
expect(appleItem.getAttribute('aria-selected')).toBe('true');
339+
340+
updateTreeItemByValue('apple', {selectable: false});
341+
expect(appleItem.hasAttribute('aria-selected')).toBe(false);
342+
});
322343
});
323344

324345
describe('roving focus mode (focusMode="roving")', () => {
@@ -492,6 +513,18 @@ describe('Tree', () => {
492513
click(appleEl);
493514
expect(treeInstance.value()).toEqual(['banana']);
494515
});
516+
517+
describe('selectable=false', () => {
518+
it('should not select an item on click', () => {
519+
updateTree({value: ['banana']});
520+
updateTreeItemByValue('apple', {selectable: false});
521+
const appleEl = getTreeItemElementByValue('apple')!;
522+
523+
click(appleEl);
524+
expect(treeInstance.value()).not.toContain('apple');
525+
expect(treeInstance.value()).toContain('banana');
526+
});
527+
});
495528
});
496529

497530
describe('selectionMode="follow"', () => {
@@ -560,6 +593,39 @@ describe('Tree', () => {
560593
'broccoli',
561594
]);
562595
});
596+
597+
describe('selectable=false', () => {
598+
it('should not select a range with shift+click if an item is not selectable', () => {
599+
updateTreeItemByValue('banana', {selectable: false});
600+
const appleEl = getTreeItemElementByValue('apple')!;
601+
const berriesEl = getTreeItemElementByValue('berries')!;
602+
603+
click(appleEl);
604+
shiftClick(berriesEl);
605+
606+
expect(treeInstance.value()).not.toContain('banana');
607+
expect(treeInstance.value()).toContain('apple');
608+
expect(treeInstance.value()).toContain('berries');
609+
});
610+
611+
it('should not toggle selection of an item on simple click', () => {
612+
updateTreeItemByValue('apple', {selectable: false});
613+
const appleEl = getTreeItemElementByValue('apple')!;
614+
615+
click(appleEl);
616+
expect(treeInstance.value()).not.toContain('apple');
617+
});
618+
619+
it('should not add to selection with ctrl+click', () => {
620+
updateTree({value: ['banana']});
621+
updateTreeItemByValue('apple', {selectable: false});
622+
const appleEl = getTreeItemElementByValue('apple')!;
623+
624+
ctrlClick(appleEl);
625+
expect(treeInstance.value()).not.toContain('apple');
626+
expect(treeInstance.value()).toContain('banana');
627+
});
628+
});
563629
});
564630
});
565631
});
@@ -607,6 +673,20 @@ describe('Tree', () => {
607673
enter();
608674
expect(treeInstance.value()).toEqual(['grains']);
609675
});
676+
677+
describe('selectable=false', () => {
678+
it('should not select the focused item with Enter', () => {
679+
updateTreeItemByValue('fruits', {selectable: false});
680+
enter();
681+
expect(treeInstance.value()).toEqual([]);
682+
});
683+
684+
it('should not select the focused item with Space', () => {
685+
updateTreeItemByValue('fruits', {selectable: false});
686+
space();
687+
expect(treeInstance.value()).toEqual([]);
688+
});
689+
});
610690
});
611691

612692
describe('selectionMode="follow"', () => {
@@ -737,6 +817,33 @@ describe('Tree', () => {
737817
up({ctrlKey: true});
738818
expect(treeInstance.value()).toEqual(['fruits']);
739819
});
820+
821+
describe('selectable=false', () => {
822+
it('should not toggle selection of the focused item with Space', () => {
823+
updateTreeItemByValue('fruits', {selectable: false});
824+
space();
825+
expect(treeInstance.value()).toEqual([]);
826+
});
827+
it('should not extend selection with Shift+ArrowDown', () => {
828+
updateTreeItemByValue('vegetables', {selectable: false});
829+
shift();
830+
down({shiftKey: true});
831+
down({shiftKey: true});
832+
expect(treeInstance.value()).not.toContain('vegetables');
833+
expect(treeInstance.value().sort()).toEqual(['fruits', 'grains']);
834+
});
835+
it('Ctrl+A should not select non-selectable items', () => {
836+
expandAll();
837+
updateTreeItemByValue('apple', {selectable: false});
838+
updateTreeItemByValue('carrot', {selectable: false});
839+
keydown('A', {ctrlKey: true});
840+
const value = treeInstance.value();
841+
expect(value).not.toContain('apple');
842+
expect(value).not.toContain('carrot');
843+
expect(value).toContain('banana');
844+
expect(value).toContain('broccoli');
845+
});
846+
});
740847
});
741848

742849
describe('selectionMode="follow"', () => {
@@ -864,6 +971,37 @@ describe('Tree', () => {
864971
expect(getFocusedTreeItemValue()).toBe('vegetables');
865972
});
866973

974+
describe('selectable=false', () => {
975+
it('should not select an item on ArrowDown', () => {
976+
updateTreeItemByValue('vegetables', {selectable: false});
977+
down();
978+
expect(treeInstance.value()).not.toContain('vegetables');
979+
expect(treeInstance.value()).toEqual([]);
980+
});
981+
982+
it('should not toggle selection of the focused item on Ctrl+Space', () => {
983+
updateTreeItemByValue('fruits', {selectable: false});
984+
space({ctrlKey: true});
985+
expect(treeInstance.value()).toEqual([]);
986+
});
987+
988+
it('should not extend selection with Shift+ArrowDown', () => {
989+
updateTreeItemByValue('vegetables', {selectable: false});
990+
shift();
991+
down({shiftKey: true});
992+
down({shiftKey: true});
993+
expect(treeInstance.value()).not.toContain('vegetables');
994+
expect(treeInstance.value().sort()).toEqual(['fruits', 'grains']);
995+
});
996+
997+
it('typeahead should not select the focused item', () => {
998+
updateTreeItemByValue('vegetables', {selectable: false});
999+
type('v');
1000+
expect(getFocusedTreeItemValue()).toBe('vegetables');
1001+
expect(treeInstance.value()).not.toContain('vegetables');
1002+
});
1003+
});
1004+
8671005
it('should not select disabled items during Shift+ArrowKey navigation even if skipDisabled is false', () => {
8681006
right(); // Expands fruits
8691007
updateTreeItemByValue('banana', {disabled: true});
@@ -1302,6 +1440,7 @@ interface TestTreeNode<V = string> {
13021440
value: V;
13031441
label: string;
13041442
disabled?: boolean;
1443+
selectable?: boolean;
13051444
children?: TestTreeNode<V>[];
13061445
}
13071446

@@ -1332,6 +1471,7 @@ interface TestTreeNode<V = string> {
13321471
[value]="node.value"
13331472
[label]="node.label"
13341473
[disabled]="!!node.disabled"
1474+
[selectable]="node.selectable ?? true"
13351475
[parent]="parent"
13361476
[attr.data-value]="node.value"
13371477
#treeItem="ngTreeItem"

src/aria/tree/tree.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,9 @@ export class TreeItem<V> extends DeferredContentAware implements OnInit, OnDestr
245245
/** Whether the tree item is disabled. */
246246
readonly disabled = input(false, {transform: booleanAttribute});
247247

248+
/** Whether the tree item is selectable. */
249+
readonly selectable = input<boolean>(true);
250+
248251
/** Optional label for typeahead. Defaults to the element's textContent. */
249252
readonly label = input<string>();
250253

src/aria/ui-patterns/behaviors/list-selection/list-selection.spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {ListFocus} from '../list-focus/list-focus';
1313

1414
type TestItem = ListSelectionItem<number> & {
1515
disabled: WritableSignal<boolean>;
16+
selectable: WritableSignal<boolean>;
1617
};
1718
type TestInputs = Partial<ListSelectionInputs<TestItem, number>> & {
1819
numItems?: number;
@@ -40,6 +41,7 @@ function getItems(length: number): Signal<TestItem[]> {
4041
value: signal(i),
4142
id: signal(`${i}`),
4243
disabled: signal(false),
44+
selectable: signal(true),
4345
element: signal({focus: () => {}} as HTMLElement),
4446
index: signal(i),
4547
};
@@ -83,6 +85,14 @@ describe('List Selection', () => {
8385
expect(selection.inputs.value()).toEqual([]);
8486
});
8587

88+
it('should not select non-selectable items', () => {
89+
const selection = getSelection();
90+
const items = selection.inputs.items() as TestItem[];
91+
items[0].selectable.set(false);
92+
selection.select(); // []
93+
expect(selection.inputs.value()).toEqual([]);
94+
});
95+
8696
it('should do nothing to already selected items', () => {
8797
const selection = getSelection();
8898
selection.select(); // [0]
@@ -106,6 +116,15 @@ describe('List Selection', () => {
106116
selection.deselect(); // [0]
107117
expect(selection.inputs.value()).toEqual([0]);
108118
});
119+
120+
it('should not deselect non-selectable items', () => {
121+
const selection = getSelection();
122+
const items = selection.inputs.items() as TestItem[];
123+
selection.select(); // [0]
124+
items[0].selectable.set(false);
125+
selection.deselect(); // [0]
126+
expect(selection.inputs.value()).toEqual([0]);
127+
});
109128
});
110129

111130
describe('#toggle', () => {
@@ -121,6 +140,14 @@ describe('List Selection', () => {
121140
selection.toggle(); // []
122141
expect(selection.inputs.value().length).toBe(0);
123142
});
143+
144+
it('should not toggle non-selectable items', () => {
145+
const selection = getSelection();
146+
const items = selection.inputs.items() as TestItem[];
147+
items[0].selectable.set(false);
148+
selection.toggle(); // []
149+
expect(selection.inputs.value()).toEqual([]);
150+
});
124151
});
125152

126153
describe('#toggleOne', () => {
@@ -145,6 +172,14 @@ describe('List Selection', () => {
145172
selection.toggleOne(); // [1]
146173
expect(selection.inputs.value()).toEqual([1]);
147174
});
175+
176+
it('should not toggle non-selectable items', () => {
177+
const selection = getSelection();
178+
const items = selection.inputs.items() as TestItem[];
179+
items[0].selectable.set(false);
180+
selection.toggleOne(); // []
181+
expect(selection.inputs.value()).toEqual([]);
182+
});
148183
});
149184

150185
describe('#selectAll', () => {
@@ -159,6 +194,14 @@ describe('List Selection', () => {
159194
selection.selectAll();
160195
expect(selection.inputs.value()).toEqual([]);
161196
});
197+
198+
it('should not select non-selectable items', () => {
199+
const selection = getSelection({multi: signal(true)});
200+
const items = selection.inputs.items() as TestItem[];
201+
items[1].selectable.set(false);
202+
selection.selectAll();
203+
expect(selection.inputs.value()).toEqual([0, 2, 3, 4]);
204+
});
162205
});
163206

164207
describe('#deselectAll', () => {
@@ -175,6 +218,15 @@ describe('List Selection', () => {
175218
selection.deselectAll();
176219
expect(selection.inputs.value().length).toBe(0);
177220
});
221+
222+
it('should not deselect non-selectable items', () => {
223+
const selection = getSelection({multi: signal(true)});
224+
const items = selection.inputs.items() as TestItem[];
225+
selection.selectAll(); // [0, 1, 2, 3, 4]
226+
items[1].selectable.set(false);
227+
selection.deselectAll(); // [1]
228+
expect(selection.inputs.value()).toEqual([1]);
229+
});
178230
});
179231

180232
describe('#toggleAll', () => {
@@ -200,6 +252,16 @@ describe('List Selection', () => {
200252
selection.toggleAll();
201253
expect(selection.inputs.value()).toEqual([]);
202254
});
255+
256+
it('should ignore non-selectable items when determining if all items are selected', () => {
257+
const selection = getSelection({multi: signal(true)});
258+
const items = selection.inputs.items() as TestItem[];
259+
items[0].selectable.set(false);
260+
selection.toggleAll();
261+
expect(selection.inputs.value()).toEqual([1, 2, 3, 4]);
262+
selection.toggleAll();
263+
expect(selection.inputs.value()).toEqual([]);
264+
});
203265
});
204266

205267
describe('#selectOne', () => {
@@ -221,6 +283,14 @@ describe('List Selection', () => {
221283
expect(selection.inputs.value()).toEqual([]);
222284
});
223285

286+
it('should not select non-selectable items', () => {
287+
const selection = getSelection({multi: signal(true)});
288+
const items = selection.inputs.items() as TestItem[];
289+
items[0].selectable.set(false);
290+
selection.selectOne(); // []
291+
expect(selection.inputs.value()).toEqual([]);
292+
});
293+
224294
it('should do nothing to already selected items', () => {
225295
const selection = getSelection({multi: signal(true)});
226296
selection.selectOne(); // [0]
@@ -313,6 +383,16 @@ describe('List Selection', () => {
313383
expect(selection.inputs.value()).toEqual([0, 2]);
314384
});
315385

386+
it('should not select a non-selectable item', () => {
387+
const selection = getSelection({multi: signal(true)});
388+
const items = selection.inputs.items() as TestItem[];
389+
items[1].selectable.set(false);
390+
selection.select(); // [0]
391+
selection.inputs.focusManager.focus(items[2]);
392+
selection.selectRange(); // [0, 2]
393+
expect(selection.inputs.value()).toEqual([0, 2]);
394+
});
395+
316396
it('should not deselect a disabled item', () => {
317397
const selection = getSelection({multi: signal(true)});
318398
const items = selection.inputs.items() as TestItem[];
@@ -331,6 +411,21 @@ describe('List Selection', () => {
331411
selection.selectRange(); // [0, 1]
332412
expect(selection.inputs.value()).toEqual([1, 0]);
333413
});
414+
415+
it('should not deselect a non-selectable item', () => {
416+
const selection = getSelection({multi: signal(true)});
417+
const items = selection.inputs.items() as TestItem[];
418+
selection.select(items[1]); // [1]
419+
items[1].selectable.set(false);
420+
selection.select(); // [0, 1]
421+
expect(selection.inputs.value()).toEqual([1, 0]);
422+
selection.inputs.focusManager.focus(items[2]);
423+
selection.selectRange(); // [0, 1, 2]
424+
expect(selection.inputs.value()).toEqual([1, 0, 2]);
425+
selection.inputs.focusManager.focus(items[0]);
426+
selection.selectRange(); // [0, 1]
427+
expect(selection.inputs.value()).toEqual([1, 0]);
428+
});
334429
});
335430

336431
describe('#beginRangeSelection', () => {

0 commit comments

Comments
 (0)