Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement multi-tab-stop #7215

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
87 changes: 60 additions & 27 deletions packages/@react-aria/selection/src/useSelectableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ export interface AriaSelectableCollectionOptions {

export interface SelectableCollectionAria {
/** Props for the collection element. */
collectionProps: DOMAttributes
collectionProps: DOMAttributes,

isKeyboard: RefObject<boolean>,
isForwardTab: RefObject<boolean>,
isFocusWithin: RefObject<boolean>
}

/**
Expand Down Expand Up @@ -274,7 +278,43 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
}
break;
case 'Tab': {
if (!allowsTabNavigation) {
if (allowsTabNavigation) {
let item = document.activeElement?.closest('[data-key]');
let walker = getFocusableTreeWalker(item, {tabbable: true});
let next: FocusableElement;
let last: FocusableElement;

do {
last = walker.lastChild() as FocusableElement;
if (last) {
next = last;
}
} while (last);

// tab behind item boundary
if (!e.shiftKey && document.activeElement === next) {
let walker = getFocusableTreeWalker(ref.current, {tabbable: true});
let next: FocusableElement;
let last: FocusableElement;

do {
last = walker.lastChild() as FocusableElement;
if (last) {
next = last;
}
} while (last);

focusWithoutScrolling(next);
}

// tab before item boundary
if (e.shiftKey && document.activeElement === item) {
let walker = getFocusableTreeWalker(ref.current, {tabbable: true});
let before = walker.firstChild() as FocusableElement;

focusWithoutScrolling(before);
}
} else {
// There may be elements that are "tabbable" inside a collection (e.g. in a grid cell).
// However, collections should be treated as a single tab stop, with arrow key navigation internally.
// We don't control the rendering of these, so we can't override the tabIndex to prevent tabbing.
Expand All @@ -298,8 +338,8 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
focusWithoutScrolling(next);
}
}
break;
}
break;
}
}
};
Expand All @@ -314,18 +354,15 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
};
});

let isKeyboard = useRef(false);
let isForwardTab = useRef(true);
let isFocusWithin = useRef(false);
let onFocus = (e: FocusEvent) => {
if (manager.isFocused) {
// If a focus event bubbled through a portal, reset focus state.
if (!e.currentTarget.contains(e.target)) {
// If a focus event bubbled through a portal, reset focus state and ignore.
if (!e.currentTarget.contains(e.target)) {
if (manager.isFocused) {
manager.setFocused(false);
}

return;
}

// Focus events can bubble through portals. Ignore these events.
if (!e.currentTarget.contains(e.target)) {
return;
}

Expand Down Expand Up @@ -355,21 +392,14 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
scrollRef.current.scrollLeft = scrollPos.current.left;
}

if (manager.focusedKey != null) {
// Refocus and scroll the focused item into view if it exists within the scrollable region.
let element = scrollRef.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
if (element) {
// This prevents a flash of focus on the first/last element in the collection, or the collection itself.
if (!element.contains(document.activeElement)) {
focusWithoutScrolling(element);
}

let modality = getInteractionModality();
if (modality === 'keyboard') {
scrollIntoViewport(element, {containingElement: ref.current});
}
}
}
isForwardTab.current = Boolean(
e.relatedTarget && (e.currentTarget.compareDocumentPosition(e.relatedTarget) &
Node.DOCUMENT_POSITION_PRECEDING));

isFocusWithin.current = ref.current.contains(e.relatedTarget);

let modality = getInteractionModality();
isKeyboard.current = modality === 'keyboard';
};

let onBlur = (e) => {
Expand Down Expand Up @@ -482,6 +512,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
}

return {
isKeyboard,
isForwardTab,
isFocusWithin,
collectionProps: {
...handlers,
tabIndex
Expand Down
36 changes: 33 additions & 3 deletions packages/@react-aria/selection/src/useSelectableItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import {DOMAttributes, FocusableElement, Key, LongPressEvent, PressEvent, RefObject} from '@react-types/shared';
import {focusSafely} from '@react-aria/focus';
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils';
import {mergeProps, openLink, useRouter} from '@react-aria/utils';
import {MultipleSelectionManager} from '@react-stately/selection';
Expand Down Expand Up @@ -68,7 +68,19 @@ export interface SelectableItemOptions {
* - 'none': links are disabled for both selection and actions (e.g. handled elsewhere).
* @default 'action'
*/
linkBehavior?: 'action' | 'selection' | 'override' | 'none'
linkBehavior?: 'action' | 'selection' | 'override' | 'none',
/**
* Whether last focus was caused by keyboard or not.
*/
isKeyboard?: RefObject<boolean>,
/**
* Whether tabbing backwards or forwards.
*/
isForwardTab?: RefObject<boolean>,
/**
* Whether focus is contained or not.
*/
isFocusWithin?: RefObject<boolean>
}

export interface SelectableItemStates {
Expand Down Expand Up @@ -117,7 +129,10 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
isDisabled,
onAction,
allowsDifferentPressOrigin,
linkBehavior = 'action'
linkBehavior = 'action',
isKeyboard,
isForwardTab,
isFocusWithin
} = options;
let router = useRouter();

Expand Down Expand Up @@ -164,6 +179,21 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
if (isFocused && manager.isFocused && !shouldUseVirtualFocus) {
if (focus) {
focus();
} else if (
isForwardTab && !isForwardTab.current &&
isFocusWithin && !isFocusWithin?.current &&
isKeyboard && isKeyboard?.current) {
let walker = getFocusableTreeWalker(ref.current, {tabbable: true});
let next: FocusableElement;
let last: FocusableElement;
do {
last = walker.lastChild() as FocusableElement;
if (last) {
next = last;
}
} while (last);

focusSafely(next);
} else if (document.activeElement !== ref.current) {
focusSafely(ref.current);
}
Expand Down