diff --git a/src/aria/tree/tree.spec.ts b/src/aria/tree/tree.spec.ts index 94740745b4fc..f19eb29f433b 100644 --- a/src/aria/tree/tree.spec.ts +++ b/src/aria/tree/tree.spec.ts @@ -114,6 +114,7 @@ describe('Tree', () => { if (config.label !== undefined) node.label = config.label; if (config.children !== undefined) node.children = config.children; if (config.disabled !== undefined) node.disabled = config.disabled; + if (config.selectable !== undefined) node.selectable = config.selectable; updateTree({nodes: newNodes}); return; } @@ -309,6 +310,16 @@ describe('Tree', () => { expect(appleItem.getAttribute('aria-current')).toBe('location'); }); + it('should not set aria-current when not selectable', () => { + expandAll(); + updateTree({nav: true, value: ['apple']}); + const appleItem = getTreeItemElementByValue('apple')!; + expect(appleItem.getAttribute('aria-current')).toBe('page'); + + updateTreeItemByValue('apple', {selectable: false}); + expect(appleItem.hasAttribute('aria-current')).toBe(false); + }); + it('should not set aria-selected when nav="true"', () => { expandAll(); @@ -319,6 +330,16 @@ describe('Tree', () => { updateTree({nav: false}); expect(appleItem.getAttribute('aria-selected')).toBe('true'); }); + + it('should not set aria-selected when not selectable', () => { + expandAll(); + updateTree({value: ['apple']}); + const appleItem = getTreeItemElementByValue('apple')!; + expect(appleItem.getAttribute('aria-selected')).toBe('true'); + + updateTreeItemByValue('apple', {selectable: false}); + expect(appleItem.hasAttribute('aria-selected')).toBe(false); + }); }); describe('roving focus mode (focusMode="roving")', () => { @@ -492,6 +513,18 @@ describe('Tree', () => { click(appleEl); expect(treeInstance.value()).toEqual(['banana']); }); + + describe('selectable=false', () => { + it('should not select an item on click', () => { + updateTree({value: ['banana']}); + updateTreeItemByValue('apple', {selectable: false}); + const appleEl = getTreeItemElementByValue('apple')!; + + click(appleEl); + expect(treeInstance.value()).not.toContain('apple'); + expect(treeInstance.value()).toContain('banana'); + }); + }); }); describe('selectionMode="follow"', () => { @@ -560,6 +593,39 @@ describe('Tree', () => { 'broccoli', ]); }); + + describe('selectable=false', () => { + it('should not select a range with shift+click if an item is not selectable', () => { + updateTreeItemByValue('banana', {selectable: false}); + const appleEl = getTreeItemElementByValue('apple')!; + const berriesEl = getTreeItemElementByValue('berries')!; + + click(appleEl); + shiftClick(berriesEl); + + expect(treeInstance.value()).not.toContain('banana'); + expect(treeInstance.value()).toContain('apple'); + expect(treeInstance.value()).toContain('berries'); + }); + + it('should not toggle selection of an item on simple click', () => { + updateTreeItemByValue('apple', {selectable: false}); + const appleEl = getTreeItemElementByValue('apple')!; + + click(appleEl); + expect(treeInstance.value()).not.toContain('apple'); + }); + + it('should not add to selection with ctrl+click', () => { + updateTree({value: ['banana']}); + updateTreeItemByValue('apple', {selectable: false}); + const appleEl = getTreeItemElementByValue('apple')!; + + ctrlClick(appleEl); + expect(treeInstance.value()).not.toContain('apple'); + expect(treeInstance.value()).toContain('banana'); + }); + }); }); }); }); @@ -607,6 +673,20 @@ describe('Tree', () => { enter(); expect(treeInstance.value()).toEqual(['grains']); }); + + describe('selectable=false', () => { + it('should not select the focused item with Enter', () => { + updateTreeItemByValue('fruits', {selectable: false}); + enter(); + expect(treeInstance.value()).toEqual([]); + }); + + it('should not select the focused item with Space', () => { + updateTreeItemByValue('fruits', {selectable: false}); + space(); + expect(treeInstance.value()).toEqual([]); + }); + }); }); describe('selectionMode="follow"', () => { @@ -737,6 +817,35 @@ describe('Tree', () => { up({ctrlKey: true}); expect(treeInstance.value()).toEqual(['fruits']); }); + + describe('selectable=false', () => { + it('should not toggle selection of the focused item with Space', () => { + updateTreeItemByValue('fruits', {selectable: false}); + space(); + expect(treeInstance.value()).toEqual([]); + }); + + it('should not extend selection with Shift+ArrowDown', () => { + updateTreeItemByValue('vegetables', {selectable: false}); + shift(); + down({shiftKey: true}); + down({shiftKey: true}); + expect(treeInstance.value()).not.toContain('vegetables'); + expect(treeInstance.value().sort()).toEqual(['fruits', 'grains']); + }); + + it('Ctrl+A should not select non-selectable items', () => { + expandAll(); + updateTreeItemByValue('apple', {selectable: false}); + updateTreeItemByValue('carrot', {selectable: false}); + keydown('A', {ctrlKey: true}); + const value = treeInstance.value(); + expect(value).not.toContain('apple'); + expect(value).not.toContain('carrot'); + expect(value).toContain('banana'); + expect(value).toContain('broccoli'); + }); + }); }); describe('selectionMode="follow"', () => { @@ -864,6 +973,37 @@ describe('Tree', () => { expect(getFocusedTreeItemValue()).toBe('vegetables'); }); + describe('selectable=false', () => { + it('should not select an item on ArrowDown', () => { + updateTreeItemByValue('vegetables', {selectable: false}); + down(); + expect(treeInstance.value()).not.toContain('vegetables'); + expect(treeInstance.value()).toEqual([]); + }); + + it('should not toggle selection of the focused item on Ctrl+Space', () => { + updateTreeItemByValue('fruits', {selectable: false}); + space({ctrlKey: true}); + expect(treeInstance.value()).toEqual([]); + }); + + it('should not extend selection with Shift+ArrowDown', () => { + updateTreeItemByValue('vegetables', {selectable: false}); + shift(); + down({shiftKey: true}); + down({shiftKey: true}); + expect(treeInstance.value()).not.toContain('vegetables'); + expect(treeInstance.value().sort()).toEqual(['fruits', 'grains']); + }); + + it('typeahead should not select the focused item', () => { + updateTreeItemByValue('vegetables', {selectable: false}); + type('v'); + expect(getFocusedTreeItemValue()).toBe('vegetables'); + expect(treeInstance.value()).not.toContain('vegetables'); + }); + }); + it('should not select disabled items during Shift+ArrowKey navigation even if skipDisabled is false', () => { right(); // Expands fruits updateTreeItemByValue('banana', {disabled: true}); @@ -1302,6 +1442,7 @@ interface TestTreeNode { value: V; label: string; disabled?: boolean; + selectable?: boolean; children?: TestTreeNode[]; } @@ -1332,6 +1473,7 @@ interface TestTreeNode { [value]="node.value" [label]="node.label" [disabled]="!!node.disabled" + [selectable]="node.selectable ?? true" [parent]="parent" [attr.data-value]="node.value" #treeItem="ngTreeItem" diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index ff6e27ced87a..045dc951493f 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -245,6 +245,9 @@ export class TreeItem extends DeferredContentAware implements OnInit, OnDestr /** Whether the tree item is disabled. */ readonly disabled = input(false, {transform: booleanAttribute}); + /** Whether the tree item is selectable. */ + readonly selectable = input(true); + /** Optional label for typeahead. Defaults to the element's textContent. */ readonly label = input(); diff --git a/src/aria/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/aria/ui-patterns/behaviors/list-selection/list-selection.spec.ts index 9c36aad62a9e..ef54329c7131 100644 --- a/src/aria/ui-patterns/behaviors/list-selection/list-selection.spec.ts +++ b/src/aria/ui-patterns/behaviors/list-selection/list-selection.spec.ts @@ -13,6 +13,7 @@ import {ListFocus} from '../list-focus/list-focus'; type TestItem = ListSelectionItem & { disabled: WritableSignal; + selectable: WritableSignal; }; type TestInputs = Partial> & { numItems?: number; @@ -40,6 +41,7 @@ function getItems(length: number): Signal { value: signal(i), id: signal(`${i}`), disabled: signal(false), + selectable: signal(true), element: signal({focus: () => {}} as HTMLElement), index: signal(i), }; @@ -83,6 +85,14 @@ describe('List Selection', () => { expect(selection.inputs.value()).toEqual([]); }); + it('should not select non-selectable items', () => { + const selection = getSelection(); + const items = selection.inputs.items() as TestItem[]; + items[0].selectable.set(false); + selection.select(); // [] + expect(selection.inputs.value()).toEqual([]); + }); + it('should do nothing to already selected items', () => { const selection = getSelection(); selection.select(); // [0] @@ -106,6 +116,15 @@ describe('List Selection', () => { selection.deselect(); // [0] expect(selection.inputs.value()).toEqual([0]); }); + + it('should not deselect non-selectable items', () => { + const selection = getSelection(); + const items = selection.inputs.items() as TestItem[]; + selection.select(); // [0] + items[0].selectable.set(false); + selection.deselect(); // [0] + expect(selection.inputs.value()).toEqual([0]); + }); }); describe('#toggle', () => { @@ -121,6 +140,14 @@ describe('List Selection', () => { selection.toggle(); // [] expect(selection.inputs.value().length).toBe(0); }); + + it('should not toggle non-selectable items', () => { + const selection = getSelection(); + const items = selection.inputs.items() as TestItem[]; + items[0].selectable.set(false); + selection.toggle(); // [] + expect(selection.inputs.value()).toEqual([]); + }); }); describe('#toggleOne', () => { @@ -145,6 +172,14 @@ describe('List Selection', () => { selection.toggleOne(); // [1] expect(selection.inputs.value()).toEqual([1]); }); + + it('should not toggle non-selectable items', () => { + const selection = getSelection(); + const items = selection.inputs.items() as TestItem[]; + items[0].selectable.set(false); + selection.toggleOne(); // [] + expect(selection.inputs.value()).toEqual([]); + }); }); describe('#selectAll', () => { @@ -159,6 +194,14 @@ describe('List Selection', () => { selection.selectAll(); expect(selection.inputs.value()).toEqual([]); }); + + it('should not select non-selectable items', () => { + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + items[1].selectable.set(false); + selection.selectAll(); + expect(selection.inputs.value()).toEqual([0, 2, 3, 4]); + }); }); describe('#deselectAll', () => { @@ -175,6 +218,15 @@ describe('List Selection', () => { selection.deselectAll(); expect(selection.inputs.value().length).toBe(0); }); + + it('should not deselect non-selectable items', () => { + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + selection.selectAll(); // [0, 1, 2, 3, 4] + items[1].selectable.set(false); + selection.deselectAll(); // [1] + expect(selection.inputs.value()).toEqual([1]); + }); }); describe('#toggleAll', () => { @@ -200,6 +252,16 @@ describe('List Selection', () => { selection.toggleAll(); expect(selection.inputs.value()).toEqual([]); }); + + it('should ignore non-selectable items when determining if all items are selected', () => { + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + items[0].selectable.set(false); + selection.toggleAll(); + expect(selection.inputs.value()).toEqual([1, 2, 3, 4]); + selection.toggleAll(); + expect(selection.inputs.value()).toEqual([]); + }); }); describe('#selectOne', () => { @@ -221,6 +283,14 @@ describe('List Selection', () => { expect(selection.inputs.value()).toEqual([]); }); + it('should not select non-selectable items', () => { + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + items[0].selectable.set(false); + selection.selectOne(); // [] + expect(selection.inputs.value()).toEqual([]); + }); + it('should do nothing to already selected items', () => { const selection = getSelection({multi: signal(true)}); selection.selectOne(); // [0] @@ -313,6 +383,16 @@ describe('List Selection', () => { expect(selection.inputs.value()).toEqual([0, 2]); }); + it('should not select a non-selectable item', () => { + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + items[1].selectable.set(false); + selection.select(); // [0] + selection.inputs.focusManager.focus(items[2]); + selection.selectRange(); // [0, 2] + expect(selection.inputs.value()).toEqual([0, 2]); + }); + it('should not deselect a disabled item', () => { const selection = getSelection({multi: signal(true)}); const items = selection.inputs.items() as TestItem[]; @@ -331,6 +411,21 @@ describe('List Selection', () => { selection.selectRange(); // [0, 1] expect(selection.inputs.value()).toEqual([1, 0]); }); + + it('should not deselect a non-selectable item', () => { + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + selection.select(items[1]); // [1] + items[1].selectable.set(false); + selection.select(); // [0, 1] + expect(selection.inputs.value()).toEqual([1, 0]); + selection.inputs.focusManager.focus(items[2]); + selection.selectRange(); // [0, 1, 2] + expect(selection.inputs.value()).toEqual([1, 0, 2]); + selection.inputs.focusManager.focus(items[0]); + selection.selectRange(); // [0, 1] + expect(selection.inputs.value()).toEqual([1, 0]); + }); }); describe('#beginRangeSelection', () => { diff --git a/src/aria/ui-patterns/behaviors/list-selection/list-selection.ts b/src/aria/ui-patterns/behaviors/list-selection/list-selection.ts index 3342daf9f3f1..d687b9939e3d 100644 --- a/src/aria/ui-patterns/behaviors/list-selection/list-selection.ts +++ b/src/aria/ui-patterns/behaviors/list-selection/list-selection.ts @@ -14,6 +14,9 @@ import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focu export interface ListSelectionItem extends ListFocusItem { /** The value of the item. */ value: SignalLike; + + /** Whether the item is selectable. */ + selectable: SignalLike; } /** Represents the required inputs for a collection that contains selectable items. */ @@ -47,7 +50,12 @@ export class ListSelection, V> { select(item?: ListSelectionItem, opts = {anchor: true}) { item = item ?? (this.inputs.focusManager.inputs.activeItem() as ListSelectionItem); - if (!item || item.disabled() || this.inputs.value().includes(item.value())) { + if ( + !item || + item.disabled() || + !item.selectable() || + this.inputs.value().includes(item.value()) + ) { return; } @@ -66,7 +74,7 @@ export class ListSelection, V> { deselect(item?: T | null) { item = item ?? this.inputs.focusManager.inputs.activeItem(); - if (item && !item.disabled()) { + if (item && !item.disabled() && item.selectable()) { this.inputs.value.update(values => values.filter(value => value !== item.value())); } } @@ -131,7 +139,7 @@ export class ListSelection, V> { toggleAll() { const selectableValues = this.inputs .items() - .filter(i => !i.disabled()) + .filter(i => !i.disabled() && i.selectable()) .map(i => i.value()); selectableValues.every(i => this.inputs.value().includes(i)) @@ -142,7 +150,7 @@ export class ListSelection, V> { /** Sets the selection to only the current active item. */ selectOne() { const item = this.inputs.focusManager.inputs.activeItem(); - if (item && item.disabled()) { + if (item && (item.disabled() || !item.selectable())) { return; } diff --git a/src/aria/ui-patterns/behaviors/list/list.spec.ts b/src/aria/ui-patterns/behaviors/list/list.spec.ts index d9fdac5eaff8..82e7c45c8ba7 100644 --- a/src/aria/ui-patterns/behaviors/list/list.spec.ts +++ b/src/aria/ui-patterns/behaviors/list/list.spec.ts @@ -12,6 +12,7 @@ import {fakeAsync, tick} from '@angular/core/testing'; type TestItem = ListItem & { disabled: WritableSignal; + selectable: WritableSignal; searchTerm: WritableSignal; value: WritableSignal; }; @@ -44,6 +45,7 @@ describe('List Behavior', () => { id: signal(`item-${index}`), element: signal(document.createElement('div')), disabled: signal(false), + selectable: signal(true), searchTerm: signal(String(value)), index: signal(index), })); @@ -240,6 +242,12 @@ describe('List Behavior', () => { expect(list.inputs.value()).toEqual(['Apricot']); }); + it('should not select a non-selectable item when navigating with selectOne:true', () => { + items[1].selectable.set(false); + list.next({selectOne: true}); + expect(list.inputs.value()).toEqual([]); + }); + it('should toggle an item when navigating with toggle:true', () => { list.goto(items[1], {selectOne: true}); expect(list.inputs.value()).toEqual(['Apricot']); @@ -248,6 +256,12 @@ describe('List Behavior', () => { expect(list.inputs.value()).toEqual([]); }); + it('should not toggle a non-selectable item when navigating with toggle:true', () => { + items[1].selectable.set(false); + list.goto(items[1], {toggle: true}); + expect(list.inputs.value()).toEqual([]); + }); + it('should only allow one selected item', () => { list.next({selectOne: true}); expect(list.inputs.value()).toEqual(['Apricot']); @@ -279,6 +293,12 @@ describe('List Behavior', () => { expect(list.inputs.value()).toEqual(['Apricot']); }); + it('should not select a non-selectable item with toggle:true', () => { + items[1].selectable.set(false); + list.next({toggle: true}); + expect(list.inputs.value()).toEqual([]); + }); + it('should allow multiple selected items', () => { list.next({toggle: true}); list.next({toggle: true}); @@ -310,6 +330,13 @@ describe('List Behavior', () => { list.goto(items[3], {selectRange: true}); expect(list.inputs.value()).toEqual(['Apple', 'Banana', 'Blackberry']); }); + + it('should not select non-selectable items in a range', () => { + items[1].selectable.set(false); + list.anchor(0); + list.goto(items[3], {selectRange: true}); + expect(list.inputs.value()).toEqual(['Apple', 'Banana', 'Blackberry']); + }); }); }); @@ -347,5 +374,12 @@ describe('List Behavior', () => { list.search('b', {selectOne: true}); expect(list.inputs.value()).toEqual(['Banana']); }); + + it('should not select a non-selectable item via typeahead', () => { + const {list, items} = getDefaultPatterns({multi: signal(false)}); + items[2].selectable.set(false); // 'Banana' + list.search('b', {selectOne: true}); + expect(list.inputs.value()).toEqual([]); + }); }); }); diff --git a/src/aria/ui-patterns/combobox/combobox.spec.ts b/src/aria/ui-patterns/combobox/combobox.spec.ts index 8a67d3f0ce70..a1ec0971b8fc 100644 --- a/src/aria/ui-patterns/combobox/combobox.spec.ts +++ b/src/aria/ui-patterns/combobox/combobox.spec.ts @@ -191,6 +191,7 @@ function getTreePattern( value: signal(node.value), id: signal('tree-item-' + tree.allItems().length), disabled: signal(false), + selectable: signal(true), searchTerm: signal(node.value), tree: signal(tree), parent: signal(parent), diff --git a/src/aria/ui-patterns/listbox/option.ts b/src/aria/ui-patterns/listbox/option.ts index 77d65184c1cc..8ea16149d004 100644 --- a/src/aria/ui-patterns/listbox/option.ts +++ b/src/aria/ui-patterns/listbox/option.ts @@ -20,7 +20,7 @@ interface ListboxPattern { } /** Represents the required inputs for an option in a listbox. */ -export interface OptionInputs extends Omit, 'index'> { +export interface OptionInputs extends Omit, 'index' | 'selectable'> { listbox: SignalLike | undefined>; } @@ -41,6 +41,9 @@ export class OptionPattern { /** Whether the option is selected. */ selected = computed(() => this.listbox()?.inputs.value().includes(this.value())); + /** Whether the option is selectable. */ + selectable = () => true; + /** Whether the option is disabled. */ disabled: SignalLike; diff --git a/src/aria/ui-patterns/radio-group/radio-button.ts b/src/aria/ui-patterns/radio-group/radio-button.ts index a2f49050f836..5c4258301057 100644 --- a/src/aria/ui-patterns/radio-group/radio-button.ts +++ b/src/aria/ui-patterns/radio-group/radio-button.ts @@ -12,7 +12,8 @@ import {ListItem} from '../behaviors/list/list'; import type {RadioGroupPattern} from './radio-group'; /** Represents the required inputs for a radio button in a radio group. */ -export interface RadioButtonInputs extends Omit, 'searchTerm' | 'index'> { +export interface RadioButtonInputs + extends Omit, 'searchTerm' | 'index' | 'selectable'> { /** A reference to the parent radio group. */ group: SignalLike | undefined>; } @@ -38,6 +39,9 @@ export class RadioButtonPattern { () => !!this.group()?.listBehavior.inputs.value().includes(this.value()), ); + /** Whether the radio button is selectable. */ + readonly selectable = () => true; + /** Whether the radio button is disabled. */ readonly disabled: SignalLike; diff --git a/src/aria/ui-patterns/tabs/tabs.ts b/src/aria/ui-patterns/tabs/tabs.ts index b605e8c137b7..95f593009343 100644 --- a/src/aria/ui-patterns/tabs/tabs.ts +++ b/src/aria/ui-patterns/tabs/tabs.ts @@ -20,7 +20,7 @@ import {List, ListInputs, ListItem} from '../behaviors/list/list'; /** The required inputs to tabs. */ export interface TabInputs - extends Omit, 'searchTerm' | 'index'>, + extends Omit, 'searchTerm' | 'index' | 'selectable'>, Omit { /** The parent tablist that controls the tab. */ tablist: SignalLike; @@ -49,6 +49,9 @@ export class TabPattern { /** The html element that should receive focus. */ readonly element: SignalLike; + /** Whether the tab is selectable. */ + readonly selectable = () => true; + /** The text used by the typeahead search. */ readonly searchTerm = () => ''; // Unused because tabs do not support typeahead. diff --git a/src/aria/ui-patterns/toolbar/toolbar-widget-group.ts b/src/aria/ui-patterns/toolbar/toolbar-widget-group.ts index 60e52c378ff7..297dafa1a0a7 100644 --- a/src/aria/ui-patterns/toolbar/toolbar-widget-group.ts +++ b/src/aria/ui-patterns/toolbar/toolbar-widget-group.ts @@ -46,7 +46,7 @@ export interface ToolbarWidgetGroupControls { /** Represents the required inputs for a toolbar widget group. */ export interface ToolbarWidgetGroupInputs - extends Omit, 'searchTerm' | 'value' | 'index'> { + extends Omit, 'searchTerm' | 'value' | 'index' | 'selectable'> { /** A reference to the parent toolbar. */ toolbar: SignalLike | undefined>; @@ -74,6 +74,9 @@ export class ToolbarWidgetGroupPattern implements ListItem { /** The value associated with the widget. */ readonly value = () => '' as V; // Unused because toolbar does not support selection. + /** Whether the widget is selectable. */ + readonly selectable = () => true; // Unused because toolbar does not support selection. + /** The position of the widget within the toolbar. */ readonly index = computed(() => this.toolbar()?.inputs.items().indexOf(this) ?? -1); diff --git a/src/aria/ui-patterns/toolbar/toolbar-widget.ts b/src/aria/ui-patterns/toolbar/toolbar-widget.ts index 69d582fad39b..e86247481968 100644 --- a/src/aria/ui-patterns/toolbar/toolbar-widget.ts +++ b/src/aria/ui-patterns/toolbar/toolbar-widget.ts @@ -13,7 +13,7 @@ import type {ToolbarPattern} from './toolbar'; /** Represents the required inputs for a toolbar widget in a toolbar. */ export interface ToolbarWidgetInputs - extends Omit, 'searchTerm' | 'value' | 'index'> { + extends Omit, 'searchTerm' | 'value' | 'index' | 'selectable'> { /** A reference to the parent toolbar. */ toolbar: SignalLike>; } @@ -40,6 +40,9 @@ export class ToolbarWidgetPattern implements ListItem { /** The value associated with the widget. */ readonly value = () => '' as V; // Unused because toolbar does not support selection. + /** Whether the widget is selectable. */ + readonly selectable = () => true; // Unused because toolbar does not support selection. + /** The position of the widget within the toolbar. */ readonly index = computed(() => this.toolbar().inputs.items().indexOf(this) ?? -1); diff --git a/src/aria/ui-patterns/tree/combobox-tree.ts b/src/aria/ui-patterns/tree/combobox-tree.ts index 77aea9570e29..70fed29206c3 100644 --- a/src/aria/ui-patterns/tree/combobox-tree.ts +++ b/src/aria/ui-patterns/tree/combobox-tree.ts @@ -72,6 +72,7 @@ export class ComboboxTreePattern /** Unfocuses the currently focused item in the tree. */ unfocus = () => this.listBehavior.unfocus(); + // TODO: handle non-selectable parent nodes. /** Selects the specified item in the tree or the current active item if not provided. */ select = (item?: TreeItemPattern) => this.listBehavior.select(item); diff --git a/src/aria/ui-patterns/tree/tree.spec.ts b/src/aria/ui-patterns/tree/tree.spec.ts index 4a7bc3708a5f..844881aa6645 100644 --- a/src/aria/ui-patterns/tree/tree.spec.ts +++ b/src/aria/ui-patterns/tree/tree.spec.ts @@ -57,6 +57,7 @@ interface TestTreeItem { value: V; children?: TestTreeItem[]; disabled: boolean; + selectable: boolean; } describe('Tree Pattern', () => { @@ -84,6 +85,7 @@ describe('Tree Pattern', () => { value: signal(node.value), element: signal(element), disabled: signal(node.disabled), + selectable: signal(node.selectable), searchTerm: signal(String(node.value)), parent: signal(parent), hasChildren: signal((node.children ?? []).length > 0), @@ -118,16 +120,18 @@ describe('Tree Pattern', () => { { value: 'Item 0', children: [ - {value: 'Item 0-0', disabled: false}, - {value: 'Item 0-1', disabled: false}, + {value: 'Item 0-0', disabled: false, selectable: true}, + {value: 'Item 0-1', disabled: false, selectable: true}, ], disabled: false, + selectable: true, }, - {value: 'Item 1', disabled: false}, + {value: 'Item 1', disabled: false, selectable: true}, { value: 'Item 2', - children: [{value: 'Item 2-0', disabled: false}], + children: [{value: 'Item 2-0', disabled: false, selectable: true}], disabled: false, + selectable: true, }, ]; @@ -229,6 +233,15 @@ describe('Tree Pattern', () => { expect(item0.current()).toBeUndefined(); expect(item1.current()).toBe('step'); }); + + it('should have undefined current state when non-selectable', () => { + const {allItems, itemPatternInputsMap} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + treeInputs.value.set(['Item 0']); + expect(item0.current()).toBe('page'); + itemPatternInputsMap.get(item0.id())!.selectable.set(false); + expect(item0.current()).toBeUndefined(); + }); }); }); @@ -374,9 +387,9 @@ describe('Tree Pattern', () => { it('should skip disabled items when skipDisabled is true', () => { treeInputs.skipDisabled.set(true); const localTreeExample: TestTreeItem[] = [ - {value: 'Item A', disabled: false}, - {value: 'Item B', disabled: true}, - {value: 'Item C', disabled: false}, + {value: 'Item A', disabled: false, selectable: true}, + {value: 'Item B', disabled: true, selectable: true}, + {value: 'Item C', disabled: false, selectable: true}, ]; const {tree, allItems} = createTree(localTreeExample, treeInputs); const itemA = getItemByValue(allItems(), 'Item A'); @@ -391,9 +404,9 @@ describe('Tree Pattern', () => { it('should not skip disabled items when skipDisabled is false', () => { treeInputs.skipDisabled.set(false); const localTreeExample: TestTreeItem[] = [ - {value: 'Item A', disabled: false}, - {value: 'Item B', disabled: true}, - {value: 'Item C', disabled: false}, + {value: 'Item A', disabled: false, selectable: true}, + {value: 'Item B', disabled: true, selectable: true}, + {value: 'Item C', disabled: false, selectable: true}, ]; const {tree, allItems} = createTree(localTreeExample, treeInputs); const itemA = getItemByValue(allItems(), 'Item A'); @@ -455,6 +468,14 @@ describe('Tree Pattern', () => { expect(item1.selected()).toBe(true); }); + it('should have undefined selected state when non-selectable', () => { + const {allItems, itemPatternInputsMap} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + treeInputs.value.set(['Item 0']); + itemPatternInputsMap.get(item0.id())!.selectable.set(false); + expect(item0.selected()).toBeUndefined(); + }); + it('should select an item on navigation', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); @@ -683,9 +704,9 @@ describe('Tree Pattern', () => { it('should not select disabled items on Shift + ArrowUp / ArrowDown', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false}, - {value: 'B', disabled: true}, - {value: 'C', disabled: false}, + {value: 'A', disabled: false, selectable: true}, + {value: 'B', disabled: true, selectable: true}, + {value: 'C', disabled: false, selectable: true}, ]; treeInputs.skipDisabled.set(false); const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -698,6 +719,22 @@ describe('Tree Pattern', () => { expect(tree.inputs.value()).toEqual(['A', 'C']); }); + it('should not select non-selectable items on Shift + ArrowUp / ArrowDown', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false, selectable: true}, + {value: 'B', disabled: false, selectable: false}, + {value: 'C', disabled: false, selectable: true}, + ]; + const {tree, allItems} = createTree(localTreeData, treeInputs); + const itemA = getItemByValue(allItems(), 'A'); + + tree.listBehavior.goto(itemA); + tree.onKeydown(shift()); + tree.onKeydown(down({shift: true})); + tree.onKeydown(down({shift: true})); + expect(tree.inputs.value()).toEqual(['A', 'C']); + }); + it('should select all visible items on Ctrl + A', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); @@ -841,9 +878,9 @@ describe('Tree Pattern', () => { it('should not select disabled items on navigation', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false}, - {value: 'B', disabled: true}, - {value: 'C', disabled: false}, + {value: 'A', disabled: false, selectable: true}, + {value: 'B', disabled: true, selectable: true}, + {value: 'C', disabled: false, selectable: true}, ]; treeInputs.skipDisabled.set(true); const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -854,6 +891,21 @@ describe('Tree Pattern', () => { expect(tree.inputs.value()).toEqual(['C']); }); + it('should not select non-selectable items on navigation', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false, selectable: true}, + {value: 'B', disabled: false, selectable: false}, + {value: 'C', disabled: false, selectable: true}, + ]; + const {tree, allItems} = createTree(localTreeData, treeInputs); + treeInputs.value.set(['A']); + tree.listBehavior.goto(getItemByValue(allItems(), 'A')); + + tree.onKeydown(down()); + tree.onKeydown(down()); + expect(tree.inputs.value()).toEqual(['C']); + }); + it('should deselect all except the focused item on Ctrl + A if all are selected', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); @@ -1090,7 +1142,9 @@ describe('Tree Pattern', () => { }); it('should not select disabled items on click', () => { - const localTreeData: TestTreeItem[] = [{value: 'A', disabled: true}]; + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: true, selectable: true}, + ]; const {tree, allItems} = createTree(localTreeData, treeInputs); const itemA = getItemByValue(allItems(), 'A'); @@ -1098,6 +1152,16 @@ describe('Tree Pattern', () => { expect(tree.inputs.value()).toEqual([]); expect(tree.activeItem()).toBe(itemA); }); + + it('should not select non-selectable items on click', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false, selectable: false}, + ]; + const {tree, allItems} = createTree(localTreeData, treeInputs); + const itemA = getItemByValue(allItems(), 'A'); + tree.onPointerdown(createClickEvent(itemA.element())); + expect(tree.inputs.value()).toEqual([]); + }); }); }); @@ -1358,8 +1422,8 @@ describe('Tree Pattern', () => { it('should set activeIndex to the first visible focusable item if no selection', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false}, - {value: 'B', disabled: false}, + {value: 'A', disabled: false, selectable: true}, + {value: 'B', disabled: false, selectable: true}, ]; const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -1369,8 +1433,8 @@ describe('Tree Pattern', () => { it('should set activeIndex to the first visible focusable disabled item if skipDisabled is false and no selection', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: true}, - {value: 'B', disabled: false}, + {value: 'A', disabled: true, selectable: true}, + {value: 'B', disabled: false, selectable: true}, ]; treeInputs.skipDisabled.set(false); const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -1381,9 +1445,9 @@ describe('Tree Pattern', () => { it('should set activeIndex to the first selected visible focusable item', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false}, - {value: 'B', disabled: false}, - {value: 'C', disabled: false}, + {value: 'A', disabled: false, selectable: true}, + {value: 'B', disabled: false, selectable: true}, + {value: 'C', disabled: false, selectable: true}, ]; treeInputs.value.set(['B']); const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -1394,9 +1458,9 @@ describe('Tree Pattern', () => { it('should prioritize the first selected item in visible order', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false}, - {value: 'B', disabled: false}, - {value: 'C', disabled: false}, + {value: 'A', disabled: false, selectable: true}, + {value: 'B', disabled: false, selectable: true}, + {value: 'C', disabled: false, selectable: true}, ]; treeInputs.value.set(['C', 'A']); const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -1407,9 +1471,9 @@ describe('Tree Pattern', () => { it('should skip a selected disabled item if skipDisabled is true', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false}, - {value: 'B', disabled: true}, - {value: 'C', disabled: false}, + {value: 'A', disabled: false, selectable: true}, + {value: 'B', disabled: true, selectable: true}, + {value: 'C', disabled: false, selectable: true}, ]; treeInputs.value.set(['B']); treeInputs.skipDisabled.set(true); @@ -1421,9 +1485,9 @@ describe('Tree Pattern', () => { it('should select a selected disabled item if skipDisabled is false', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false}, - {value: 'B', disabled: true}, - {value: 'C', disabled: false}, + {value: 'A', disabled: false, selectable: true}, + {value: 'B', disabled: true, selectable: true}, + {value: 'C', disabled: false, selectable: true}, ]; treeInputs.value.set(['B']); treeInputs.skipDisabled.set(false); diff --git a/src/aria/ui-patterns/tree/tree.ts b/src/aria/ui-patterns/tree/tree.ts index 0d98f13111ec..0f17ff45b7df 100644 --- a/src/aria/ui-patterns/tree/tree.ts +++ b/src/aria/ui-patterns/tree/tree.ts @@ -27,11 +27,34 @@ export interface TreeItemInputs extends Omit, 'index'> { tree: SignalLike>; } -export interface TreeItemPattern extends TreeItemInputs {} /** * Represents an item in a Tree. */ -export class TreeItemPattern implements ExpansionItem { +export class TreeItemPattern implements ListItem, ExpansionItem { + /** A unique identifier for this item. */ + readonly id: SignalLike; + + /** The value of this item. */ + readonly value: SignalLike; + + /** A reference to the item element. */ + readonly element: SignalLike; + + /** Whether the item is disabled. */ + readonly disabled: SignalLike; + + /** The text used by the typeahead search. */ + readonly searchTerm: SignalLike; + + /** The tree pattern this item belongs to. */ + readonly tree: SignalLike>; + + /** The parent item. */ + readonly parent: SignalLike | TreePattern>; + + /** The children items. */ + readonly children: SignalLike[]>; + /** The position of this item among its siblings. */ readonly index = computed(() => this.tree().visibleItems().indexOf(this)); @@ -47,6 +70,9 @@ export class TreeItemPattern implements ExpansionItem { /** Whether the item is expandable. It's expandable if children item exist. */ readonly expandable: SignalLike; + /** Whether the item is selectable. */ + readonly selectable: SignalLike; + /** The level of the current item in a tree. */ readonly level: SignalLike = computed(() => this.parent().level() + 1); @@ -69,45 +95,27 @@ export class TreeItemPattern implements ExpansionItem { readonly tabindex = computed(() => this.tree().listBehavior.getItemTabindex(this)); /** Whether the item is selected. */ - readonly selected = computed(() => { + readonly selected: SignalLike = computed(() => { if (this.tree().nav()) { return undefined; } + if (!this.selectable()) { + return undefined; + } return this.tree().value().includes(this.value()); }); /** The current type of this item. */ - readonly current = computed(() => { + readonly current: SignalLike = computed(() => { if (!this.tree().nav()) { return undefined; } + if (!this.selectable()) { + return undefined; + } return this.tree().value().includes(this.value()) ? this.tree().currentType() : undefined; }); - /** A unique identifier for this item. */ - id: SignalLike; - - /** The value of this item. */ - value: SignalLike; - - /** A reference to the item element. */ - element: SignalLike; - - /** Whether the item is disabled. */ - disabled: SignalLike; - - /** The text used by the typeahead search. */ - searchTerm: SignalLike; - - /** The tree pattern this item belongs to. */ - tree: SignalLike>; - - /** The parent item. */ - parent: SignalLike | TreePattern>; - - /** The children items. */ - children: SignalLike[]>; - constructor(readonly inputs: TreeItemInputs) { this.id = inputs.id; this.value = inputs.value; @@ -119,6 +127,7 @@ export class TreeItemPattern implements ExpansionItem { this.parent = inputs.parent; this.children = inputs.children; this.expandable = inputs.hasChildren; + this.selectable = inputs.selectable; this.expansion = new ExpansionControl({ ...inputs, expandable: this.expandable, @@ -175,7 +184,7 @@ export class TreePattern { readonly expanded = () => true; /** The tabindex of the tree. */ - tabindex: SignalLike<-1 | 0> = computed(() => this.listBehavior.tabindex()); + readonly tabindex: SignalLike<-1 | 0> = computed(() => this.listBehavior.tabindex()); /** The id of the current active item. */ readonly activedescendant = computed(() => this.listBehavior.activedescendant()); diff --git a/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.html b/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.html index 70ea217323eb..7a308eabebc9 100644 --- a/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.html +++ b/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.html @@ -53,6 +53,7 @@ [value]="node.value" [label]="node.name" [disabled]="node.disabled" + [selectable]="!nav.value || !node.children" #treeItem="ngTreeItem" class="example-tree-item example-selectable example-stateful" > diff --git a/src/components-examples/aria/tree/tree-nav/tree-nav-example.html b/src/components-examples/aria/tree/tree-nav/tree-nav-example.html index d965bad7bf92..5bfc22c5f30e 100644 --- a/src/components-examples/aria/tree/tree-nav/tree-nav-example.html +++ b/src/components-examples/aria/tree/tree-nav/tree-nav-example.html @@ -13,6 +13,7 @@ [value]="node.value" [label]="node.name" [disabled]="node.disabled" + [selectable]="!node.children" #treeItem="ngTreeItem" class="example-tree-item example-selectable example-stateful" href="#{{ node.name }}"