From bad7b62c1c750ff4d4021e2ca09d5886cdd33cdd Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Sun, 2 Nov 2025 22:12:24 -0800 Subject: [PATCH 1/3] refactor(multiple): default softDisabled to true Updates the default value of the input to across all ARIA components. This allows disabled items to receive focus by default, improving keyboard accessibility. - Grid focus coordinates behavior has also been updated to correctly handle focus when is enabled. --- src/aria/accordion/accordion.spec.ts | 2 +- src/aria/accordion/accordion.ts | 2 +- src/aria/grid/grid.ts | 2 +- src/aria/listbox/listbox.spec.ts | 39 ++++- src/aria/listbox/listbox.ts | 2 +- src/aria/private/accordion/accordion.spec.ts | 2 +- src/aria/private/behaviors/grid/grid-focus.ts | 2 +- .../behaviors/grid/grid-navigation.spec.ts | 159 ++++++++++++++++-- .../behaviors/grid/grid-selection.spec.ts | 2 +- src/aria/private/behaviors/grid/grid.spec.ts | 14 +- src/aria/private/behaviors/list/list.spec.ts | 10 +- src/aria/private/combobox/combobox.spec.ts | 4 +- src/aria/private/listbox/listbox.spec.ts | 2 +- src/aria/private/menu/menu.spec.ts | 4 +- src/aria/private/tabs/tabs.spec.ts | 7 +- src/aria/private/toolbar/toolbar.spec.ts | 12 +- src/aria/private/tree/tree.spec.ts | 26 +-- src/aria/tabs/tabs.spec.ts | 27 ++- src/aria/tabs/tabs.ts | 2 +- src/aria/toolbar/toolbar.spec.ts | 16 +- src/aria/tree/tree.spec.ts | 8 +- src/aria/tree/tree.ts | 2 +- .../tabs-configurable-example.ts | 2 +- .../tree-configurable-example.ts | 2 +- 24 files changed, 266 insertions(+), 84 deletions(-) diff --git a/src/aria/accordion/accordion.spec.ts b/src/aria/accordion/accordion.spec.ts index ddee39132f8b..38635ca4a004 100644 --- a/src/aria/accordion/accordion.spec.ts +++ b/src/aria/accordion/accordion.spec.ts @@ -419,7 +419,7 @@ class AccordionGroupExample { value = model([]); multiExpandable = signal(false); disabledGroup = signal(false); - softDisabled = signal(false); + softDisabled = signal(true); wrap = signal(false); disableItem(itemValue: string, disabled: boolean) { diff --git a/src/aria/accordion/accordion.ts b/src/aria/accordion/accordion.ts index b98e26cd0a82..b6aab4cb44a3 100644 --- a/src/aria/accordion/accordion.ts +++ b/src/aria/accordion/accordion.ts @@ -171,7 +171,7 @@ export class AccordionGroup { value = model([]); /** Whether to allow disabled items to receive focus. */ - softDisabled = input(false, {transform: booleanAttribute}); + softDisabled = input(true, {transform: booleanAttribute}); /** Whether keyboard navigation should wrap around from the last item to the first, and vice-versa. */ wrap = input(false, {transform: booleanAttribute}); diff --git a/src/aria/grid/grid.ts b/src/aria/grid/grid.ts index 91f5148ff71a..e9c872b4d19e 100644 --- a/src/aria/grid/grid.ts +++ b/src/aria/grid/grid.ts @@ -66,7 +66,7 @@ export class Grid { readonly disabled = input(false, {transform: booleanAttribute}); /** Whether to allow disabled items to receive focus. */ - readonly softDisabled = input(false, {transform: booleanAttribute}); + readonly softDisabled = input(true, {transform: booleanAttribute}); /** The focus strategy used by the grid. */ readonly focusMode = input<'roving' | 'activedescendant'>('roving'); diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts index 60de0e9c816f..24bd0188a2ea 100644 --- a/src/aria/listbox/listbox.spec.ts +++ b/src/aria/listbox/listbox.spec.ts @@ -218,8 +218,23 @@ describe('Listbox', () => { expect(optionElements[4].getAttribute('tabindex')).toBe('-1'); }); - it('should set initial focus (tabindex="0") on the first non-disabled option if selected option is disabled', () => { - setupListbox({focusMode: 'roving', value: [1], disabledOptions: [1]}); + it('should set initial focus (tabindex="0") on the first non-disabled option if selected option is disabled when softDisabled is false', () => { + setupListbox({ + focusMode: 'roving', + value: [1], + disabledOptions: [0], + softDisabled: false, + }); + expect(optionElements[0].getAttribute('tabindex')).toBe('-1'); + expect(optionElements[1].getAttribute('tabindex')).toBe('0'); + }); + + it('should set initial focus (tabindex="0") on the first option if selected option is disabled', () => { + setupListbox({ + focusMode: 'roving', + value: [1], + disabledOptions: [0], + }); expect(optionElements[0].getAttribute('tabindex')).toBe('0'); expect(optionElements[1].getAttribute('tabindex')).toBe('-1'); }); @@ -251,6 +266,16 @@ describe('Listbox', () => { expect(listboxElement.getAttribute('aria-activedescendant')).toBe(optionElements[0].id); }); + it('should set aria-activedescendant to the ID of the first non-disabled option if selected option is disabled when softDisabled is false', () => { + setupListbox({ + focusMode: 'activedescendant', + value: [1], + disabledOptions: [0], + softDisabled: false, + }); + expect(listboxElement.getAttribute('aria-activedescendant')).toBe(optionElements[1].id); + }); + it('should set tabindex="-1" for all options', () => { setupListbox({focusMode: 'activedescendant'}); expect(optionElements[0].getAttribute('tabindex')).toBe('-1'); @@ -553,17 +578,17 @@ describe('Listbox', () => { isFocused: (index: number) => boolean, ) { describe(`keyboard navigation (focusMode="${focusMode}")`, () => { - it('should move focus to the last enabled option on End', () => { + it('should move focus to the last focusable option on End', () => { setupListbox({focusMode, disabledOptions: [4]}); end(); - expect(isFocused(3)).toBe(true); + expect(isFocused(4)).toBe(true); }); - it('should move focus to the first enabled option on Home', () => { + it('should move focus to the first focusable option on Home', () => { setupListbox({focusMode, disabledOptions: [0]}); end(); home(); - expect(isFocused(1)).toBe(true); + expect(isFocused(0)).toBe(true); }); it('should allow keyboard navigation if the group is readonly', () => { @@ -774,7 +799,7 @@ class ListboxExample { value: number[] = []; disabled = false; readonly = false; - softDisabled = false; + softDisabled = true; focusMode: 'roving' | 'activedescendant' = 'roving'; orientation: 'vertical' | 'horizontal' = 'vertical'; multi = false; diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index 1a598a2c38c6..92ba46214060 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -98,7 +98,7 @@ export class Listbox { wrap = input(true, {transform: booleanAttribute}); /** Whether to allow disabled items in the list to receive focus. */ - softDisabled = input(false, {transform: booleanAttribute}); + softDisabled = input(true, {transform: booleanAttribute}); /** The focus strategy used by the list. */ focusMode = input<'roving' | 'activedescendant'>('roving'); diff --git a/src/aria/private/accordion/accordion.spec.ts b/src/aria/private/accordion/accordion.spec.ts index af7f65da0dda..00f7f806c252 100644 --- a/src/aria/private/accordion/accordion.spec.ts +++ b/src/aria/private/accordion/accordion.spec.ts @@ -66,7 +66,7 @@ describe('Accordion Pattern', () => { multiExpandable: signal(true), items: signal([]), expandedIds: signal([]), - softDisabled: signal(false), + softDisabled: signal(true), wrap: signal(true), element: signal(document.createElement('div')), }; diff --git a/src/aria/private/behaviors/grid/grid-focus.ts b/src/aria/private/behaviors/grid/grid-focus.ts index 74bb8f9e83d6..fa89ea784cfa 100644 --- a/src/aria/private/behaviors/grid/grid-focus.ts +++ b/src/aria/private/behaviors/grid/grid-focus.ts @@ -143,7 +143,7 @@ export class GridFocus { /** Moves focus to the cell at the given coordinates if it's part of a focusable cell. */ focusCoordinates(coords: RowCol): boolean { - if (this.gridDisabled()) { + if (this.gridDisabled() && !this.inputs.softDisabled()) { return false; } diff --git a/src/aria/private/behaviors/grid/grid-navigation.spec.ts b/src/aria/private/behaviors/grid/grid-navigation.spec.ts index 0dd60cdf1850..c90fe681ed6d 100644 --- a/src/aria/private/behaviors/grid/grid-navigation.spec.ts +++ b/src/aria/private/behaviors/grid/grid-navigation.spec.ts @@ -51,7 +51,7 @@ function setupGridNavigation( const gridFocusInputs: GridFocusInputs = { focusMode: signal('roving'), disabled: signal(false), - softDisabled: signal(false), + softDisabled: signal(true), }; const gridFocus = new GridFocus({ grid: gridData, @@ -114,9 +114,11 @@ describe('GridNavigation', () => { expect(gridFocus.activeCoords()).toEqual({row: 1, col: 2}); }); - it('should return false if the coordinates cannot be focused', () => { + it('should return false if the coordinates cannot be focused when softDisabled is false', () => { const cells = createTestGrid(createGridD); - const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); cells[1][0].disabled.set(true); // This cell spans {row: 1, col: 2} const result = gridNav.gotoCoords({row: 1, col: 2}); @@ -156,12 +158,23 @@ describe('GridNavigation', () => { expect(gridNav.peek(direction.Up, from, 'continuous')).toEqual({row: 3, col: 2}); }); - it('should return undefined if no focusable cell is found', () => { + it('should return the next coordinates even if all cells are disabled', () => { cells.flat().forEach(cell => cell.disabled.set(true)); gridNav.gotoCoords({row: 1, col: 0}); const nextCoords = gridNav.peek(direction.Up, gridFocus.activeCoords()); + expect(nextCoords).toEqual({row: 0, col: 0}); + }); + + it('should return undefined if all cells are disabled when softDisabled is false', () => { + const {gridNav} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); + cells.flat().forEach(cell => cell.disabled.set(true)); + + const nextCoords = gridNav.peek(direction.Up, {row: 1, col: 0}); + expect(nextCoords).toBeUndefined(); }); }); @@ -184,12 +197,23 @@ describe('GridNavigation', () => { expect(gridNav.peek(direction.Down, from, 'continuous')).toEqual({row: 0, col: 2}); }); - it('should return undefined if no focusable cell is found', () => { + it('should return the next coordinates even if all cells are disabled', () => { cells.flat().forEach(cell => cell.disabled.set(true)); gridNav.gotoCoords({row: 1, col: 0}); const nextCoords = gridNav.peek(direction.Down, gridFocus.activeCoords()); + expect(nextCoords).toEqual({row: 2, col: 0}); + }); + + it('should return undefined if all cells are disabled when softDisabled is false', () => { + const {gridNav} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); + cells.flat().forEach(cell => cell.disabled.set(true)); + + const nextCoords = gridNav.peek(direction.Down, {row: 1, col: 0}); + expect(nextCoords).toBeUndefined(); }); }); @@ -212,7 +236,7 @@ describe('GridNavigation', () => { expect(gridNav.peek(direction.Left, from, 'continuous')).toEqual({row: 3, col: 2}); }); - it('should return undefined if no focusable cell is found', () => { + it('should return the next coordinates even if all cells are disabled', () => { cells.flat().forEach(function (cell) { cell.disabled.set(true); }); @@ -220,6 +244,17 @@ describe('GridNavigation', () => { const nextCoords = gridNav.peek(direction.Left, gridFocus.activeCoords()); + expect(nextCoords).toEqual({row: 1, col: 2}); + }); + + it('should return undefined if all cells are disabled when softDisabled is false', () => { + const {gridNav} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); + cells.flat().forEach(cell => cell.disabled.set(true)); + + const nextCoords = gridNav.peek(direction.Left, {row: 1, col: 0}); + expect(nextCoords).toBeUndefined(); }); }); @@ -242,7 +277,7 @@ describe('GridNavigation', () => { expect(gridNav.peek(direction.Right, from, 'continuous')).toEqual({row: 1, col: 0}); }); - it('should return undefined if no focusable cell is found', () => { + it('should return the next coordinates even if all cells are disabled', () => { cells.flat().forEach(function (cell) { cell.disabled.set(true); }); @@ -250,6 +285,17 @@ describe('GridNavigation', () => { const nextCoords = gridNav.peek(direction.Right, gridFocus.activeCoords()); + expect(nextCoords).toEqual({row: 1, col: 1}); + }); + + it('should return undefined if all cells are disabled when softDisabled is false', () => { + const {gridNav} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); + cells.flat().forEach(cell => cell.disabled.set(true)); + + const nextCoords = gridNav.peek(direction.Right, {row: 1, col: 0}); + expect(nextCoords).toBeUndefined(); }); }); @@ -1844,9 +1890,11 @@ describe('GridNavigation', () => { }); describe('first/peekFirst', () => { - it('should navigate to the first focusable cell in the grid', () => { + it('should navigate to the first focusable cell in the grid when softDisabled is false', () => { const cells = createTestGrid(createGridB); - const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); // Disable the first few cells to make it more interesting. cells[0][0].disabled.set(true); @@ -1864,10 +1912,32 @@ describe('GridNavigation', () => { expect(gridFocus.activeCoords()).toEqual({row: 0, col: 2}); }); - it('should navigate to the first focusable cell in a specific row', () => { - const cells = createTestGrid(createGridC); + it('should navigate to the first focusable cell in the grid', () => { + const cells = createTestGrid(createGridB); const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + // Disable the first few cells to make it more interesting. + cells[0][0].disabled.set(true); + cells[0][1].disabled.set(true); + + const firstCoords = gridNav.peekFirst(); + expect(firstCoords).toEqual({row: 0, col: 0}); + + // The active cell should not have changed yet. + expect(gridFocus.activeCell()).toBeUndefined(); + + const result = gridNav.first(); + expect(result).toBe(true); + expect(gridFocus.activeCell()).toBe(cells[0][0]); + expect(gridFocus.activeCoords()).toEqual({row: 0, col: 0}); + }); + + it('should navigate to the first focusable cell in a specific row when softDisabled is false', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); + // Disable the first cell in row 1. cells[1][0].disabled.set(true); @@ -1882,12 +1952,33 @@ describe('GridNavigation', () => { expect(gridFocus.activeCell()).toBe(cells[1][1]); expect(gridFocus.activeCoords()).toEqual({row: 1, col: 1}); }); + + it('should navigate to the first focusable cell in a specific row', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + + // Disable the first cell in row 1. + cells[1][0].disabled.set(true); + + const firstInRowCoords = gridNav.peekFirst(1); + expect(firstInRowCoords).toEqual({row: 1, col: 0}); + + // The active cell should not have changed yet. + expect(gridFocus.activeCell()).toBeUndefined(); + + const result = gridNav.first(1); + expect(result).toBe(true); + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 0}); + }); }); describe('last/peekLast', () => { - it('should navigate to the last focusable cell in the grid', () => { + it('should navigate to the last focusable cell in the grid when softDisabled is false', () => { const cells = createTestGrid(createGridB); - const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); // Disable the last few cells to make it more interesting. cells[3][1].disabled.set(true); // cell-3-2 @@ -1905,10 +1996,32 @@ describe('GridNavigation', () => { expect(gridFocus.activeCoords()).toEqual({row: 3, col: 0}); }); - it('should navigate to the last focusable cell in a specific row', () => { - const cells = createTestGrid(createGridC); + it('should navigate to the last focusable cell in the grid', () => { + const cells = createTestGrid(createGridB); const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + // Disable the last few cells to make it more interesting. + cells[3][1].disabled.set(true); // cell-3-2 + cells[3][0].disabled.set(true); // cell-3-1 + + const lastCoords = gridNav.peekLast(); + expect(lastCoords).toEqual({row: 3, col: 2}); + + // The active cell should not have changed yet. + expect(gridFocus.activeCell()).toBeUndefined(); + + const result = gridNav.last(); + expect(result).toBe(true); + expect(gridFocus.activeCell()!.id()).toBe('cell-3-2'); + expect(gridFocus.activeCoords()).toEqual({row: 3, col: 2}); + }); + + it('should navigate to the last focusable cell in a specific row when softDisabled is false', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells), { + softDisabled: signal(false), + }); + // Disable the last cell in row 1. cells[1][2].disabled.set(true); @@ -1920,5 +2033,21 @@ describe('GridNavigation', () => { expect(gridFocus.activeCell()!.id()).toBe('cell-1-1'); expect(gridFocus.activeCoords()).toEqual({row: 1, col: 2}); }); + + it('should navigate to the last focusable cell in a specific row', () => { + const cells = createTestGrid(createGridC); + const {gridNav, gridFocus} = setupGridNavigation(signal(cells)); + + // Disable the last cell in row 1. + cells[1][2].disabled.set(true); + + const lastInRowCoords = gridNav.peekLast(1); + expect(lastInRowCoords).toEqual({row: 1, col: 3}); + + const result = gridNav.last(1); + expect(result).toBe(true); + expect(gridFocus.activeCell()!.id()).toBe('cell-1-3'); + expect(gridFocus.activeCoords()).toEqual({row: 1, col: 3}); + }); }); }); diff --git a/src/aria/private/behaviors/grid/grid-selection.spec.ts b/src/aria/private/behaviors/grid/grid-selection.spec.ts index 7f3fab28d58f..95a4a3dda522 100644 --- a/src/aria/private/behaviors/grid/grid-selection.spec.ts +++ b/src/aria/private/behaviors/grid/grid-selection.spec.ts @@ -47,7 +47,7 @@ function setupGridSelection( const gridFocusInputs: GridFocusInputs = { focusMode: signal('roving'), disabled: signal(false), - softDisabled: signal(false), + softDisabled: signal(true), }; const gridFocus = new GridFocus({ grid: gridData, diff --git a/src/aria/private/behaviors/grid/grid.spec.ts b/src/aria/private/behaviors/grid/grid.spec.ts index 332bba3ec424..910e79ab5fb2 100644 --- a/src/aria/private/behaviors/grid/grid.spec.ts +++ b/src/aria/private/behaviors/grid/grid.spec.ts @@ -43,7 +43,7 @@ function setupGrid( cells, focusMode: signal('roving'), disabled: signal(false), - softDisabled: signal(false), + softDisabled: signal(true), rowWrap: signal('loop'), colWrap: signal('loop'), enableSelection: signal(true), @@ -238,13 +238,23 @@ describe('Grid', () => { it('should return false if no focusable cell is found when state is empty', () => { const cells = createTestGrid(createGridA); cells.flat().forEach(c => c.disabled.set(true)); - const grid = setupGrid(signal(cells)); + const grid = setupGrid(signal(cells), {softDisabled: signal(false)}); const result = grid.resetState(); expect(result).toBe(false); expect(grid.focusBehavior.activeCell()).toBeUndefined(); }); + it('should return true and focus a cell if all cells are disabled but softDisabled is true', () => { + const cells = createTestGrid(createGridA); + cells.flat().forEach(c => c.disabled.set(true)); + const grid = setupGrid(signal(cells)); + + const result = grid.resetState(); + expect(result).toBe(true); + expect(grid.focusBehavior.activeCell()).toBe(cells[0][0]); + }); + it('should re-focus the active cell if it is stale but still exists', () => { const cellsSignal = signal(createTestGrid(createGridA)); const grid = setupGrid(cellsSignal); diff --git a/src/aria/private/behaviors/list/list.spec.ts b/src/aria/private/behaviors/list/list.spec.ts index 23f993a39491..14d607d434c0 100644 --- a/src/aria/private/behaviors/list/list.spec.ts +++ b/src/aria/private/behaviors/list/list.spec.ts @@ -32,7 +32,7 @@ describe('List Behavior', () => { orientation: inputs.orientation ?? signal('vertical'), element: signal({focus: () => {}} as HTMLElement), focusMode: inputs.focusMode ?? signal('roving'), - softDisabled: inputs.softDisabled ?? signal(false), + softDisabled: inputs.softDisabled ?? signal(true), selectionMode: signal('explicit'), ...inputs, }); @@ -176,8 +176,8 @@ describe('List Behavior', () => { expect(list.inputs.activeItem()).toBe(list.inputs.items()[8]); }); - it('should skip disabled items when navigating', () => { - const {list, items} = getDefaultPatterns(); + it('should skip disabled items when softDisabled is false', () => { + const {list, items} = getDefaultPatterns({softDisabled: signal(false)}); items[1].disabled.set(true); // Disable second item expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); list.next(); @@ -186,8 +186,8 @@ describe('List Behavior', () => { expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); // Should skip back to 'Apple' }); - it('should not skip disabled items when softDisabled is true', () => { - const {list, items} = getDefaultPatterns({softDisabled: signal(true)}); + it('should not skip disabled items when navigating', () => { + const {list, items} = getDefaultPatterns(); items[1].disabled.set(true); // Disable second item expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); list.next(); diff --git a/src/aria/private/combobox/combobox.spec.ts b/src/aria/private/combobox/combobox.spec.ts index 0b78c7fc4670..4c2d39e00229 100644 --- a/src/aria/private/combobox/combobox.spec.ts +++ b/src/aria/private/combobox/combobox.spec.ts @@ -128,7 +128,7 @@ function getListboxPattern( wrap: signal(true), readonly: signal(false), disabled: signal(false), - softDisabled: signal(false), + softDisabled: signal(true), multi: signal(false), focusMode: signal('activedescendant'), textDirection: signal('ltr'), @@ -171,7 +171,7 @@ function getTreePattern( typeaheadDelay: signal(0.5), wrap: signal(true), disabled: signal(false), - softDisabled: signal(false), + softDisabled: signal(true), multi: signal(false), focusMode: signal('activedescendant'), textDirection: signal('ltr'), diff --git a/src/aria/private/listbox/listbox.spec.ts b/src/aria/private/listbox/listbox.spec.ts index f926b500efe3..2dbfe867d950 100644 --- a/src/aria/private/listbox/listbox.spec.ts +++ b/src/aria/private/listbox/listbox.spec.ts @@ -40,7 +40,7 @@ describe('Listbox Pattern', () => { wrap: inputs.wrap ?? signal(true), readonly: inputs.readonly ?? signal(false), disabled: inputs.disabled ?? signal(false), - softDisabled: inputs.softDisabled ?? signal(false), + softDisabled: inputs.softDisabled ?? signal(true), multi: inputs.multi ?? signal(false), focusMode: inputs.focusMode ?? signal('roving'), textDirection: inputs.textDirection ?? signal('ltr'), diff --git a/src/aria/private/menu/menu.spec.ts b/src/aria/private/menu/menu.spec.ts index a31697d00b32..824ee1b566b3 100644 --- a/src/aria/private/menu/menu.spec.ts +++ b/src/aria/private/menu/menu.spec.ts @@ -59,7 +59,7 @@ function getMenuBarPattern(values: string[]) { value: signal([]), wrap: signal(true), typeaheadDelay: signal(0.5), - softDisabled: signal(false), + softDisabled: signal(true), focusMode: signal('activedescendant'), element: signal(document.createElement('div')), }); @@ -96,7 +96,7 @@ function getMenuPattern( activeItem: signal(undefined), typeaheadDelay: signal(0.5), wrap: signal(true), - softDisabled: signal(false), + softDisabled: signal(true), multi: signal(false), focusMode: signal('activedescendant'), textDirection: signal('ltr'), diff --git a/src/aria/private/tabs/tabs.spec.ts b/src/aria/private/tabs/tabs.spec.ts index e9d592bca900..3ceb1f9388a9 100644 --- a/src/aria/private/tabs/tabs.spec.ts +++ b/src/aria/private/tabs/tabs.spec.ts @@ -63,7 +63,7 @@ describe('Tabs Pattern', () => { focusMode: signal('roving'), disabled: signal(false), activeItem: signal(undefined), - softDisabled: signal(false), + softDisabled: signal(true), items: signal([]), value: signal(['tab-1']), element: signal(document.createElement('div')), @@ -193,6 +193,7 @@ describe('Tabs Pattern', () => { }); it('should not set activeIndex if no tabs are focusable', () => { + tabListInputs.softDisabled.set(false); tabInputs.forEach(input => input.disabled.set(true)); tabListInputs.activeItem.set(tabPatterns[10]); tabListPattern.setDefaultState(); @@ -200,6 +201,7 @@ describe('Tabs Pattern', () => { }); it('should set activeIndex to the first focusable tab if no tabs are selected', () => { + tabListInputs.softDisabled.set(false); tabListInputs.activeItem.set(tabPatterns[2]); tabListInputs.value.set([]); tabInputs[0].disabled.set(true); @@ -215,6 +217,7 @@ describe('Tabs Pattern', () => { }); it('should set activeIndex to the first focusable tab when the selected tab is not focusable', () => { + tabListInputs.softDisabled.set(false); tabListInputs.value.set([tabPatterns[1].value()]); tabInputs[1].disabled.set(true); tabListPattern.setDefaultState(); @@ -231,6 +234,7 @@ describe('Tabs Pattern', () => { }); it('skips the disabled tab when `softDisabled` is set to false.', () => { + tabListInputs.softDisabled.set(false); tabInputs[1].disabled.set(true); tabListPattern.onKeydown(right()); expect(tabPatterns[0].active()).toBeFalse(); @@ -239,7 +243,6 @@ describe('Tabs Pattern', () => { }); it('does not skip the disabled tab when `softDisabled` is set to true.', () => { - tabListInputs.softDisabled.set(true); tabInputs[1].disabled.set(true); tabListPattern.onKeydown(right()); expect(tabPatterns[0].active()).toBeFalse(); diff --git a/src/aria/private/toolbar/toolbar.spec.ts b/src/aria/private/toolbar/toolbar.spec.ts index bafc661029d6..8ea70de2645e 100644 --- a/src/aria/private/toolbar/toolbar.spec.ts +++ b/src/aria/private/toolbar/toolbar.spec.ts @@ -103,7 +103,7 @@ describe('Toolbar Pattern', () => { orientation: signal('horizontal'), textDirection: signal('ltr'), disabled: signal(false), - softDisabled: signal(false), + softDisabled: signal(true), wrap: signal(false), }; widgetInputs = [ @@ -173,13 +173,13 @@ describe('Toolbar Pattern', () => { }); it('should skip a disabled toolbar widget when softDisabled is false', () => { + toolbarInputs.softDisabled.set(false); widgetInputs[1].disabled.set(true); toolbar.onKeydown(right()); expect(toolbarInputs.activeItem()).toBe(items[2]); }); it('should not skip disabled items when softDisabled is true', () => { - toolbarInputs.softDisabled.set(true); widgetInputs[1].disabled.set(true); toolbar.onKeydown(right()); expect(toolbarInputs.activeItem()).toBe(items[1]); @@ -216,7 +216,7 @@ describe('Toolbar Pattern', () => { orientation: signal('horizontal'), textDirection: signal('ltr'), disabled: signal(false), - softDisabled: signal(false), + softDisabled: signal(true), wrap: signal(false), }; const widgetInputs = [ @@ -261,7 +261,7 @@ describe('Toolbar Pattern', () => { orientation: signal('horizontal'), textDirection: signal('ltr'), disabled: signal(false), - softDisabled: signal(false), + softDisabled: signal(true), wrap: signal(false), }; widgetInputs = [ @@ -286,12 +286,14 @@ describe('Toolbar Pattern', () => { }); it('should skip disabled widgets and set the next focusable widget as active', () => { + toolbarInputs.softDisabled.set(false); widgetInputs[0].disabled.set(true); toolbar.setDefaultState(); expect(toolbarInputs.activeItem()).toBe(items[1]); }); it('should call "setDefaultState" on a widget group if it is the first focusable item', () => { + toolbarInputs.softDisabled.set(false); const fakeControls = jasmine.createSpyObj('fakeControls', [ 'setDefaultState', ]); @@ -329,7 +331,7 @@ describe('Toolbar Pattern', () => { orientation: signal('horizontal'), textDirection: signal('ltr'), disabled: signal(false), - softDisabled: signal(false), + softDisabled: signal(true), wrap: signal(false), }; const widgetInputs = [ diff --git a/src/aria/private/tree/tree.spec.ts b/src/aria/private/tree/tree.spec.ts index 726940cec797..16bd5fc53349 100644 --- a/src/aria/private/tree/tree.spec.ts +++ b/src/aria/private/tree/tree.spec.ts @@ -147,7 +147,7 @@ describe('Tree Pattern', () => { multi: signal(false), orientation: signal('vertical'), selectionMode: signal('follow'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -201,7 +201,7 @@ describe('Tree Pattern', () => { multi: signal(false), orientation: signal('vertical'), selectionMode: signal('follow'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -257,7 +257,7 @@ describe('Tree Pattern', () => { multi: signal(false), orientation: signal('vertical'), selectionMode: signal('explicit'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -443,7 +443,7 @@ describe('Tree Pattern', () => { multi: signal(false), orientation: signal('vertical'), selectionMode: signal('follow'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -511,7 +511,7 @@ describe('Tree Pattern', () => { multi: signal(false), orientation: signal('vertical'), selectionMode: signal('explicit'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -591,7 +591,7 @@ describe('Tree Pattern', () => { multi: signal(true), orientation: signal('vertical'), selectionMode: signal('explicit'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -767,7 +767,7 @@ describe('Tree Pattern', () => { multi: signal(true), orientation: signal('vertical'), selectionMode: signal('follow'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -934,7 +934,7 @@ describe('Tree Pattern', () => { multi: signal(false), orientation: signal('vertical'), selectionMode: signal('follow'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -976,7 +976,7 @@ describe('Tree Pattern', () => { multi: signal(false), orientation: signal('vertical'), selectionMode: signal('explicit'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -1031,7 +1031,7 @@ describe('Tree Pattern', () => { multi: signal(true), orientation: signal('vertical'), selectionMode: signal('explicit'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -1081,7 +1081,7 @@ describe('Tree Pattern', () => { multi: signal(true), orientation: signal('vertical'), selectionMode: signal('follow'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -1177,7 +1177,7 @@ describe('Tree Pattern', () => { multi: signal(false), orientation: signal('vertical'), selectionMode: signal('explicit'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), @@ -1438,7 +1438,7 @@ describe('Tree Pattern', () => { multi: signal(false), orientation: signal('vertical'), selectionMode: signal('explicit'), - softDisabled: signal(false), + softDisabled: signal(true), textDirection: signal('ltr'), typeaheadDelay: signal(0), value: signal([]), diff --git a/src/aria/tabs/tabs.spec.ts b/src/aria/tabs/tabs.spec.ts index 84dc700ed48a..8bf4ecfbeb6b 100644 --- a/src/aria/tabs/tabs.spec.ts +++ b/src/aria/tabs/tabs.spec.ts @@ -273,12 +273,12 @@ describe('Tabs', () => { it('should move focus with ArrowRight', () => { expect(isTabFocused(0)).toBe(true); right(); - expect(isTabFocused(2)).toBe(true); + expect(isTabFocused(1)).toBe(true); }); it('should move focus with ArrowLeft', () => { right(); - expect(isTabFocused(2)).toBe(true); + expect(isTabFocused(1)).toBe(true); left(); expect(isTabFocused(0)).toBe(true); }); @@ -286,6 +286,8 @@ describe('Tabs', () => { it('should wrap focus with ArrowRight if wrap is true', () => { updateTabs({wrap: true}); right(); + expect(isTabFocused(1)).toBe(true); + right(); expect(isTabFocused(2)).toBe(true); right(); expect(isTabFocused(0)).toBe(true); @@ -294,6 +296,8 @@ describe('Tabs', () => { it('should not wrap focus with ArrowRight if wrap is false', () => { updateTabs({wrap: false}); right(); + expect(isTabFocused(1)).toBe(true); + right(); expect(isTabFocused(2)).toBe(true); right(); expect(isTabFocused(2)).toBe(true); @@ -359,12 +363,12 @@ describe('Tabs', () => { it('should move focus with ArrowLeft (effectively next)', () => { expect(isTabFocused(0)).toBe(true); left(); - expect(isTabFocused(2)).toBe(true); + expect(isTabFocused(1)).toBe(true); }); it('should move focus with ArrowRight (effectively previous)', () => { left(); - expect(isTabFocused(2)).toBe(true); + expect(isTabFocused(1)).toBe(true); right(); expect(isTabFocused(0)).toBe(true); }); @@ -372,6 +376,9 @@ describe('Tabs', () => { it('should wrap focus with ArrowLeft if wrap is true', () => { updateTabs({wrap: true}); left(); + expect(isTabFocused(1)).toBe(true); + left(); + expect(isTabFocused(2)).toBe(true); left(); expect(isTabFocused(0)).toBe(true); }); @@ -402,12 +409,12 @@ describe('Tabs', () => { it('should move focus with ArrowDown', () => { expect(isTabFocused(0)).toBe(true); down(); - expect(isTabFocused(2)).toBe(true); + expect(isTabFocused(1)).toBe(true); }); it('should move focus with ArrowUp', () => { down(); - expect(isTabFocused(2)).toBe(true); + expect(isTabFocused(1)).toBe(true); up(); expect(isTabFocused(0)).toBe(true); }); @@ -415,6 +422,8 @@ describe('Tabs', () => { it('should wrap focus with ArrowDown if wrap is true', () => { updateTabs({wrap: true}); down(); + expect(isTabFocused(1)).toBe(true); + down(); expect(isTabFocused(2)).toBe(true); down(); expect(isTabFocused(0)).toBe(true); @@ -423,6 +432,8 @@ describe('Tabs', () => { it('should not wrap focus with ArrowDown if wrap is false', () => { updateTabs({wrap: false}); down(); + expect(isTabFocused(1)).toBe(true); + down(); expect(isTabFocused(2)).toBe(true); down(); expect(isTabFocused(2)).toBe(true); @@ -443,7 +454,7 @@ describe('Tabs', () => { }); it('should move focus to first tab with Home', () => { - down(); + end(); expect(isTabFocused(2)).toBe(true); home(); expect(isTabFocused(0)).toBe(true); @@ -723,7 +734,7 @@ class TestTabsComponent { orientation = signal<'horizontal' | 'vertical'>('horizontal'); disabled = signal(false); wrap = signal(true); - softDisabled = signal(false); + softDisabled = signal(true); focusMode = signal<'roving' | 'activedescendant'>('roving'); selectionMode = signal<'follow' | 'explicit'>('follow'); } diff --git a/src/aria/tabs/tabs.ts b/src/aria/tabs/tabs.ts index f6442a604f70..41c410c0bdf0 100644 --- a/src/aria/tabs/tabs.ts +++ b/src/aria/tabs/tabs.ts @@ -159,7 +159,7 @@ export class TabList implements OnInit, OnDestroy { readonly wrap = input(true, {transform: booleanAttribute}); /** Whether to allow disabled items to receive focus. */ - readonly softDisabled = input(false, {transform: booleanAttribute}); + readonly softDisabled = input(true, {transform: booleanAttribute}); /** The focus strategy used by the tablist. */ readonly focusMode = input<'roving' | 'activedescendant'>('roving'); diff --git a/src/aria/toolbar/toolbar.spec.ts b/src/aria/toolbar/toolbar.spec.ts index 2b8275b35757..1ba6747c691f 100644 --- a/src/aria/toolbar/toolbar.spec.ts +++ b/src/aria/toolbar/toolbar.spec.ts @@ -158,12 +158,12 @@ describe('Toolbar', () => { it('should move focus to the next widget on ArrowDown', () => { down(); - expect(document.activeElement).toBe(widgetElements[2]); + expect(document.activeElement).toBe(widgetElements[1]); }); it('should move focus to the previous widget on ArrowUp', () => { down(); - expect(document.activeElement).toBe(widgetElements[2]); + expect(document.activeElement).toBe(widgetElements[1]); up(); expect(document.activeElement).toBe(widgetElements[0]); @@ -177,12 +177,12 @@ describe('Toolbar', () => { it('should move focus to the next widget on ArrowRight', () => { right(); - expect(document.activeElement).toBe(widgetElements[2]); + expect(document.activeElement).toBe(widgetElements[1]); }); it('should move focus to the previous widget on ArrowLeft', () => { right(); - expect(document.activeElement).toBe(widgetElements[2]); + expect(document.activeElement).toBe(widgetElements[1]); left(); expect(document.activeElement).toBe(widgetElements[0]); @@ -242,12 +242,12 @@ describe('Toolbar', () => { describe('horizontal orientation', () => { it('should move focus to the next widget on ArrowLeft', () => { left(); - expect(document.activeElement).toBe(widgetElements[2]); + expect(document.activeElement).toBe(widgetElements[1]); }); it('should move focus to the previous widget on ArrowRight', () => { left(); - expect(document.activeElement).toBe(widgetElements[2]); + expect(document.activeElement).toBe(widgetElements[1]); right(); expect(document.activeElement).toBe(widgetElements[0]); @@ -364,7 +364,7 @@ describe('Toolbar', () => { }); it('should call "first" when navigating into a group from the previous widget', () => { - click(widgetElements[0]); + click(widgetElements[1]); right(); expect(testWidgetGroupInstance.lastAction()).toBe('first'); }); @@ -473,5 +473,5 @@ class TestToolbarComponent { disabled = signal(false); widgetGroupDisabled = signal(false); wrap = signal(true); - softDisabled = signal(false); + softDisabled = signal(true); } diff --git a/src/aria/tree/tree.spec.ts b/src/aria/tree/tree.spec.ts index 61ae511ece27..ef0a928c18d8 100644 --- a/src/aria/tree/tree.spec.ts +++ b/src/aria/tree/tree.spec.ts @@ -1315,7 +1315,8 @@ describe('Tree', () => { }); }); - it('should move focus to the last enabled visible item on End', () => { + it('should move focus to the last enabled visible item on End (softDisabled="false")', () => { + updateTree({softDisabled: false}); right(); // Expands fruits updateTreeItemByValue('dairy', {disabled: true}); updateTreeItemByValue('grains', {disabled: true}); @@ -1324,7 +1325,8 @@ describe('Tree', () => { expect(isFocused('berries')).toBe(true); }); - it('should move focus to the first enabled visible item on Home', () => { + it('should move focus to the first enabled visible item on Home (softDisabled="false")', () => { + updateTree({softDisabled: false}); end(); updateTreeItemByValue('fruits', {disabled: true}); home(); @@ -1530,7 +1532,7 @@ class TestTreeComponent { orientation = signal<'vertical' | 'horizontal'>('vertical'); multi = signal(false); wrap = signal(true); - softDisabled = signal(false); + softDisabled = signal(true); focusMode = signal<'roving' | 'activedescendant'>('roving'); selectionMode = signal<'explicit' | 'follow'>('explicit'); nav = signal(false); diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index 9ff8707e8447..0602f2aec3ef 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -118,7 +118,7 @@ export class Tree { readonly wrap = input(true, {transform: booleanAttribute}); /** Whether to allow disabled items to receive focus. */ - readonly softDisabled = input(false, {transform: booleanAttribute}); + readonly softDisabled = input(true, {transform: booleanAttribute}); /** Typeahead delay. */ readonly typeaheadDelay = input(0.5); diff --git a/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.ts b/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.ts index dc0e12eec010..46e341450342 100644 --- a/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.ts +++ b/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.ts @@ -30,5 +30,5 @@ export class TabsConfigurableExample { wrap = new FormControl(true, {nonNullable: true}); disabled = new FormControl(false, {nonNullable: true}); - softDisabled = new FormControl(false, {nonNullable: true}); + softDisabled = new FormControl(true, {nonNullable: true}); } diff --git a/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.ts b/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.ts index 0f6258402272..d193eb1cdffb 100644 --- a/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.ts +++ b/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.ts @@ -40,7 +40,7 @@ export class TreeConfigurableExample { multi = new FormControl(false, {nonNullable: true}); disabled = new FormControl(false, {nonNullable: true}); wrap = new FormControl(true, {nonNullable: true}); - softDisabled = new FormControl(false, {nonNullable: true}); + softDisabled = new FormControl(true, {nonNullable: true}); nav = new FormControl(false, {nonNullable: true}); selectedValues = model(['package.json']); From 60ea311402ffb97d55c80b5941079a0e492f94ea Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:14:32 -0800 Subject: [PATCH 2/3] fix(cdk/a11y): update tests to reflect correct behavior --- src/aria/listbox/listbox.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts index 24bd0188a2ea..9cd2ee2296b7 100644 --- a/src/aria/listbox/listbox.spec.ts +++ b/src/aria/listbox/listbox.spec.ts @@ -232,7 +232,7 @@ describe('Listbox', () => { it('should set initial focus (tabindex="0") on the first option if selected option is disabled', () => { setupListbox({ focusMode: 'roving', - value: [1], + value: [0], disabledOptions: [0], }); expect(optionElements[0].getAttribute('tabindex')).toBe('0'); @@ -262,7 +262,7 @@ describe('Listbox', () => { }); it('should set aria-activedescendant to the ID of the first non-disabled option if selected option is disabled', () => { - setupListbox({focusMode: 'activedescendant', value: [1], disabledOptions: [1]}); + setupListbox({focusMode: 'activedescendant', value: [0], disabledOptions: [0]}); expect(listboxElement.getAttribute('aria-activedescendant')).toBe(optionElements[0].id); }); From d1abf848075b040126b73d5443c3dc04f723234f Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Mon, 3 Nov 2025 18:19:07 -0800 Subject: [PATCH 3/3] refactor(cdk/a11y): refine softDisabled behavior and update tests\n\nThis commit refines the behavior across several a11y components (List, Listbox, Accordion, Tree) to ensure correct interaction between disabled states and navigation/selection. Specifically:\n\n- In , the method now explicitly checks if the list is disabled before allowing selection updates, preventing unintended selections when permits navigation.\n- In , a new method is introduced to clearly distinguish between a 'hard' disabled state (blocking all interaction) and a 'soft' disabled state (allowing navigation but blocking selection).\n- Corresponding tests in , , , and have been updated and expanded to accurately reflect and verify these refined interactions, ensuring that navigation and selection behave as expected in various disabled scenarios. --- src/aria/accordion/accordion.spec.ts | 9 ++++- src/aria/listbox/listbox.spec.ts | 21 ++++++++++-- .../behaviors/list-focus/list-focus.ts | 13 ++++--- src/aria/private/behaviors/list/list.spec.ts | 34 +++++++++++++++++-- src/aria/private/behaviors/list/list.ts | 2 +- src/aria/tree/tree.spec.ts | 8 ++++- 6 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/aria/accordion/accordion.spec.ts b/src/aria/accordion/accordion.spec.ts index 38635ca4a004..2b5c31835341 100644 --- a/src/aria/accordion/accordion.spec.ts +++ b/src/aria/accordion/accordion.spec.ts @@ -363,12 +363,19 @@ describe('AccordionGroup', () => { }); it('should not allow keyboard navigation if group is disabled', () => { - configureAccordionComponent({disabledGroup: true}); + configureAccordionComponent({disabledGroup: true, softDisabled: false}); downArrowKey(triggerElements[0]); expect(isTriggerActive(triggerElements[1])).toBeFalse(); }); + it('should allow keyboard navigation if group is disabled', () => { + configureAccordionComponent({disabledGroup: true}); + + downArrowKey(triggerElements[0]); + expect(isTriggerActive(triggerElements[1])).toBeTrue(); + }); + it('should not allow expansion if group is disabled', () => { configureAccordionComponent({disabledGroup: true}); diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts index 9cd2ee2296b7..03553200c9b1 100644 --- a/src/aria/listbox/listbox.spec.ts +++ b/src/aria/listbox/listbox.spec.ts @@ -195,11 +195,16 @@ describe('Listbox', () => { expect(listboxElement.getAttribute('tabindex')).toBe('-1'); }); - it('should set tabindex="0" for the listbox when disabled and focusMode is "roving"', () => { - setupListbox({disabled: true, focusMode: 'roving'}); + it('should set tabindex="0" for the listbox when disabled and focusMode is "roving when softDisabled is false"', () => { + setupListbox({disabled: true, focusMode: 'roving', softDisabled: false}); expect(listboxElement.getAttribute('tabindex')).toBe('0'); }); + it('should set tabindex="-1" for the listbox when disabled and focusMode is "roving"', () => { + setupListbox({disabled: true, focusMode: 'roving'}); + expect(listboxElement.getAttribute('tabindex')).toBe('-1'); + }); + it('should set initial focus (tabindex="0") on the first non-disabled option if no value is set', () => { setupListbox({focusMode: 'roving'}); expect(optionElements[0].getAttribute('tabindex')).toBe('0'); @@ -639,6 +644,18 @@ describe('Listbox', () => { down(); expect(isFocused(1)).toBe(true); }); + + it('should not skip disabled options with ArrowDown when completely disabled', () => { + setupListbox({ + focusMode, + orientation: 'vertical', + softDisabled: true, + disabled: true, + }); + + down(); + expect(isFocused(0)).toBe(true); + }); }); describe('horizontal orientation', () => { diff --git a/src/aria/private/behaviors/list-focus/list-focus.ts b/src/aria/private/behaviors/list-focus/list-focus.ts index 5da16de7f18b..39d208814f96 100644 --- a/src/aria/private/behaviors/list-focus/list-focus.ts +++ b/src/aria/private/behaviors/list-focus/list-focus.ts @@ -66,9 +66,14 @@ export class ListFocus { return this.inputs.disabled() || this.inputs.items().every(i => i.disabled()); } + /** Whether the list is in a disabled state, but should still be focusable */ + isListDisabledFocusable(): boolean { + return this.isListDisabled() && !this.inputs.softDisabled(); + } + /** The id of the current active item. */ getActiveDescendant(): string | undefined { - if (this.isListDisabled()) { + if (this.isListDisabledFocusable()) { return undefined; } if (this.inputs.focusMode() === 'roving') { @@ -79,7 +84,7 @@ export class ListFocus { /** The tabindex for the list. */ getListTabindex(): -1 | 0 { - if (this.isListDisabled()) { + if (this.isListDisabledFocusable()) { return 0; } return this.inputs.focusMode() === 'activedescendant' ? 0 : -1; @@ -87,7 +92,7 @@ export class ListFocus { /** Returns the tabindex for the given item. */ getItemTabindex(item: T): -1 | 0 { - if (this.isListDisabled()) { + if (this.isListDisabledFocusable()) { return -1; } if (this.inputs.focusMode() === 'activedescendant') { @@ -98,7 +103,7 @@ export class ListFocus { /** Moves focus to the given item if it is focusable. */ focus(item: T, opts?: {focusElement?: boolean}): boolean { - if (this.isListDisabled() || !this.isFocusable(item)) { + if (this.isListDisabledFocusable() || !this.isFocusable(item)) { return false; } diff --git a/src/aria/private/behaviors/list/list.spec.ts b/src/aria/private/behaviors/list/list.spec.ts index 14d607d434c0..0ae24a73a078 100644 --- a/src/aria/private/behaviors/list/list.spec.ts +++ b/src/aria/private/behaviors/list/list.spec.ts @@ -115,11 +115,11 @@ describe('List Behavior', () => { }); }); - describe('with disabled: true', () => { + describe('with disabled: true and softDisabled is false', () => { let list: TestList; beforeEach(() => { - const patterns = getDefaultPatterns({disabled: signal(true)}); + const patterns = getDefaultPatterns({disabled: signal(true), softDisabled: signal(false)}); list = patterns.list; }); @@ -145,6 +145,36 @@ describe('List Behavior', () => { }); }); + describe('with disabled: true', () => { + let list: TestList; + + beforeEach(() => { + const patterns = getDefaultPatterns({disabled: signal(true)}); + list = patterns.list; + }); + + it('should report disabled state', () => { + expect(list.disabled()).toBe(true); + }); + + it('should not change active index on navigation', () => { + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); + list.next(); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[1]); + list.last(); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[8]); + }); + + it('should not select items', () => { + list.next({selectOne: true}); + expect(list.inputs.value()).toEqual([]); + }); + + it('should have a tabindex of 0', () => { + expect(list.tabindex()).toBe(-1); + }); + }); + describe('Navigation', () => { it('should navigate to the next item with next()', () => { const {list} = getDefaultPatterns(); diff --git a/src/aria/private/behaviors/list/list.ts b/src/aria/private/behaviors/list/list.ts index 49576d5902ac..81f6db0c1a41 100644 --- a/src/aria/private/behaviors/list/list.ts +++ b/src/aria/private/behaviors/list/list.ts @@ -226,7 +226,7 @@ export class List, V> { const moved = operation(); - if (moved) { + if (moved && !this.disabled()) { this.updateSelection(opts); } diff --git a/src/aria/tree/tree.spec.ts b/src/aria/tree/tree.spec.ts index ef0a928c18d8..349a46fe9702 100644 --- a/src/aria/tree/tree.spec.ts +++ b/src/aria/tree/tree.spec.ts @@ -352,10 +352,16 @@ describe('Tree', () => { expect(treeElement.getAttribute('tabindex')).toBe('-1'); }); + it('should set tabindex="0" for the tree when disabled when softDisabled is false', () => { + updateTree({disabled: true, focusMode: 'roving', softDisabled: false}); + + expect(treeElement.getAttribute('tabindex')).toBe('0'); + }); + it('should set tabindex="0" for the tree when disabled', () => { updateTree({disabled: true, focusMode: 'roving'}); - expect(treeElement.getAttribute('tabindex')).toBe('0'); + expect(treeElement.getAttribute('tabindex')).toBe('-1'); }); it('should set initial focus (tabindex="0") on the first non-disabled item if no value is set', () => {