From d2480b9f7d5088e91cef7078610833ee8ce0c7e4 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Thu, 23 Jul 2026 03:46:41 +0000 Subject: [PATCH] fix(aria/grid): account for disabled cell widgets and in-place disablement in roving tabindex --- src/aria/grid/grid.spec.ts | 90 +++++++++++++++++++ src/aria/private/behaviors/grid/grid-focus.ts | 3 +- src/aria/private/grid/cell.ts | 4 +- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/aria/grid/grid.spec.ts b/src/aria/grid/grid.spec.ts index 3e799a4670eb..03fb5170b582 100644 --- a/src/aria/grid/grid.spec.ts +++ b/src/aria/grid/grid.spec.ts @@ -1059,6 +1059,96 @@ describe('Grid directives', () => { expect(widgetDirective.isActivated()).toBeFalse(); }); }); + + describe('disabled element roving tabindex calculation', () => { + it('should not choose disabled element as active cell when softDisabled is false upon cell deletion', async () => { + const gridData: RowConfig[] = [ + { + cells: [{id: 'c0-0'}, {id: 'c0-1', disabled: true}, {id: 'c0-2'}], + }, + ]; + await setupGrid({ + gridData, + softDisabled: false, + focusMode: 'roving', + }); + gridInstance._pattern.setDefaultStateEffect(); + await fixture.whenStable(); + + expect(getActiveCellId()).toBe('c0-0'); + + // Delete c0-0 so c0-1 (disabled) is now at index 0 + fixture.componentInstance.gridData.set([ + { + cells: [{id: 'c0-1', disabled: true}, {id: 'c0-2'}], + }, + ]); + await fixture.whenStable(); + await fixture.whenStable(); + + expect(getActiveCellId()).toBe('c0-2'); + }); + + it('should not choose cell with disabled widget as active cell when softDisabled is false upon cell deletion', async () => { + const gridData: RowConfig[] = [ + { + cells: [ + {id: 'c0-0'}, + {id: 'c0-1', widgets: [{id: 'w0-1', disabled: true}]}, + {id: 'c0-2'}, + ], + }, + ]; + await setupGrid({ + gridData, + softDisabled: false, + focusMode: 'roving', + }); + gridInstance._pattern.setDefaultStateEffect(); + await fixture.whenStable(); + + expect(getActiveCellId()).toBe('c0-0'); + + // Delete c0-0 so c0-1 (with disabled widget) is now at index 0 + fixture.componentInstance.gridData.set([ + { + cells: [{id: 'c0-1', widgets: [{id: 'w0-1', disabled: true}]}, {id: 'c0-2'}], + }, + ]); + await fixture.whenStable(); + await fixture.whenStable(); + + expect(getActiveCellId()).toBe('c0-2'); + }); + + it('should recalculate active cell when active cell dynamically becomes disabled and softDisabled is false', async () => { + const gridData: RowConfig[] = [ + { + cells: [{id: 'c0-0'}, {id: 'c0-1'}, {id: 'c0-2'}], + }, + ]; + await setupGrid({ + gridData, + softDisabled: false, + focusMode: 'roving', + }); + gridInstance._pattern.setDefaultStateEffect(); + await fixture.whenStable(); + + expect(getActiveCellId()).toBe('c0-0'); + + // Dynamically disable c0-0 in-place + fixture.componentInstance.gridData.set([ + { + cells: [{id: 'c0-0', disabled: true}, {id: 'c0-1'}, {id: 'c0-2'}], + }, + ]); + await fixture.whenStable(); + await fixture.whenStable(); + + expect(getActiveCellId()).toBe('c0-1'); + }); + }); }); describe('structural validations', () => { diff --git a/src/aria/private/behaviors/grid/grid-focus.ts b/src/aria/private/behaviors/grid/grid-focus.ts index 31f0502e80f7..f6b8a69dbf4c 100644 --- a/src/aria/private/behaviors/grid/grid-focus.ts +++ b/src/aria/private/behaviors/grid/grid-focus.ts @@ -73,7 +73,8 @@ export class GridFocus { const activeCellNotValid = activeCellCoords === undefined; const activeCellMismatch = activeCell !== activeCoordsCell; - return activeCellNotValid || activeCellMismatch; + const activeCellNotFocusable = !this.isFocusable(activeCell!); + return activeCellNotValid || activeCellMismatch || activeCellNotFocusable; }); /** The id of the current active cell, for ARIA activedescendant. */ diff --git a/src/aria/private/grid/cell.ts b/src/aria/private/grid/cell.ts index 121295985649..de913f595da0 100644 --- a/src/aria/private/grid/cell.ts +++ b/src/aria/private/grid/cell.ts @@ -56,7 +56,9 @@ export class GridCellPattern implements GridCell { readonly selectable: SignalLike = () => this.inputs.selectable(); /** Whether a cell is disabled. */ - readonly disabled: SignalLike = () => this.inputs.disabled(); + readonly disabled: SignalLike = computed( + () => this.inputs.disabled() || (this.inputs.widget()?.inputs.disabled() ?? false), + ); /** The number of rows the cell should span. */ readonly rowSpan: SignalLike = () => this.inputs.rowSpan();