From 9dafa04ec61832c85ba87bce19c297270b9d7c2a Mon Sep 17 00:00:00 2001 From: Wonseok Chang Date: Tue, 30 Jun 2026 05:05:23 +0900 Subject: [PATCH] feat: support focusable disabled collection items via allowFocusWhenDisabled MenuItem, ListBoxItem, Tab, and TreeItem previously could not implement the ARIA APG "focusability of disabled controls" pattern: a disabled item either got `aria-disabled` but was removed from keyboard navigation (disabledBehavior 'all'), or stayed navigable but dropped `aria-disabled` (disabledBehavior 'selection'). Neither produced a focusable item that is also marked `aria-disabled`. Closes #10276. Add an opt-in per-item `allowFocusWhenDisabled` prop that decouples focusability from the existing disabled semantics. The item stays non-interactive (no selection or action, `aria-disabled` is still emitted) but remains reachable via roving tabindex / arrow-key navigation. `isDisabled` is intentionally left unchanged so `aria-disabled` emission and selection/action suppression keep working; only a new orthogonal focusability axis is introduced. - react-stately: add `MultipleSelectionManager.isFocusableWhenDisabled` (optional on the interface for external-implementer compat) backed by the item prop. - useSelectableItem: keep the roving tabindex/focus wiring and avoid clearing the focused key for focusable-disabled items. Native link navigation is already blocked by the existing unconditional link onClick preventDefault, since pressProps is never merged for a disabled item. - List/Grid/Tabs keyboard delegates: stop skipping focusable-disabled items. - useTab: don't null out the roving tabindex for a focusable-disabled tab. - react-aria-components: expose `allowFocusWhenDisabled` on MenuItem, ListBoxItem, Tab, and TreeItem. Tests cover, per component: aria-disabled stays set, the item is reachable via keyboard navigation, and selection/action remain blocked. --- .../react-aria-components/src/ListBox.tsx | 6 ++ packages/react-aria-components/src/Menu.tsx | 6 ++ packages/react-aria-components/src/Tabs.tsx | 6 ++ packages/react-aria-components/src/Tree.tsx | 6 ++ .../test/ListBox.test.js | 54 +++++++++++++++++ .../react-aria-components/test/Menu.test.tsx | 46 +++++++++++++++ .../react-aria-components/test/Tabs.test.js | 58 +++++++++++++++++++ .../react-aria-components/test/Tree.test.tsx | 29 ++++++++++ .../src/grid/GridKeyboardDelegate.ts | 4 +- .../src/selection/ListKeyboardDelegate.ts | 4 +- .../src/selection/useSelectableItem.ts | 14 ++++- .../src/tabs/TabsKeyboardDelegate.ts | 7 ++- packages/react-aria/src/tabs/useTab.ts | 6 +- .../src/selection/SelectionManager.ts | 4 ++ packages/react-stately/src/selection/types.ts | 6 ++ 15 files changed, 249 insertions(+), 7 deletions(-) diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 1db8ee3c600..e026365315c 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -525,6 +525,12 @@ export interface ListBoxItemProps 'aria-label'?: string; /** Whether the item is disabled. */ isDisabled?: boolean; + /** + * Whether the item should remain focusable while disabled, following the ARIA APG + * "focusability of disabled controls" pattern. The item stays non-interactive (no selection or + * action, `aria-disabled`) but remains reachable via keyboard navigation. + */ + allowFocusWhenDisabled?: boolean; /** * Handler that is called when a user performs an action on the item. The exact user event depends * on the collection's `selectionBehavior` prop and the interaction modality. diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 03bcaae1baf..68555bd2277 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -539,6 +539,12 @@ export interface MenuItemProps 'aria-label'?: string; /** Whether the item is disabled. */ isDisabled?: boolean; + /** + * Whether the item should remain focusable while disabled, following the ARIA APG + * "focusability of disabled controls" pattern. The item stays non-interactive (no selection or + * action, `aria-disabled`) but remains reachable via keyboard navigation. + */ + allowFocusWhenDisabled?: boolean; /** Handler that is called when the item is selected. */ onAction?: () => void; /** Whether the menu should close when the menu item is selected. */ diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index d2932620df5..5180a04c841 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -150,6 +150,12 @@ export interface TabProps id?: Key; /** Whether the tab is disabled. */ isDisabled?: boolean; + /** + * Whether the tab should remain focusable while disabled, following the ARIA APG + * "focusability of disabled controls" pattern. The tab stays non-interactive (not selectable, + * `aria-disabled`) but remains reachable via keyboard navigation. + */ + allowFocusWhenDisabled?: boolean; } export interface TabRenderProps { diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index f743c06c70b..5a2810c224a 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -759,6 +759,12 @@ export interface TreeItemProps children: ReactNode; /** Whether the item is disabled. */ isDisabled?: boolean; + /** + * Whether the item should remain focusable while disabled, following the ARIA APG + * "focusability of disabled controls" pattern. The item stays non-interactive (no selection or + * action, `aria-disabled`) but remains reachable via keyboard navigation. + */ + allowFocusWhenDisabled?: boolean; /** * Handler that is called when a user performs an action on this tree item. The exact user event * depends on the collection's `selectionBehavior` prop and the interaction modality. diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index db3dddbcae1..414a9e65077 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -698,6 +698,60 @@ describe('ListBox', () => { expect(document.activeElement).toBe(items[2]); }); + it('should support allowFocusWhenDisabled on items (focusable but not selectable)', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = render( + + Cat + + Dog + + Kangaroo + + ); + + let items = getAllByRole('option'); + expect(items[1]).toHaveAttribute('aria-disabled', 'true'); + + await user.tab(); + expect(document.activeElement).toBe(items[0]); + // The disabled option is reachable via keyboard navigation (not skipped). + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(items[1]); + // But it cannot be selected. + await user.keyboard('{Enter}'); + expect(items[1]).not.toHaveAttribute('aria-selected', 'true'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should support allowFocusWhenDisabled with collection-level disabledKeys', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = render( + + Cat + + Dog + + Kangaroo + + ); + + let items = getAllByRole('option'); + expect(items[1]).toHaveAttribute('aria-disabled', 'true'); + + await user.tab(); + expect(document.activeElement).toBe(items[0]); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(items[1]); + await user.keyboard('{Enter}'); + expect(items[1]).not.toHaveAttribute('aria-selected', 'true'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + it.each` interactionType ${'mouse'} diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index fe0111e6fbd..18c38f56a3b 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -422,6 +422,52 @@ describe('Menu', () => { expect(menuitem).not.toHaveClass('focus'); }); + it('should support allowFocusWhenDisabled on a menu item (focusable but not actionable)', async () => { + let onAction = jest.fn(); + let {getAllByRole} = render( + + Cat + + Dog + + Kangaroo + + ); + let items = getAllByRole('menuitem'); + expect(items[1]).toHaveAttribute('aria-disabled', 'true'); + + await user.tab(); + expect(document.activeElement).toBe(items[0]); + // The disabled item is reachable via keyboard navigation (not skipped). + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(items[1]); + // But it cannot be activated. + await user.keyboard('{Enter}'); + expect(onAction).not.toHaveBeenCalled(); + }); + + it('should support allowFocusWhenDisabled with collection-level disabledKeys', async () => { + let onAction = jest.fn(); + let {getAllByRole} = render( + + Cat + + Dog + + Kangaroo + + ); + let items = getAllByRole('menuitem'); + expect(items[1]).toHaveAttribute('aria-disabled', 'true'); + + await user.tab(); + expect(document.activeElement).toBe(items[0]); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(items[1]); + await user.keyboard('{Enter}'); + expect(onAction).not.toHaveBeenCalled(); + }); + it('should support press state', async () => { let {getAllByRole} = renderMenu({}, {className: ({isPressed}) => (isPressed ? 'pressed' : '')}); let menuitem = getAllByRole('menuitem')[0]; diff --git a/packages/react-aria-components/test/Tabs.test.js b/packages/react-aria-components/test/Tabs.test.js index d5d34e78e07..0b7550f5037 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -370,6 +370,64 @@ describe('Tabs', () => { expect(document.activeElement).toBe(items[2]); }); + it('supports allowFocusWhenDisabled on a tab (focusable but not selectable)', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = render( + + + A + + B + + C + + A + B + C + + ); + let items = getAllByRole('tab'); + expect(items[1]).toHaveAttribute('aria-disabled', 'true'); + + await user.tab(); + expect(document.activeElement).toBe(items[0]); + // The disabled tab is reachable via keyboard navigation (not skipped). + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(items[1]); + // But it cannot be selected/activated. + await user.keyboard('{Enter}'); + expect(items[1]).not.toHaveAttribute('aria-selected', 'true'); + expect(onSelectionChange).not.toHaveBeenCalledWith('b'); + }); + + it('supports allowFocusWhenDisabled with collection-level disabledKeys', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = render( + + + A + + B + + C + + A + B + C + + ); + let items = getAllByRole('tab'); + expect(items[1]).toHaveAttribute('aria-disabled', 'true'); + + await user.tab(); + expect(document.activeElement).toBe(items[0]); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(items[1]); + await user.keyboard('{Enter}'); + expect(items[1]).not.toHaveAttribute('aria-selected', 'true'); + expect(onSelectionChange).not.toHaveBeenCalledWith('b'); + }); + it('finds the first non-disabled tab', async () => { let {getAllByRole} = render( diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 2e0f634ad75..72d96e92bca 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -1071,6 +1071,35 @@ describe('Tree', () => { expect(onSelectionChange).toHaveBeenCalledTimes(0); }); + it('should support allowFocusWhenDisabled on rows (focusable but not actionable)', async () => { + let {getAllByRole} = render( + + ); + + let rows = getAllByRole('row'); + let disabledRow = rows[1]; + // aria-disabled stays even though the row remains focusable. + expect(disabledRow).toHaveAttribute('aria-disabled', 'true'); + + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + // The disabled row is reachable via keyboard navigation (not skipped). + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(disabledRow); + + // But it cannot be actioned. + await user.keyboard('{Enter}'); + expect(onAction).toHaveBeenCalledTimes(0); + }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { let {getAllByRole} = render( diff --git a/packages/react-aria/src/grid/GridKeyboardDelegate.ts b/packages/react-aria/src/grid/GridKeyboardDelegate.ts index 3356f4260a2..dfba91e48e3 100644 --- a/packages/react-aria/src/grid/GridKeyboardDelegate.ts +++ b/packages/react-aria/src/grid/GridKeyboardDelegate.ts @@ -84,7 +84,9 @@ export class GridKeyboardDelegate> implements Key return ( this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key)) && - item.props?.disabledBehavior !== 'selection' + item.props?.disabledBehavior !== 'selection' && + // Items that opt into staying focusable while disabled remain navigable. + !item.props?.allowFocusWhenDisabled ); } diff --git a/packages/react-aria/src/selection/ListKeyboardDelegate.ts b/packages/react-aria/src/selection/ListKeyboardDelegate.ts index 06e736447c6..144d5eeae36 100644 --- a/packages/react-aria/src/selection/ListKeyboardDelegate.ts +++ b/packages/react-aria/src/selection/ListKeyboardDelegate.ts @@ -92,7 +92,9 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return ( this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key)) && - item.props?.disabledBehavior !== 'selection' + item.props?.disabledBehavior !== 'selection' && + // Items that opt into staying focusable while disabled remain navigable. + !item.props?.allowFocusWhenDisabled ); } diff --git a/packages/react-aria/src/selection/useSelectableItem.ts b/packages/react-aria/src/selection/useSelectableItem.ts index 4f6d6173132..55d733a7135 100644 --- a/packages/react-aria/src/selection/useSelectableItem.ts +++ b/packages/react-aria/src/selection/useSelectableItem.ts @@ -209,11 +209,15 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte ]); isDisabled = isDisabled || manager.isDisabled(key); + // An item may be disabled for interaction (no selection/action, `aria-disabled`) yet remain + // focusable, following the ARIA APG "focusability of disabled controls" pattern. In that case we + // keep the roving tabindex/focus wiring even though the item stays otherwise non-interactive. + let isFocusableWhenDisabled = isDisabled && (manager.isFocusableWhenDisabled?.(key) ?? false); // Set tabIndex to 0 if the element is focused, or -1 otherwise so that only the last focused // item is tabbable. If using virtual focus, don't set a tabIndex at all so that VoiceOver // on iOS 14 doesn't try to move real DOM focus to the item anyway. let itemProps: SelectableItemAria['itemProps'] = {}; - if (!shouldUseVirtualFocus && !isDisabled) { + if (!shouldUseVirtualFocus && (!isDisabled || isFocusableWhenDisabled)) { itemProps = { tabIndex: key === manager.focusedKey ? 0 : -1, onFocus(e) { @@ -222,6 +226,10 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte } } }; + // Note: a focusable disabled item stays non-interactive. Selection/actions are suppressed via + // `allowsSelection`/`allowsActions` below, and native link (``) navigation is already + // blocked by the unconditional link `onClick` preventDefault near the return statement (since + // `pressProps` is never merged for a disabled item, no router open occurs). } else if (isDisabled) { itemProps.onMouseDown = e => { // Prevent focus going to the body when clicking on a disabled item. @@ -230,10 +238,10 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte } useEffect(() => { - if (isDisabled && manager.focusedKey === key) { + if (isDisabled && !isFocusableWhenDisabled && manager.focusedKey === key) { manager.setFocusedKey(null); } - }, [manager, isDisabled, key]); + }, [manager, isDisabled, isFocusableWhenDisabled, key]); // With checkbox selection, onAction (i.e. navigation) becomes primary, and occurs on a single click of the row. // Clicking the checkbox enters selection mode, after which clicking anywhere on any row toggles selection for that row. diff --git a/packages/react-aria/src/tabs/TabsKeyboardDelegate.ts b/packages/react-aria/src/tabs/TabsKeyboardDelegate.ts index 04b6773c45d..91456ac1c61 100644 --- a/packages/react-aria/src/tabs/TabsKeyboardDelegate.ts +++ b/packages/react-aria/src/tabs/TabsKeyboardDelegate.ts @@ -45,7 +45,12 @@ export class TabsKeyboardDelegate implements KeyboardDelegate { } private isDisabled(key: Key) { - return this.disabledKeys.has(key) || !!this.collection.getItem(key)?.props?.isDisabled; + let item = this.collection.getItem(key); + // Tabs that opt into staying focusable while disabled remain navigable. + if (item?.props?.allowFocusWhenDisabled) { + return false; + } + return this.disabledKeys.has(key) || !!item?.props?.isDisabled; } getFirstKey(): Key | null { diff --git a/packages/react-aria/src/tabs/useTab.ts b/packages/react-aria/src/tabs/useTab.ts index f560aa977d2..22bd89957bb 100644 --- a/packages/react-aria/src/tabs/useTab.ts +++ b/packages/react-aria/src/tabs/useTab.ts @@ -60,6 +60,10 @@ export function useTab( let isSelected = key === selectedKey; let isDisabled = propsDisabled || state.isDisabled || state.selectionManager.isDisabled(key); + // A tab disabled per-key may opt into staying focusable (ARIA APG "focusability of disabled + // controls"). It stays non-selectable with `aria-disabled`, but keeps its roving tabindex. + let isFocusableWhenDisabled = + isDisabled && !state.isDisabled && (manager.isFocusableWhenDisabled?.(key) ?? false); let item = state.collection.getItem(key); let {itemProps, isPressed} = useSelectableItem({ selectionManager: manager, @@ -94,7 +98,7 @@ export function useTab( 'aria-selected': isSelected, 'aria-disabled': isDisabled || undefined, 'aria-controls': isSelected ? tabPanelId : undefined, - tabIndex: isDisabled ? undefined : tabIndex, + tabIndex: isDisabled && !isFocusableWhenDisabled ? undefined : tabIndex, role: 'tab' }), isSelected, diff --git a/packages/react-stately/src/selection/SelectionManager.ts b/packages/react-stately/src/selection/SelectionManager.ts index d6ff15c5b5e..01816f5b190 100644 --- a/packages/react-stately/src/selection/SelectionManager.ts +++ b/packages/react-stately/src/selection/SelectionManager.ts @@ -533,6 +533,10 @@ export class SelectionManager implements MultipleSelectionManager { ); } + isFocusableWhenDisabled(key: Key): boolean { + return !!this.collection.getItem(key)?.props?.allowFocusWhenDisabled; + } + isLink(key: Key): boolean { return !!this.collection.getItem(key)?.props?.href; } diff --git a/packages/react-stately/src/selection/types.ts b/packages/react-stately/src/selection/types.ts index d2f337be638..481ebb392a4 100644 --- a/packages/react-stately/src/selection/types.ts +++ b/packages/react-stately/src/selection/types.ts @@ -115,6 +115,12 @@ export interface MultipleSelectionManager extends FocusState { canSelectItem(key: Key): boolean; /** Returns whether the given key is non-interactive, i.e. both selection and actions are disabled. */ isDisabled(key: Key): boolean; + /** + * Returns whether the given key should remain focusable while disabled, following the + * ARIA APG "focusability of disabled controls" pattern. Such items are still non-interactive + * (no selection/action, `aria-disabled`) but remain reachable via keyboard navigation. + */ + isFocusableWhenDisabled?(key: Key): boolean; /** Sets the selection behavior for the collection. */ setSelectionBehavior(selectionBehavior: SelectionBehavior): void; /** Returns whether the given key is a hyperlink. */