Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/react-aria-components/src/ListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,12 @@ export interface ListBoxItemProps<T = object>
'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.
Expand Down
6 changes: 6 additions & 0 deletions packages/react-aria-components/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,12 @@ export interface MenuItemProps<T = object>
'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. */
Expand Down
6 changes: 6 additions & 0 deletions packages/react-aria-components/src/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-aria-components/src/Tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,12 @@ export interface TreeItemProps<T = object>
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.
Expand Down
54 changes: 54 additions & 0 deletions packages/react-aria-components/test/ListBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ListBox aria-label="Test" selectionMode="single" onSelectionChange={onSelectionChange}>
<ListBoxItem id="cat">Cat</ListBoxItem>
<ListBoxItem id="dog" isDisabled allowFocusWhenDisabled>
Dog
</ListBoxItem>
<ListBoxItem id="kangaroo">Kangaroo</ListBoxItem>
</ListBox>
);

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(
<ListBox
aria-label="Test"
selectionMode="single"
disabledKeys={['dog']}
onSelectionChange={onSelectionChange}>
<ListBoxItem id="cat">Cat</ListBoxItem>
<ListBoxItem id="dog" allowFocusWhenDisabled>
Dog
</ListBoxItem>
<ListBoxItem id="kangaroo">Kangaroo</ListBoxItem>
</ListBox>
);

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'}
Expand Down
46 changes: 46 additions & 0 deletions packages/react-aria-components/test/Menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Menu aria-label="Animals" onAction={onAction}>
<MenuItem id="cat">Cat</MenuItem>
<MenuItem id="dog" isDisabled allowFocusWhenDisabled>
Dog
</MenuItem>
<MenuItem id="kangaroo">Kangaroo</MenuItem>
</Menu>
);
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(
<Menu aria-label="Animals" disabledKeys={['dog']} onAction={onAction}>
<MenuItem id="cat">Cat</MenuItem>
<MenuItem id="dog" allowFocusWhenDisabled>
Dog
</MenuItem>
<MenuItem id="kangaroo">Kangaroo</MenuItem>
</Menu>
);
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];
Expand Down
58 changes: 58 additions & 0 deletions packages/react-aria-components/test/Tabs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Tabs keyboardActivation="manual" onSelectionChange={onSelectionChange}>
<TabList aria-label="Test">
<Tab id="a">A</Tab>
<Tab id="b" isDisabled allowFocusWhenDisabled>
B
</Tab>
<Tab id="c">C</Tab>
</TabList>
<TabPanel id="a">A</TabPanel>
<TabPanel id="b">B</TabPanel>
<TabPanel id="c">C</TabPanel>
</Tabs>
);
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(
<Tabs keyboardActivation="manual" disabledKeys={['b']} onSelectionChange={onSelectionChange}>
<TabList aria-label="Test">
<Tab id="a">A</Tab>
<Tab id="b" allowFocusWhenDisabled>
B
</Tab>
<Tab id="c">C</Tab>
</TabList>
<TabPanel id="a">A</TabPanel>
<TabPanel id="b">B</TabPanel>
<TabPanel id="c">C</TabPanel>
</Tabs>
);
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(
<Tabs>
Expand Down
29 changes: 29 additions & 0 deletions packages/react-aria-components/test/Tree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,35 @@ describe('Tree', () => {
expect(onSelectionChange).toHaveBeenCalledTimes(0);
});

it('should support allowFocusWhenDisabled on rows (focusable but not actionable)', async () => {
let {getAllByRole} = render(
<StaticTree
treeProps={{
selectionMode: 'none',
disabledBehavior: 'all',
onAction,
disabledKeys: ['projects']
}}
rowProps={{allowFocusWhenDisabled: true}}
/>
);

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(
<StaticTree treeProps={{selectionMode: 'multiple', escapeKeyBehavior: 'none'}} />
Expand Down
4 changes: 3 additions & 1 deletion packages/react-aria/src/grid/GridKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> 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
);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/react-aria/src/selection/ListKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ export class ListKeyboardDelegate<T> 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
);
}

Expand Down
14 changes: 11 additions & 3 deletions packages/react-aria/src/selection/useSelectableItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 (`<a href>`) 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.
Expand All @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion packages/react-aria/src/tabs/TabsKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ export class TabsKeyboardDelegate<T> 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 {
Expand Down
6 changes: 5 additions & 1 deletion packages/react-aria/src/tabs/useTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export function useTab<T>(
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,
Expand Down Expand Up @@ -94,7 +98,7 @@ export function useTab<T>(
'aria-selected': isSelected,
'aria-disabled': isDisabled || undefined,
'aria-controls': isSelected ? tabPanelId : undefined,
tabIndex: isDisabled ? undefined : tabIndex,
tabIndex: isDisabled && !isFocusableWhenDisabled ? undefined : tabIndex,
role: 'tab'
}),
isSelected,
Expand Down
4 changes: 4 additions & 0 deletions packages/react-stately/src/selection/SelectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions packages/react-stately/src/selection/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down