diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 162837b1ba4..13ef031b6fa 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -328,7 +328,10 @@ const threadItemBase = style({ borderRadius: 'default' }); -export interface ThreadItemProps extends Pick { +export interface ThreadItemProps extends Pick< + GridListItemProps, + 'children' | 'textValue' | 'focusMode' | 'allowsArrowNavigation' +> { /** * Spectrum-defined styles, returned by the `style()` macro. */ @@ -340,7 +343,15 @@ export interface ThreadItemProps extends Pick mergeStyles(threadItemBase({...renderProps}), styles)}> {children} diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index ae2c03cd45a..aecb2e07889 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -490,6 +490,8 @@ export function StreamingChat() { // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) return ( @@ -621,7 +623,7 @@ export function VirtualizedChat() { let message = isPending ? 'Generating response' : 'Response generated'; return ( - + {message} diff --git a/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx new file mode 100644 index 00000000000..02b72b5dbcf --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/SideNav.stories.tsx @@ -0,0 +1,408 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {ActionMenu} from '../src/ActionMenu'; +import {Collection} from 'react-aria/Collection'; +import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import {MenuItem} from '../src/Menu'; +import type {Meta, StoryObj} from '@storybook/react'; +import {ReactElement} from 'react'; +import {screen, userEvent} from 'storybook/test'; +import { + SideNav, + SideNavHeader, + SideNavItem, + SideNavItemContent, + SideNavItemLink, + SideNavProps, + SideNavSection +} from '../src/SideNav'; +import {Text} from '../src/Content'; + +const meta: Meta = { + component: SideNav, + parameters: { + chromaticProvider: {disableAnimations: true} + }, + title: 'S2 Chromatic/SideNav' +}; + +export default meta; + +function SideNavExample(props: SideNavProps): ReactElement { + return ( +
+ + + + + Your files + + + + + + + + Your libraries + + + + + + Projects-1 + + + + + + Projects-1A + + + + + + + + Projects-2 + + + + + + + Projects-3 + + + + + +
+ ); +} + +export const Static: StoryObj = { + render: args => +}; + +// A nested item is selected while its collapsed ancestor shows the "has selected descendant" +// indicator (the parent row's selected state without the child being visible). +export const CollapsedSelectedAncestor: StoryObj = { + ...Static, + args: { + expandedKeys: [], + selectedRoute: '/projects-2' + } +}; + +function SideNavSectionsExample(props: SideNavProps): ReactElement { + return ( +
+ + + Photography + + + + Your files + + + + + + + Work + + + + Your libraries + + + + + + Projects-1 + + + + + + + Projects-2 + + + + + + +
+ ); +} + +export const Sections: StoryObj = { + render: args => +}; + +interface SideNavItemType { + id: string; + name: string; + href?: string; + childItems?: SideNavItemType[]; +} + +let rows: SideNavItemType[] = [ + {id: 'Photos', name: 'Your files', href: '/Photos'}, + { + id: 'projects', + name: 'Projects', + href: '/projects', + childItems: [ + {id: 'projects-1', name: 'Projects-1', href: '/projects-1'}, + { + id: 'projects-2', + name: 'Projects-2', + href: '/projects-2', + childItems: [ + {id: 'projects-2A', name: 'Projects-2A', href: '/projects-2A'}, + {id: 'projects-2B', name: 'Projects-2B', href: '/projects-2B'}, + {id: 'projects-2C', name: 'Projects-2C', href: '/projects-2C'} + ] + }, + {id: 'projects-3', name: 'Projects-3', href: '/projects-3'} + ] + }, + { + id: 'reports', + name: 'Reports', + href: '/reports', + childItems: [{id: 'reports-1', name: 'Reports-1', href: '/reports-1'}] + } +]; + +const DynamicSideNavItem = (props: SideNavItemType): ReactElement => { + let {id, name, href, childItems} = props; + return ( + + + + {name} + + + + {(item: SideNavItemType) => } + + + ); +}; + +function SideNavDynamicExample(props: SideNavProps): ReactElement { + return ( +
+ + {(item: SideNavItemType) => } + +
+ ); +} + +export const Dynamic: StoryObj = { + render: args => +}; + +export const Hovered: StoryObj = { + ...Static, + play: async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + // Hover a row that isn't the selected one (selectedRoute is /projects-2) so the hover + // indicator is distinct from the selection indicator. Hover the row itself, not the inner + // link + let row = screen.getByRole('row', {name: 'Projects-3'}); + await userEvent.hover(row); + // Give the hover state time to settle before the screenshot is captured. + await new Promise(resolve => setTimeout(resolve, 100)); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; + +export const Focused: StoryObj = { + ...Static, + play: async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + await userEvent.tab(); + // Give the focus state time to settle before the screenshot is captured. + await new Promise(resolve => setTimeout(resolve, 100)); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; + +const DynamicSideNavItemWithActions = (props: SideNavItemType): ReactElement => { + let {id, name, href, childItems} = props; + return ( + + + + {name} + + + + Edit + + + Delete + + + + + {(item: SideNavItemType) => } + + + ); +}; + +function SideNavDynamicExampleWithActions(props: SideNavProps): ReactElement { + return ( +
+ + {(item: SideNavItemType) => } + +
+ ); +} + +export const FocusedActionMenu: StoryObj = { + render: args => , + play: async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + await userEvent.tab(); + await new Promise(resolve => setTimeout(resolve, 100)); + await userEvent.tab(); + await new Promise(resolve => setTimeout(resolve, 100)); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; + +function SideNavLongTextExample(props: SideNavProps): ReactElement { + return ( +
+ + + + + Your files but really long text so that it wraps + + + + + + + + Your libraries but really long text so that it wraps + + + + + + Projects-1 but really long text so that it wraps + + + + + + Projects-1A but really long text so that it wraps + + + + + + + + Projects-2 but really long text so that it wraps + + + + + + + Projects-3 but really long text so that it wraps + + + + + +
+ ); +} + +export const LongText: StoryObj = { + render: args => , + play: async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + await userEvent.tab(); + await new Promise(resolve => setTimeout(resolve, 100)); + }, + parameters: { + chromaticProvider: { + colorSchemes: ['light'], + backgrounds: ['base'], + locales: ['en-US'], + disableAnimations: true + } + } +}; diff --git a/packages/@react-spectrum/s2/exports/SideNav.ts b/packages/@react-spectrum/s2/exports/SideNav.ts new file mode 100644 index 00000000000..bc5c9090112 --- /dev/null +++ b/packages/@react-spectrum/s2/exports/SideNav.ts @@ -0,0 +1,20 @@ +export { + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, + SideNavSection, + SideNavHeader +} from '../src/SideNav'; +export {Collection} from 'react-aria/Collection'; +export type { + SideNavProps, + SideNavItemProps, + SideNavItemContentProps, + SideNavItemLinkProps, + SideNavSectionProps, + SideNavHeaderProps +} from '../src/SideNav'; +export type {Key} from '@react-types/shared'; + +export {Text} from '../src/Content'; diff --git a/packages/@react-spectrum/s2/exports/index.ts b/packages/@react-spectrum/s2/exports/index.ts index d6ded1a4569..f3b4ac44f67 100644 --- a/packages/@react-spectrum/s2/exports/index.ts +++ b/packages/@react-spectrum/s2/exports/index.ts @@ -120,6 +120,14 @@ export { SegmentedControlContext } from '../src/SegmentedControl'; export {SelectBox, SelectBoxGroup, SelectBoxGroupContext} from '../src/SelectBoxGroup'; +export { + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, + SideNavSection, + SideNavHeader +} from '../src/SideNav'; export {Slider, SliderContext} from '../src/Slider'; export {Skeleton, useIsSkeleton} from '../src/Skeleton'; export {SkeletonCollection} from '../src/SkeletonCollection'; @@ -259,6 +267,14 @@ export type {SelectBoxProps, SelectBoxGroupProps} from '../src/SelectBoxGroup'; export type {SliderProps} from '../src/Slider'; export type {RangeCalendarProps} from '../src/RangeCalendar'; export type {RangeSliderProps} from '../src/RangeSlider'; +export type { + SideNavProps, + SideNavItemProps, + SideNavItemContentProps, + SideNavItemLinkProps, + SideNavSectionProps, + SideNavHeaderProps +} from '../src/SideNav'; export type {SkeletonProps} from '../src/Skeleton'; export type {SkeletonCollectionProps} from '../src/SkeletonCollection'; export type {StatusLightProps} from '../src/StatusLight'; diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx new file mode 100644 index 00000000000..ad8ca063edb --- /dev/null +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -0,0 +1,748 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {ActionButtonGroupContext} from './ActionButtonGroup'; +import {ActionMenuContext} from './ActionMenu'; +import {baseColor, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; +import {Button, ButtonContext} from 'react-aria-components/Button'; +import {centerBaseline} from './CenterBaseline'; +import Chevron from '../ui-icons/Chevron'; +import { + Collection, + DOMRef, + forwardRefType, + GlobalDOMAttributes, + Key, + Node, + RouterOptions +} from '@react-types/shared'; +import { + createContext, + forwardRef, + ReactNode, + RefObject, + useContext, + useEffect, + useRef, + useState +} from 'react'; +import { + getAllowedOverrides, + StylesPropWithHeight, + UnsafeStyles +} from './style-utils' with {type: 'macro'}; +import {GridListHeaderProps, GridListSectionProps} from 'react-aria-components/GridList'; +import {IconContext} from './Icon'; +import {Link} from 'react-aria-components/Link'; +import {Provider, useContextProps} from 'react-aria-components/slots'; +import { + TreeItemProps as RACTreeItemProps, + TreeProps as RACTreeProps, + Tree, + TreeHeader, + TreeItem, + TreeItemContent, + TreeItemContentProps, + TreeItemRenderProps, + TreeRenderProps, + TreeSection +} from 'react-aria-components/Tree'; +import {Text, TextContext} from './Content'; +import {TreeState} from 'react-stately/useTreeState'; +import {useDOMRef} from './useDOMRef'; +import {useLocale} from 'react-aria/I18nProvider'; +import {useScale} from './utils'; + +export interface SideNavProps + extends + Omit< + RACTreeProps, + | 'style' + | 'className' + | 'render' + | 'onRowAction' + | 'selectionBehavior' + | 'onScroll' + | 'onCellAction' + | 'onSelectionChange' + | 'selectedKeys' + | 'defaultSelectedKeys' + | 'disabledBehavior' + | 'selectionMode' + | 'escapeKeyBehavior' + | 'shouldSelectOnPressUp' + | 'disallowEmptySelection' + | 'renderEmptyState' + | 'keyboardNavigationBehavior' + | 'dragAndDropHooks' // To be implemented + | keyof GlobalDOMAttributes + >, + UnsafeStyles { + /** The route that is currently selected. */ + selectedRoute: string; + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StylesPropWithHeight; +} + +export interface SideNavItemProps extends Omit< + RACTreeItemProps, + | 'className' + | 'style' + | 'render' + | 'onClick' + | 'allowsArrowNavigation' + | 'focusMode' + | 'value' + | 'onAction' + | keyof GlobalDOMAttributes +> { + /** A string representation of the side nav item's contents, used for features like typeahead. */ + textValue: string; + /** Whether this item has children. */ + hasChildItems?: boolean; +} + +const sideNavWrapper = style( + { + minHeight: 0, + height: 'full', + minWidth: 160, + display: 'flex', + isolation: 'isolate', + disableTapHighlight: true, + position: 'relative', + overflow: 'clip', + '--indicator-level-padding': { + type: 'width', + value: { + // 4 (start gap) + 10 (drag handle) + (hasCheckbox ? 16 + 8 : 0) + 40 (expand button) + // keep in sync with treeCellGrid gridTemplateColumns + default: 54 + } + } + }, + getAllowedOverrides({height: true}) +); + +// TODO: the below is needed so the borders of the top and bottom row isn't cut off if the TreeView is wrapped within a container by always reserving the 2px needed for the +// keyboard focus ring. Perhaps find a different way of rendering the outlines since the top of the item doesn't +// scroll into view due to how the ring is offset. Alternatively, have the tree render the top/bottom outline like it does in Listview +const tree = style({ + ...focusRing(), + outlineOffset: -2, // make certain we are visible inside overflow hidden containers + userSelect: 'none', + minHeight: 0, + minWidth: 0, + width: 'full', + height: 'full', + overflowY: 'auto', + overflowX: 'hidden', + boxSizing: 'border-box', + '--indent': { + type: 'width', + value: 16 + } +}); + +interface InternalSideNavContextValue { + /** The route that is currently selected. */ + selectedRoute?: string; + /** The last route the focused key was synced to; dedupes the focus sync across items. */ + syncedRouteRef?: RefObject; +} +let InternalSideNavContext = createContext({}); + +/** + * A SideNav provides users with a way to navigate nested hierarchical set of links. + */ +export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function SideNav( + props: SideNavProps, + ref: DOMRef +) { + let {children, UNSAFE_className, UNSAFE_style, selectedRoute, ...rest} = props; + + let domRef = useDOMRef(ref); + + // Tracks the last route we moved the focused key to, so the focus sync (driven from + // RouteFocusSync, which has the built collection) only runs when the route actually changes + let syncedRouteRef = useRef(undefined); + + return ( +
+ + tree(renderProps)} + selectionMode="none" + keyboardNavigationBehavior="tab"> + {children} + + +
+ ); +}); + +const treeRow = style({ + outlineStyle: 'none', + position: 'relative', + display: 'flex', + minHeight: 32, + width: 'full', + boxSizing: 'border-box', + font: 'ui', + color: { + default: baseColor('neutral-subdued'), + forcedColors: 'ButtonText' + }, + cursor: { + default: 'default', + isLink: 'pointer' + }, + borderRadius: 'sm', + marginTop: { + default: space(6), + ':first-child': 0 + } +}); + +const treeCellGrid = style({ + display: 'grid', + width: 'full', + minHeight: 'full', + boxSizing: 'border-box', + alignContent: 'center', + alignItems: 'center', + gridTemplateColumns: [12, 'auto', 'auto', '1fr', 'auto', 'auto'], + gridTemplateRows: '1fr', + gridTemplateAreas: ['. level-padding icon content actions actionmenu'], + paddingEnd: 4, // account for any focus rings on the last item in the cell + color: { + default: baseColor('neutral-subdued'), + isSelected: baseColor('neutral'), + isDescendantSelected: baseColor('neutral'), + isDisabled: { + default: 'gray-400', + forcedColors: 'GrayText' + }, + forcedColors: 'ButtonText' + }, + fontWeight: { + isDescendantSelected: 'bold' + }, + forcedColorAdjust: 'none' +}); + +const treeIcon = style({ + gridArea: 'icon', + marginEnd: 'text-to-visual', + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } +}); + +const treeContent = style({ + gridArea: 'content', + paddingY: '[7px]' // padding shouldn't scale +}); + +let treeRowFocusRing = style({ + ...focusRing(), + outlineOffset: -2, + outlineWidth: 2, + outlineColor: { + default: 'focus-ring', + forcedColors: 'ButtonBorder' + }, + position: 'absolute', + inset: 0, + top: 0, + bottom: 0, + borderRadius: 'default', // tokens say 12... but that seems a lot, should it match selection in other collections? + zIndex: 1, + pointerEvents: 'none' +}); + +const treeRowLink = style({ + display: 'grid', + gridArea: 'content', + gridTemplateColumns: ['auto', '1fr'], + gridTemplateAreas: ['icon content'], + alignItems: 'center', + minWidth: 0, + outlineStyle: 'none', + textDecoration: 'none', + color: 'inherit', + cursor: { + default: 'pointer', + isDisabled: 'default' + } +}); + +const treeActions = style({ + gridArea: 'actions', + marginStart: 2, + marginEnd: 4 +}); + +const treeActionMenu = style({ + gridArea: 'actionmenu' +}); + +const SideNavItemLinkContext = createContext<{ + isDisabled?: boolean; + href?: string; + hrefLang?: string; + target?: string; + rel?: string; + download?: string | boolean; + ping?: string; + referrerPolicy?: ReferrerPolicy; + routerOptions?: RouterOptions; + // Lets the row track whether the link (as opposed to another focusable child like an ActionMenu + // trigger) is the focused element, so the row focus ring can follow the link specifically. + onFocusChange?: (isFocused: boolean) => void; +}>({}); + +export const SideNavItem = (props: SideNavItemProps): ReactNode => { + let {href, hrefLang, target, rel, download, ping, referrerPolicy, routerOptions, ...rest} = props; + + let hasLink = href != null && href.length > 0; + return ( + + treeRow(renderProps)} + /> + + ); +}; + +export interface SideNavItemContentProps extends Omit { + /** Rendered contents of the side nav item or child items. */ + children: ReactNode; +} + +const indicator = style<{isDisabled: boolean; isSelected: boolean; isHovered: boolean}>({ + position: 'absolute', + display: { + default: 'none', + isSelected: 'block', + isHovered: 'block' + }, + backgroundColor: { + isHovered: 'gray-400', + isSelected: 'gray-800', + isDisabled: 'disabled', + forcedColors: { + default: 'Highlight', + isDisabled: 'GrayText' + } + }, + height: 18, + width: '[2px]', + contain: 'strict', + top: '50%', + transform: 'translateY(-50%)', + '--indicator-indent': { + type: 'width', + value: 4 + }, + insetStart: + '[calc(calc(var(--tree-item-level, 0) - 1) * var(--indent) + var(--indicator-indent))]', + borderStyle: 'none', + borderRadius: 'full' +}); + +// Moves the tree's focused key to the item matching selectedRoute. Lives in items +// (rather than up in SideNav) because it needs the built collection off `state`, which only exists +// after the tree has rendered. Runs when the route or the collection changes; the shared +// syncedRouteRef dedupes across items so it fires once per route change. +// If the item is inside a collapsed parent, the focused key is moved to the closest +// visible ancestor instead of the hidden descendant. +function useRouteFocusSync({state}: {state: TreeState}): void { + let {selectedRoute, syncedRouteRef} = useContext(InternalSideNavContext); + let {collection, selectionManager, expandedKeys} = state; + useEffect(() => { + if ( + selectedRoute == null || + syncedRouteRef == null || + syncedRouteRef.current === selectedRoute + ) { + return; + } + let key = findKeyForRoute(collection, selectedRoute); + if (key != null) { + key = closestVisibleKey(collection, expandedKeys, key); + syncedRouteRef.current = selectedRoute; + selectionManager.setFocusedKey(key); + } + }, [selectedRoute, collection, expandedKeys, syncedRouteRef, selectionManager]); +} + +export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => { + let {children} = props; + let scale = useScale(); + let linkProps = useContext(SideNavItemLinkContext); + let {selectedRoute} = useContext(InternalSideNavContext); + + return ( + + {({ + isExpanded, + hasChildItems, + isDisabled, + isSelected, + id, + state, + isHovered, + isFocusVisible, + isFocusVisibleWithin + }) => { + return ( + + {children} + + ); + }} + + ); +}; + +const SideNavItemContentInner = props => { + let { + isExpanded, + hasChildItems, + isDisabled, + isSelected, + linkProps, + scale, + id, + state, + selectedRoute, + isHovered, + isFocusVisible, + isFocusVisibleWithin, + children + } = props; + // Whether the link within this row is the focused element (any modality). Combined with the + // keyboard-only isFocusVisibleWithin below, this lets the row focus ring follow the link + // specifically and not other focusable children (e.g. an ActionMenu trigger). + let [isLinkFocused, setLinkFocused] = useState(false); + + useRouteFocusSync({state}); + + let hasLink = linkProps.href != null && linkProps.href.length > 0; + + return ( + <> +
+
+
+
+ + {typeof children === 'string' ? {children} : children} + +
+ + + ); +}; + +interface ExpandableRowChevronProps { + isExpanded?: boolean; + isDisabled?: boolean; + isRTL?: boolean; + scale: 'medium' | 'large'; + isHidden?: boolean; +} + +const expandButton = style({ + gridArea: 'expand-button', + color: { + default: 'inherit', + isDisabled: { + default: 'disabled', + forcedColors: 'GrayText' + } + }, + height: 32, + width: 32, + display: 'flex', + flexWrap: 'wrap', + alignContent: 'center', + justifyContent: 'center', + outlineStyle: 'none', + cursor: 'default', + transform: { + isExpanded: { + default: 'rotate(90deg)', + isRTL: 'rotate(-90deg)' + } + }, + padding: 0, + transition: 'default', + backgroundColor: 'transparent', + borderStyle: 'none', + disableTapHighlight: true, + visibility: { + isHidden: 'hidden' + } +}); + +function ExpandableRowChevron(props: ExpandableRowChevronProps) { + let expandButtonRef = useRef(null); + let [fullProps, ref] = useContextProps( + {...props, slot: 'chevron'}, + expandButtonRef, + ButtonContext + ); + let {isExpanded, scale, isHidden} = fullProps; + let {direction} = useLocale(); + + return ( + + ); +} + +export interface SideNavSectionProps extends Omit< + GridListSectionProps, + 'value' | 'render' | 'style' | 'className' +> {} + +export function SideNavSection(props: SideNavSectionProps) { + return ( + + {props.children} + + ); +} + +export interface SideNavHeaderProps extends Omit< + GridListHeaderProps, + 'value' | 'render' | 'style' | 'className' +> {} + +export const SideNavHeader = (props: SideNavHeaderProps): ReactNode => { + return ( + + {props.children} + + ); +}; + +export interface SideNavItemLinkProps { + /** Rendered contents of the link. */ + children?: ReactNode; +} + +export const SideNavItemLink = (props: SideNavItemLinkProps): ReactNode => { + let {children} = props; + let {selectedRoute} = useContext(InternalSideNavContext); + let linkProps = useContext(SideNavItemLinkContext); + + return ( + + + {typeof children === 'string' ? {children} : children} + + + ); +}; + +// The collection key of the item whose href matches `route`, or null. getKeys() covers collapsed +// items too, and the href is stored as a data attribute so it doesn't trigger Tree's link handling. +function findKeyForRoute(collection: Collection>, route: string): Key | null { + for (let key of collection.getKeys()) { + if (collection.getItem(key)?.props?.href === route) { + return key; + } + } + return null; +} + +// Walks up from `key` to the closest ancestor that is actually rendered (i.e. all of its ancestors +// are expanded). Returns `key` unchanged when it is already visible. A collapsed ancestor hides +// everything beneath it, so the highest collapsed ancestor is the closest visible row. +function closestVisibleKey( + collection: Collection>, + expandedKeys: Set, + key: Key +): Key { + let target = key; + let node = collection.getItem(key); + while (node?.parentKey != null) { + if (!expandedKeys.has(node.parentKey)) { + target = node.parentKey; + } + node = collection.getItem(node.parentKey); + } + return target; +} + +// Cache so each row doesn't have to walk up the tree every time +let selectedAncestorsCache = new WeakMap< + Collection>, + {selection: unknown; ancestors: Set} +>(); + +// The set of collection keys that are ancestors of the item matching `selectedRoute`. +function getSelectedAncestors(state: TreeState, selectedRoute: string): Set { + let {collection} = state; + let cached = selectedAncestorsCache.get(collection); + if (cached && cached.selection === selectedRoute) { + return cached.ancestors; + } + + let matchKey = findKeyForRoute(collection, selectedRoute); + + let ancestors = new Set(); + let node = matchKey != null ? collection.getItem(matchKey) : null; + while (node?.parentKey != null && !ancestors.has(node.parentKey)) { + ancestors.add(node.parentKey); + node = collection.getItem(node.parentKey); + } + + selectedAncestorsCache.set(collection, {selection: selectedRoute, ancestors}); + return ancestors; +} + +// Whether the row `id` is an ancestor of the item matching `selectedRoute`, i.e. it has a +// selected descendant. Used to keep a collapsed parent styled when its selected child is hidden. +function hasSelectedDescendant( + id: Key | undefined, + state: TreeState, + selectedRoute: string | undefined +) { + if (id == null || selectedRoute == null || !state) { + return false; + } + return getSelectedAncestors(state, selectedRoute).has(id); +} diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 1737a6c5549..13c0cadbe35 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -448,6 +448,7 @@ export const TableView = forwardRef(function TableView( selectionStyle = 'checkbox', dragAndDropHooks, disabledBehavior = 'all', + keyboardNavigationBehavior = 'arrow', ...otherProps } = props; @@ -496,7 +497,8 @@ export const TableView = forwardRef(function TableView( setIsInResizeMode, selectionMode, selectionStyle, - disabledBehavior + disabledBehavior, + keyboardNavigationBehavior }), [ isQuiet, @@ -508,7 +510,8 @@ export const TableView = forwardRef(function TableView( setIsInResizeMode, selectionMode, selectionStyle, - disabledBehavior + disabledBehavior, + keyboardNavigationBehavior ] ); @@ -563,6 +566,7 @@ export const TableView = forwardRef(function TableView( onRowAction={onAction} dragAndDropHooks={dragAndDropHooks} disabledBehavior={disabledBehavior} + keyboardNavigationBehavior={keyboardNavigationBehavior} {...otherProps} selectedKeys={selectedKeys} defaultSelectedKeys={undefined} @@ -770,13 +774,17 @@ export interface ColumnProps extends Omit< * A column within a ``. */ export const Column = forwardRef(function Column(props: ColumnProps, ref: DOMRef) { - let {isQuiet} = useContext(InternalTableContext); + let {isQuiet, keyboardNavigationBehavior} = useContext(InternalTableContext); let {allowsResizing, children, align = 'start'} = props; let domRef = useDOMRef(ref); let isMenu = allowsResizing || !!props.menuItems; return ( {({isFocusVisible}) => ( <> - {selectionMode === 'single' && ( - <> - {isFocusVisible && } - - - )} + {isFocusVisible && } + {selectionMode === 'single' && } {selectionMode === 'multiple' && ( )} @@ -1650,7 +1656,7 @@ function EditableCellInner( let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); let dialogRef = useRef>(null); - let {density} = useContext(InternalTableContext); + let {density, keyboardNavigationBehavior} = useContext(InternalTableContext); let size: 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = 'M'; if (density === 'compact') { size = 'S'; @@ -1735,7 +1741,7 @@ function EditableCellInner( isPending: isSaving, isQuiet: !isSaving, size, - excludeFromTabOrder: true, + excludeFromTabOrder: keyboardNavigationBehavior === 'arrow', styles: style({ // TODO: really need access to display here instead, but not possible right now // will be addressable with displayOuter @@ -2206,7 +2212,8 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row( ref: DOMRef ) { let {selectionBehavior, selectionMode, allowsDragging} = useTableOptions(); - let {selectionStyle, ...tableVisualOptions} = useContext(InternalTableContext); + let {selectionStyle, keyboardNavigationBehavior, ...tableVisualOptions} = + useContext(InternalTableContext); let domRef = useDOMRef(ref); let isInFooter = useContext(FooterContext); @@ -2236,6 +2243,8 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row( {({isFocusVisibleWithinRow}) => !(otherProps.isDisabled && tableVisualOptions.disabledBehavior === 'all') && ( @@ -2249,8 +2258,12 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row( selectionStyle === 'checkbox' && ( // Not sure what we want to do with this className, in Cell it currently overrides the className that would have been applied. // The `spread` otherProps must be after className in Cell. - // @ts-ignore - + )} diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx new file mode 100644 index 00000000000..e105b07136d --- /dev/null +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -0,0 +1,536 @@ +/** + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {action} from 'storybook/actions'; +import {ActionMenu} from '../src/ActionMenu'; +import {Avatar} from '../src/Avatar'; +import {categorizeArgTypes, getActionArgs} from './utils'; +import {Collection} from 'react-aria/Collection'; +import Copy from '../s2wf-icons/S2_Icon_Copy_20_N.svg'; +import Cut from '../s2wf-icons/S2_Icon_Cut_20_N.svg'; +import {Divider} from '../src/Divider'; +import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import {Heading, Keyboard, Text} from '../src/Content'; +import {MenuItem} from '../src/Menu'; +import type {Meta, StoryObj} from '@storybook/react'; +import Paste from '../s2wf-icons/S2_Icon_Paste_20_N.svg'; +import React, {ReactElement, ReactNode, useRef, useState} from 'react'; +import {RouterProvider} from 'react-aria-components'; +import {SearchField} from '../src/SearchField'; +import { + SideNav, + SideNavHeader, + SideNavItem, + SideNavItemContent, + SideNavItemLink, + SideNavProps, + SideNavSection +} from '../src/SideNav'; +import {style} from '../style' with {type: 'macro'}; +import {useLandmark} from 'react-aria'; + +const events = ['onSelectionChange']; + +const meta: Meta = { + component: SideNav, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + args: {...getActionArgs(events)}, + argTypes: { + ...categorizeArgTypes('Events', [...events]), + children: {table: {disable: true}} + } +}; + +export default meta; + +type SideNavStoryObj = StoryObj; + +// Treats the SideNav as navigation: activating a link is intercepted by the RouterProvider so the +// page doesn't actually navigate, and the activated href drives the controlled selected key. Any +// non-link selection (e.g. keyboard or items without a link) flows through onSelectionChange. +function RoutedSideNav(props: SideNavProps & {children: ReactNode}) { + let {children, ...args} = props; + let [selectedRoute, setSelectedRoute] = useState(props.selectedRoute ?? '/Photos'); + + let updateSelection = (href: string) => { + setSelectedRoute(href); + }; + + return ( + + + {children} + + + ); +} + +const SideNavExampleStatic = args => ( + + + + + Your files + + + + + + + + Your libraries + + + + + + Projects-1 + + + + + + Projects-1A + + + + + + + + Projects-2 + + + + + + + Projects-3 + + + + + +); + +export const Example: SideNavStoryObj = { + render: args => ( +
+ +
+ ), + args: {} +}; + +export const ExampleCustomWidth: SideNavStoryObj = { + render: SideNavExampleStatic, + args: { + styles: style({width: 400}) + }, + parameters: { + docs: { + disable: true + } + } +}; + +export const ExampleAutoWidth: SideNavStoryObj = { + render: SideNavExampleStatic, + args: { + styles: style({width: 'auto'}) + }, + parameters: { + docs: { + disable: true + } + } +}; + +const SideNavSectionsExample = args => ( + + + Photography + + + + Your files + + + + + + + Work + + + + Your libraries + + + + + + Projects-1 + + + + + + Projects-1A + + + + + + + + Projects-2 + + + + + + + Projects-3 + + + + + + +); + +export const SideNavSections = { + render: SideNavSectionsExample, + args: { + selectionMode: 'single' + } +}; + +interface SideNavItemType { + id: string; + name: string; + href?: string; + childItems?: SideNavItemType[]; +} + +let dynamicItems: SideNavItemType[] = [ + {id: 'Photos', name: 'Your files', href: '/Photos'}, + { + id: 'projects', + name: 'Projects', + href: '/projects', + childItems: [ + {id: 'projects-1', name: 'Projects-1', href: '/projects-1'}, + { + id: 'projects-2', + name: 'Projects-2', + childItems: [ + {id: 'projects-2A', name: 'Projects-2A', href: '/projects-2A'}, + {id: 'projects-2B', name: 'Projects-2B', href: '/projects-2B'}, + {id: 'projects-2C', name: 'Projects-2C', href: '/projects-2C'}, + {id: 'projects-2D', name: 'Projects-2D', href: '/projects-2D'}, + {id: 'projects-2E', name: 'Projects-2E', href: '/projects-2E'}, + {id: 'projects-2F', name: 'Projects-2F', href: '/projects-2F'} + ] + } + ] + }, + { + id: 'reports', + name: 'Reports', + href: '/reports', + childItems: [{id: 'reports-1', name: 'Reports-1', href: '/reports-1'}] + } +]; + +const DynamicSideNavItem = (props: SideNavItemType): ReactElement => { + let {id, name, href, childItems} = props; + return ( + + + {href && href.length > 0 && ( + + {name} + + )} + {(!href || href.length === 0) && {name}} + + + {(item: SideNavItemType) => } + + + ); +}; + +const SideNavDynamicExample = (args: SideNavProps): ReactElement => { + let [selectedRoute, setSelectedRoute] = useState('/Photos'); + return ( +
+ + + {(item: SideNavItemType) => } + + +
+ ); +}; +type SideNavDynamicStoryObj = StoryObj; + +export const SideNavDynamic: SideNavDynamicStoryObj = { + render: args => , + args: {} +}; + +const DynamicWithActionsSideNavItem = (props: SideNavItemType): ReactElement => { + let {id, name, href, childItems} = props; + return ( + + + {href && href.length > 0 && ( + + {name} + + )} + {(!href || href.length === 0) && {name}} + + alert('copy')}> + + Copy + Copy the selected text + ⌘C + + alert('cut')}> + + Cut + Cut the selected text + ⌘X + + alert('paste')}> + + Paste + Paste the copied text + ⌘V + + + + + {(item: SideNavItemType) => } + + + ); +}; + +const SideNavDynamicWithActionsExample = (args: SideNavProps): ReactElement => { + let [selectedRoute, setSelectedRoute] = useState('/Photos'); + return ( +
+ + + {(item: SideNavItemType) => } + + +
+ ); +}; +type SideNavDynamicWithActionsStoryObj = StoryObj; + +export const SideNavDynamicWithActions: SideNavDynamicWithActionsStoryObj = { + render: args => , + args: {}, + name: 'WithActions' +}; + +function Banner(props: {children: ReactNode}): ReactElement { + let ref = useRef(null); + let {landmarkProps} = useLandmark({...props, role: 'banner', 'aria-label': 'Global'}, ref); + return ( +
+ {props.children} +
+ ); +} + +function Navigation(props: {'aria-label'?: string; children: ReactNode}): ReactElement { + let ref = useRef(null); + let {landmarkProps} = useLandmark({...props, role: 'navigation'}, ref); + return ( + + ); +} + +function Main(props: {'aria-label'?: string; children: ReactNode}): ReactElement { + let ref = useRef(null); + let {landmarkProps} = useLandmark({...props, role: 'main'}, ref); + return ( +
+ {props.children} +
+ ); +} + +const AppLayoutExample = (args: SideNavProps): ReactElement => { + let [selectedRoute, setSelectedRoute] = useState('/files'); + return ( + +
+ + + Acme Cloud + +
+ +
+ +
+
+ + + + + + Home + + + + + + + Your files + + + + + + + + Shared with you + + + + + + + Projects + + + + + + Website redesign + + + + + + + Mobile app + + + + + + + + Archive + + + + + +
+ + Workspace + +

+ This area is the main landmark and the side navigation on the left is wrapped in a + navigation landmark. Press F6 (or Shift+F6) to move keyboard focus between the + navigation, banner, and main landmarks. +

+ +

Current route: {selectedRoute}

+
+
+
+
+ ); +}; +type AppLayoutStoryObj = StoryObj; + +export const WithLandmark: AppLayoutStoryObj = { + render: args => , + args: {}, + name: 'With Landmark', + parameters: { + layout: 'fullscreen', + docs: { + disable: true + } + } +}; diff --git a/packages/@react-spectrum/s2/test/SideNav.test.tsx b/packages/@react-spectrum/s2/test/SideNav.test.tsx new file mode 100644 index 00000000000..1590127d675 --- /dev/null +++ b/packages/@react-spectrum/s2/test/SideNav.test.tsx @@ -0,0 +1,403 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {ActionMenu} from '../src/ActionMenu'; +import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import {MenuItem} from '../src/Menu'; +import React from 'react'; +import {RouterProvider} from 'react-aria-components'; +import { + SideNav, + SideNavItem, + SideNavItemContent, + SideNavItemLink, + SideNavProps +} from '../src/SideNav'; +import {Text} from '../src/Content'; +import userEvent, {UserEvent} from '@testing-library/user-event'; + +function SideNavExample(props: Partial>) { + let {selectedRoute = '/files', ...rest} = props; + return ( + + + + + Your files + + + + + + + + Your libraries + + + + + + Projects 1 + + + + + + + Projects 2 + + + + + + ); +} + +// A controlled wrapper mirroring how SideNav is used with a router: activating a link is +// intercepted by RouterProvider, and the navigated href becomes the controlled selectedRoute. +function RoutedSideNavExample(props: Partial>) { + let [selectedRoute, setSelectedRoute] = React.useState('/files'); + return ( + + + + ); +} + +// libraries > Projects 1 > Projects 1A +function DeepSideNavExample(props: Partial>) { + let {selectedRoute = '/libraries', ...rest} = props; + return ( + + + + + Your libraries + + + + + + Projects 1 + + + + + + Projects 1A + + + + + + + ); +} + +// A top-level leaf ("Your files") followed by a three-level branch: +// libraries > Projects 1 > Projects 1A +function ThreeLevelSideNavExample(props: Partial>) { + let {selectedRoute = '/files', ...rest} = props; + return ( + + + + + Your files + + + + + + + Your libraries + + + + + + Projects 1 + + + + + + Projects 1A + + + + + + + ); +} + +// An item with no href and no link, but with a secondary action (ActionMenu). Focus should stay +// on the row rather than jumping into the ActionMenu trigger. +function NoLinkActionMenuSideNavExample(props: Partial>) { + let {selectedRoute = '/files', ...rest} = props; + return ( + + + + + Your files + + + + + + Section + + + Edit + + + Delete + + + + + + + Section 2 + + + + + + ); +} + +describe('SideNav', () => { + let user: UserEvent; + + beforeAll(function () { + user = userEvent.setup({delay: null, pointerMap}); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => { + jest.runAllTimers(); + }); + }); + + it('expands and collapses a level with the mouse', async () => { + let onExpandedChange = jest.fn(); + let {getByRole, queryByRole} = render(); + + let librariesRow = getByRole('row', {name: 'Your libraries'}); + expect(librariesRow).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + + await user.click(within(librariesRow).getByRole('button')); + + expect(librariesRow).toHaveAttribute('aria-expanded', 'true'); + expect(getByRole('link', {name: 'Projects 1'})).toBeInTheDocument(); + expect(onExpandedChange).toHaveBeenLastCalledWith(new Set(['libraries'])); + + await user.click(within(librariesRow).getByRole('button')); + + expect(librariesRow).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + expect(onExpandedChange).toHaveBeenLastCalledWith(new Set([])); + }); + + it('expands and collapses a level with the keyboard', async () => { + let {getByRole, queryByRole} = render(); + let librariesRow = getByRole('row', {name: 'Your libraries'}); + + // Move focus onto the "Your libraries" link. + await user.tab(); + await user.keyboard('{ArrowDown}'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + + // Right arrow on the link opens the level. + await user.keyboard('{ArrowRight}'); + expect(librariesRow).toHaveAttribute('aria-expanded', 'true'); + expect(getByRole('link', {name: 'Projects 1'})).toBeInTheDocument(); + + // Left arrow returns focus to the row, then collapses the level. + await user.keyboard('{ArrowLeft}'); + expect(librariesRow).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + }); + + it('marks the link matching selectedRoute with aria-current="page"', () => { + let {getByRole, rerender} = render(); + + expect(getByRole('link', {name: 'Your files'})).toHaveAttribute('aria-current', 'page'); + expect(getByRole('link', {name: 'Your libraries'})).not.toHaveAttribute('aria-current'); + + // Changing the selected route moves aria-current. + rerender(); + expect(getByRole('link', {name: 'Your files'})).not.toHaveAttribute('aria-current'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveAttribute('aria-current', 'page'); + }); + + it('updates aria-current when navigating via the router', async () => { + let {getByRole} = render(); + + expect(getByRole('link', {name: 'Your files'})).toHaveAttribute('aria-current', 'page'); + + // Activating another link navigates, which updates the controlled selectedRoute. + await user.click(getByRole('link', {name: 'Your libraries'})); + expect(getByRole('link', {name: 'Your files'})).not.toHaveAttribute('aria-current'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveAttribute('aria-current', 'page'); + }); + + it('moves the focused key to the selectedRoute item, even nested farther down', async () => { + // Projects 2 is nested under (an expanded) Your libraries — farther down and visible. + let {getByRole} = render( + + ); + + await user.tab(); + expect(getByRole('link', {name: 'Projects 2'})).toHaveFocus(); + }); + + it('moves the focused key to the closest visible ancestor when selectedRoute is under a collapsed parent', async () => { + let {getByRole, queryByRole} = render(); + + expect(getByRole('row', {name: 'Your libraries'})).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 2'})).toBeNull(); + + await user.tab(); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + }); + + it('moves the focused key past an expanded ancestor that is itself hidden by a collapsed ancestor', async () => { + // libraries (collapsed) > Projects 1 (expanded) > Projects 1A (selectedRoute). Projects 1 is + // expanded, but it's still hidden because its own parent (libraries) is collapsed. Focus must + // skip the expanded-but-hidden Projects 1 and land on the closest rendered ancestor, libraries + // (not the first row, "Your files"). + let {getByRole, queryByRole} = render( + + ); + + // Both descendants are hidden behind the collapsed parent. + expect(getByRole('row', {name: 'Your libraries'})).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1'})).toBeNull(); + expect(queryByRole('link', {name: 'Projects 1A'})).toBeNull(); + + await user.tab(); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + }); + + it('arrow left from a deep leaf steps to parent, collapses it, then moves to the grandparent', async () => { + let {getByRole, queryByRole} = render( + + ); + await user.tab(); + expect(getByRole('link', {name: 'Projects 1A'})).toHaveFocus(); + + // 1st ArrowLeft: leaf has nothing to collapse, so focus moves up to its parent. + await user.keyboard('{ArrowLeft}'); + expect(getByRole('link', {name: 'Projects 1'})).toHaveFocus(); + expect(getByRole('row', {name: 'Projects 1'})).toHaveAttribute('aria-expanded', 'true'); + + // 2nd ArrowLeft: the parent is expanded, so it collapses; focus stays on it. + await user.keyboard('{ArrowLeft}'); + expect(getByRole('row', {name: 'Projects 1'})).toHaveAttribute('aria-expanded', 'false'); + expect(queryByRole('link', {name: 'Projects 1A'})).toBeNull(); + expect(getByRole('link', {name: 'Projects 1'})).toHaveFocus(); + + // 3rd ArrowLeft: the parent is now collapsed, so focus moves up to the grandparent. + await user.keyboard('{ArrowLeft}'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + }); + + it('keeps focus on the row (not the ActionMenu) for an item with no href/link', async () => { + let {getByRole} = render(); + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + + // Focus stays on the row itself; it does not jump into the ActionMenu trigger. + let sectionRow = getByRole('row', {name: 'Section'}); + expect(sectionRow).toHaveFocus(); + expect(within(sectionRow).getByRole('button', {name: 'More actions'})).not.toHaveFocus(); + }); + + it("clicking on a category item's content expands the category", async () => { + let {getByRole} = render(); + let sectionRow = getByRole('row', {name: 'Section'}); + + await user.click(within(sectionRow).getByText('Section')); + expect(sectionRow).toHaveAttribute('aria-expanded', 'true'); + }); + + it('activates the link when clicked', async () => { + let navigate = jest.fn(); + let {getByRole} = render( + + + + ); + + await user.click(getByRole('link', {name: 'Your files'})); + expect(navigate).toHaveBeenCalledWith('/files', undefined); + }); + + it('activates the link when keyboard activated', async () => { + let navigate = jest.fn(); + let {getByRole} = render( + + + + ); + + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(navigate).toHaveBeenCalledWith('/files', undefined); + }); + + it('takes one tab to leave the sidenav from a link', async () => { + let {getByRole} = render( + <> + + + + ); + + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + + await user.tab(); + expect(getByRole('textbox')).toHaveFocus(); + }); + + it('takes one shift tab to leave the sidenav from a link', async () => { + let {getByRole} = render( + <> + + + + ); + + await user.tab(); + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + + await user.tab({shift: true}); + expect(getByRole('textbox')).toHaveFocus(); + }); +}); diff --git a/packages/@react-spectrum/s2/test/TableView.test.tsx b/packages/@react-spectrum/s2/test/TableView.test.tsx index c217b1648ea..ffdff1238a3 100644 --- a/packages/@react-spectrum/s2/test/TableView.test.tsx +++ b/packages/@react-spectrum/s2/test/TableView.test.tsx @@ -21,6 +21,7 @@ import {pointerMap, User} from '@react-aria/test-utils'; import React, {useState} from 'react'; import {Tab, TabList, Tabs} from '../src/Tabs'; import {Text} from '../src/Content'; +import {useDragAndDrop} from '../src/useDragAndDrop'; import userEvent from '@testing-library/user-event'; // @ts-ignore @@ -292,4 +293,130 @@ describe('TableView', () => { await user.click(tableTester.getRows()[0]); expect(onSelectionChange).toHaveBeenCalled(); }); + + describe('tab keyboard navigation', () => { + function TabNavTable(props) { + let {dragAndDropHooks} = useDragAndDrop({ + getItems: keys => [...keys].map(key => ({'text/plain': `${key}`})) + }); + return ( + + + + + Filter + + + }> + Name + + + + Filter + + + }> + Type + + Notes + + + + Foo 1 + Bar 1 + + + + + + Foo 2 + Bar 2 + + + + + + Foo 3 + Bar 3 + + + + + + + ); + } + + it('row drag cell and checkbox cell auto focuses drag handle button/checkbox and allows arrow key nav', async () => { + let {getByRole, getAllByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let checkboxes = getAllByRole('checkbox'); + let dragButton1 = getByRole('button', {name: 'Drag Foo 1'}); + + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(dragButton1); + + await user.keyboard('{ArrowRight}'); + let row1 = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row1}); + expect(cells[1].contains(document.activeElement)).toBe(true); + expect(document.activeElement).toBe(checkboxes[1]); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(checkboxes[2]); + }); + + it('column headers and selectAll header auto focuses their menu trigger/checkbox and allows arrow key nav', async () => { + let {getByRole, getAllByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let checkboxes = getAllByRole('checkbox'); + let columns = tableTester.getColumns(); + + await user.tab(); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(columns[0]); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(checkboxes[0]); + expect(columns[1].contains(document.activeElement)).toBe(true); + + await user.keyboard('{ArrowRight}'); + act(() => jest.runAllTimers()); + expect(document.activeElement?.tagName.toLowerCase()).toBe('button'); + expect(columns[2].contains(document.activeElement)).toBe(true); + }); + + it('textfield in cell works with tab mode', async () => { + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + let cells = tableTester.getCells({element: tableTester.getRows()[0]}); + let notesCell = cells[cells.length - 1]; + + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(notesCell); + + await user.tab(); + let input = getByRole('textbox', {name: 'Foo 1 notes'}); + expect(document.activeElement).toBe(input); + + await user.type(input, 'Foo'); + expect(input).toHaveValue('Foo'); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(input); + + await user.tab({shift: true}); + expect(document.activeElement).toBe(notesCell); + }); + }); }); diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx index a56abd96ac9..dadc43c5ade 100644 --- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx +++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx @@ -732,7 +732,7 @@ let photos = [ items={photos} selectionMode="multiple" aria-label="Shared files"> - {item => ( + {item => ( @@ -744,6 +744,58 @@ let photos = [ ``` +You can further control if a row automatically focuses itself or its children on keyboard focus via `focusMode`. Futhermore, `allowsArrowNavigation` can be used to allow arrow key navigation from the row's children to adjacent rows. +This allows keyboard users to navigate to the contents of the row without needing to tab in and out of the row even when in tab keyboard navigation. +Be sure to only set `allowsArrowNavigation` on rows whose interactive content don't use arrow keys. + +```tsx render +"use client"; +import {GridList, GridListItem, Text} from 'vanilla-starter/GridList'; +import {Button} from 'vanilla-starter/Button'; +import {useState} from 'react'; + +///- begin collapse -/// +let initialItems = [ + {id: 1, name: 'Apple', image: 'https://images.unsplash.com/photo-1630563451961-ac2ff27616ab?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, + {id: 2, name: 'Peach', image: 'https://images.unsplash.com/photo-1642372849486-f88b963cb734?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, + {id: 3, name: 'Blueberry', image: 'https://images.unsplash.com/photo-1606757389667-45c2024f9fa4?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, + {id: 4, name: 'Broccoli', image: 'https://images.unsplash.com/photo-1685504445355-0e7bdf90d415?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, + {id: 5, name: 'Brussels Sprouts', image: 'https://images.unsplash.com/photo-1685504507286-dc290728c01a?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, + {id: 6, name: 'Peas', image: 'https://images.unsplash.com/photo-1587411768345-867e228218c8?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0'}, +]; +///- end collapse -/// + +function Example() { + let [items, setItems] = useState(initialItems); + return ( + + {item => ( + + + {item.name} + + + )} + + ); +} +``` + ## Drag and drop GridList supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the list as a whole, on individual items, insert new items between existing ones, or reorder items. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=GridList) to learn more. diff --git a/packages/dev/s2-docs/pages/react-aria/Table.mdx b/packages/dev/s2-docs/pages/react-aria/Table.mdx index 4fcfb9c7b5f..21a023a11f9 100644 --- a/packages/dev/s2-docs/pages/react-aria/Table.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Table.mdx @@ -217,7 +217,7 @@ function FileTable() { {column => ( - {column.id === 'price' + {column.id === 'price' ? item.price.toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}) : item[column.id]} @@ -725,6 +725,55 @@ function subscribe(fn) { } ``` +## Keyboard navigation + +By default, Table uses arrow key navigation to move focus into cells. Set `keyboardNavigationBehavior="tab"` to have Tab move focus in and out of a cell. +Use this when cells contain interactive elements such as text fields, where arrow keys and typing in the field should not trigger grid navigation or selection. + +```tsx render files={["starters/docs/src/Table.tsx", "starters/docs/src/Table.css"]} +"use client"; +import {Table, TableHeader, Column, Row, TableBody, Cell} from 'vanilla-starter/Table'; +import {TextField} from 'vanilla-starter/TextField'; + +///- begin collapse -/// +let files = [ + {id: 'games', name: 'Games', type: 'Folder', date: '6/7/2023'}, + {id: 'apps', name: 'Applications', type: 'Folder', date: '4/7/2025'}, + {id: 'report', name: '2024 Financial Report', type: 'PDF Document', date: '12/30/2024'}, + {id: 'job', name: 'Job Posting', type: 'Text Document', date: '1/18/2025'}, +]; +///- end collapse -/// + +
+ + Name + Type + Date Modified + Notes + + + {item => ( + + {item.name} + {item.type} + {item.date} + + + )} + +
+``` + +You can further control if a cell automatically focuses itself or its children on keyboard focus via `focusMode`. Futhermore, `allowsArrowNavigation` can be used to allow arrow key navigation from the cell's children to adjacent cells. +This allows keyboard users to navigate to the selection checkboxes without needing to tab in and out of the selection cell even when in tab keyboard navigation. +The vanilla CSS starter applies these to selection cells automatically, see the **Table.tsx** tab above. +Be sure to only set `allowsArrowNavigation` on cells whose interactive content don't use arrow keys. + ## Drag and drop Table supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the table as a whole, on individual rows, insert new rows between existing ones, or reorder rows. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=Table) to learn more. diff --git a/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx b/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx new file mode 100644 index 00000000000..89bb0fc3e11 --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx @@ -0,0 +1,17 @@ +'use client'; +import {RouterProvider} from 'react-aria-components'; +import React, {ReactNode, useState} from 'react'; + +export function RoutedSideNav(props: { + children: ({selectedRoute}: {selectedRoute: string}) => ReactNode; + defaultSelectedRoute: string; +}) { + let {children} = props; + let [selectedRoute, setSelectedRoute] = useState(props.defaultSelectedRoute); + + let updateSelection = (href: string) => { + setSelectedRoute(href); + }; + + return {children({selectedRoute})}; +} diff --git a/packages/dev/s2-docs/pages/s2/SideNav.mdx b/packages/dev/s2-docs/pages/s2/SideNav.mdx new file mode 100644 index 00000000000..b5699bd533b --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -0,0 +1,331 @@ +import {Layout} from '../../src/Layout'; +export default Layout; + +import docs from 'docs:@react-spectrum/s2'; + +export const tags = ['hierarchy', 'navigation', 'nested']; +export const relatedPages = []; +export const description = 'Displays hierarchical navigation with collapsing.'; + +# SideNav + +{docs.exports.SideNav.description} + +```tsx render docs={docs.exports.SideNav} links={docs.links} props={[]} type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} +"use client"; +import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink} from '@react-spectrum/s2/SideNav'; +import {RoutedSideNav} from './RoutedSideNav'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + + + {({selectedRoute}) => ( + + + Guidelines + + Style + + + Color + + Background Layers + + + + + Support + + Contact Us + + + + )} + +``` + +## Selection + +SideNav doesn't support selection like other [Collections](collections). Instead of the `id`, it uses the `href` prop as a key for selection. Note that +`id` is still used for the expansion state and disabled items as not every item will have an `href`. + +SideNavs do not support an uncontrolled selection state, you are responsible for managing it through the `selectedRoute` prop. You may wire this up +to a router or other state management solution. + +If a SideNavItem has an `href`, then you must pass a `SideNavItemLink` as a child of the `SideNavItemContent`. + +## Content + +`SideNav` follows the [Collection Components API](collections), accepting both static and dynamic collections. This example shows a dynamic collection, passing a list of objects to the `items` prop, and a recursive function to render the children. + +```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} +"use client"; +import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, Collection} from '@react-spectrum/s2/SideNav'; +import {RoutedSideNav} from './RoutedSideNav'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +///- begin collapse -/// +interface Item { + id: number; + title: string; + type: 'directory' | 'file'; + href?: string; + children?: Item[]; +} + +let items: Item[] = [ + {id: 1, title: 'Documents', type: 'directory', children: [ + {id: 2, title: 'Project', type: 'directory', children: [ + {id: 3, title: 'Weekly Report', type: 'file', href: '/weekly-report'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ]} + ]}, + {id: 5, title: 'Photos', type: 'directory', children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ]} +]; +///- end collapse -/// + + + {({selectedRoute}) => ( + + {function renderItem(item) { + return ( + + {item.href ? {item.title} : item.title} + {/*- begin highlight -*/} + {/* recursively render children */} + {item.children && + {renderItem} + } + {/*- end highlight -*/} + + ); + }} + + )} + +``` + +### Slots + +`SideNavItemContent` supports icons, `Text`, [ActionMenu](ActionMenu), and [ActionButtonGroup](ActionButtonGroup) as children. + +```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} +"use client"; +import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, Collection, Text} from '@react-spectrum/s2/SideNav'; +import {RoutedSideNav} from './RoutedSideNav'; +import {ActionMenu, MenuItem} from '@react-spectrum/s2/ActionMenu'; +import Folder from '@react-spectrum/s2/icons/Folder'; +import File from '@react-spectrum/s2/icons/File'; +import Edit from '@react-spectrum/s2/icons/Edit'; +import Delete from '@react-spectrum/s2/icons/Delete'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +///- begin collapse -/// +interface Item { + id: number; + title: string; + type: 'directory' | 'file'; + href?: string; + children?: Item[]; +} +let items: Item[] = [ + {id: 1, title: 'Documents', type: 'directory', children: [ + {id: 2, title: 'Project', type: 'directory', children: [ + {id: 3, title: 'Weekly Report', type: 'file', href: '/weekly-report'}, + {id: 4, title: 'Budget', type: 'file', href: '/budget'} + ]} + ]}, + {id: 5, title: 'Photos', type: 'directory', children: [ + {id: 6, title: 'Image 1', type: 'file', href: '/image-1'}, + {id: 7, title: 'Image 2', type: 'file', href: '/image-2'} + ]} +]; +///- end collapse -/// + + + {({selectedRoute}) => ( + + {function renderItem(item) { + return ( + + + {/*- begin highlight -*/} + { + item.href ? + {item.type === 'directory' ? : } + {item.title} + : <> + {item.type === 'directory' ? : } + {item.title} + + } + {/*- end highlight -*/} + + + + Edit + + + + Delete + + + + {item.children && + {renderItem} + } + + ); + }} + + )} + +``` + +## Sections + +A SideNav can contain sections to group items together. They are non-collapsible and non-interactive. + +```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/RoutedSideNav.tsx']} +"use client"; +import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, SideNavSection, SideNavHeader, Text} from '@react-spectrum/s2/SideNav'; +import {RoutedSideNav} from './RoutedSideNav'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import Delete from '@react-spectrum/s2/icons/Delete'; +import UserGroup from '@react-spectrum/s2/icons/UserGroup'; +import CCLibrary from '@react-spectrum/s2/icons/CCLibrary'; +import Files from '@react-spectrum/s2/icons/Files'; +import Images from '@react-spectrum/s2/icons/Images'; +import Animation from '@react-spectrum/s2/icons/Animation'; +import AudioWave from '@react-spectrum/s2/icons/AudioWave'; + + + {({selectedRoute}) => ( + + + Workspaces + + + + + Files + + + + + + + + Your Libraries + + + + + + + Photos + + + + + + + + + Shared with You + + + + + + + Animations + + + + + + + + + Deleted + + + + + + + Audio + + + + + + + )} + +``` + +## API + +```tsx links={{SideNav: '#sidenav', SideNavItem: '#sidenavitem', SideNavItemContent: '#sidenavitemcontent', SideNavItemLink: '#sidenavitemlink', ActionMenu: 'ActionMenu', ActionButtonGroup: 'ActionButtonGroup', Icon: 'icons', Text: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span'}} + + + + + + or + + + + + + + + + or + + + +``` + +### SideNav + + + +### SideNavItem + + + +### SideNavItemContent + + + +### SideNavItemLink + + + +### SideNavSection + + + +### SideNavHeader + + diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index ed3e611c2f3..eea1b318b7f 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -946,6 +946,51 @@ function subscribe(fn) { } ``` +## Keyboard navigation + +By default, TableView uses arrow key navigation to move focus into cells. Set `keyboardNavigationBehavior="tab"` to have Tab move focus in and out of a cell. + +```tsx render type="s2" +"use client"; +import {TableView, TableHeader, Column, TableBody, Row, Cell} from '@react-spectrum/s2/TableView'; +import {TextField} from '@react-spectrum/s2/TextField'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +///- begin collapse -/// +let files = [ + {id: 'games', name: 'Games', type: 'Folder', date: '6/7/2023'}, + {id: 'apps', name: 'Applications', type: 'Folder', date: '4/7/2025'}, + {id: 'report', name: '2024 Financial Report', type: 'PDF Document', date: '12/30/2024'}, + {id: 'job', name: 'Job Posting', type: 'Text Document', date: '1/18/2025'}, +]; +///- end collapse -/// + + + + Name + Type + Date Modified + Notes + + + {item => ( + + {item.name} + {item.type} + {item.date} + + + )} + + +``` + ## Drag and drop Table supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the table as a whole, on individual rows, insert new rows between existing ones, or reorder rows. See the [drag and drop guide](dnd?component=TableView) to learn more. diff --git a/packages/react-aria-components/exports/GridList.ts b/packages/react-aria-components/exports/GridList.ts index c23901474ee..c1dee740968 100644 --- a/packages/react-aria-components/exports/GridList.ts +++ b/packages/react-aria-components/exports/GridList.ts @@ -25,6 +25,7 @@ export { } from '../src/GridList'; export {Collection, type CollectionProps} from 'react-aria/Collection'; export type { + GridListHeaderProps, GridListProps, GridListRenderProps, GridListItemProps, diff --git a/packages/react-aria-components/exports/Tree.ts b/packages/react-aria-components/exports/Tree.ts index 49031e1c69a..d647e67c5c8 100644 --- a/packages/react-aria-components/exports/Tree.ts +++ b/packages/react-aria-components/exports/Tree.ts @@ -25,6 +25,7 @@ export { TreeStateContext } from '../src/Tree'; export type { + GridListSectionProps, TreeProps, TreeRenderProps, TreeEmptyStateRenderProps, diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index a03fa5933c3..c1ed251a5b6 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -376,6 +376,7 @@ export type {FieldErrorProps, FieldErrorRenderProps} from '../src/FieldError'; export type {FileTriggerProps} from '../src/FileTrigger'; export type {FormProps} from '../src/Form'; export type { + GridListHeaderProps, GridListProps, GridListRenderProps, GridListItemProps, diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index db972650186..7b1a62f5322 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -510,6 +510,17 @@ export interface GridListItemProps * on the collection's `selectionBehavior` prop and the interaction modality. */ onAction?: () => void; + /** + * Whether the row or its first focusable child element should be focused when the row is + * focused. Defaults to 'row'. + */ + focusMode?: 'child' | 'row'; + /** + * Whether the row should support arrow key navigation even when the containing collection uses + * tab keyboard navigation. Allows users to navigate between rows with arrow keys while + * focus is on an interactive child element within the row. + */ + allowsArrowNavigation?: boolean; } /** @@ -528,7 +539,9 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function { node: item, shouldSelectOnPressUp: !!dragState, - isVirtualized + isVirtualized, + focusMode: props.focusMode, + allowsArrowNavigation: props.allowsArrowNavigation }, state, ref diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 45eb1d488bf..b4c8c9f3b8c 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1191,6 +1191,18 @@ export interface ColumnProps * ``. */ maxWidth?: ColumnStaticSize | null; + /** + * Whether the column header or its first focusable child element should be focused when the + * column header is focused. Defaults to 'child' in arrow keyboard navigation mode and 'cell' in + * tab keyboard navigation mode. + */ + focusMode?: 'child' | 'cell'; + /** + * Whether the column should support arrow key navigation even when the containing table uses tab + * keyboard navigation. Allows users to navigate between columns and rows with arrow keys while + * focus is on an interactive child element within the column header. + */ + allowsArrowNavigation?: boolean; } class TableColumnNode extends CollectionNode { @@ -1222,7 +1234,12 @@ export const Column = /*#__PURE__*/ createLeafComponent( let state = useContext(TableStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let {columnHeaderProps, isPressed} = useTableColumnHeader( - {node: column, isVirtualized}, + { + node: column, + isVirtualized, + focusMode: props.focusMode, + allowsArrowNavigation: props.allowsArrowNavigation + }, state, ref ); @@ -2066,6 +2083,18 @@ export interface CellProps textValue?: string; /** Indicates how many columns the data cell spans. */ colSpan?: number; + /** + * Whether the cell or its first focusable child element should be focused when the cell is + * focused. Defaults to 'child' in arrow keyboard navigation mode and 'cell' in tab keyboard + * navigation mode. + */ + focusMode?: 'child' | 'cell'; + /** + * Whether the cell should support arrow key navigation even when the containing table uses + * tab keyboard navigation. Allows users to navigate between cells and rows with arrow keys while + * focus is on an interactive child element within the cell. + */ + allowsArrowNavigation?: boolean; } class TableCellNode extends CollectionNode { @@ -2104,7 +2133,9 @@ export const Cell = /*#__PURE__*/ createLeafComponent( { node: cell, shouldSelectOnPressUp: !!dragState, - isVirtualized + isVirtualized, + focusMode: props.focusMode, + allowsArrowNavigation: props.allowsArrowNavigation }, state, ref diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index c05ce61afa2..1d2aed86b81 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -732,7 +732,7 @@ export interface TreeItemProps LinkDOMProps, HoverEvents, PressEvents, - Pick, + Pick, Omit, 'onClick'> { /** * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the @@ -787,7 +787,9 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent( let {rowProps, gridCellProps, expandButtonProps, descriptionProps, ...states} = useTreeItem( { node: item, - shouldSelectOnPressUp: !!dragState + shouldSelectOnPressUp: !!dragState, + focusMode: props.focusMode, + allowsArrowNavigation: props.allowsArrowNavigation }, state, ref diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 92d62ab384a..5c3ba1cf921 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -975,15 +975,26 @@ let comboboxEmptyState = () => { return
No results
; }; -export const GridListWithTextfield: GridListStory = args => { +type GridListWithTextfieldArgs = GridListProps & {autoFocusChildren?: boolean}; + +const GridListWithTextfieldRender = (args: GridListWithTextfieldArgs) => { let isHorizontalStack = args.orientation === 'horizontal' && args.layout !== 'grid'; + let {autoFocusChildren, keyboardNavigationBehavior, ...otherArgs} = args; + + let focusMode = + autoFocusChildren && keyboardNavigationBehavior === 'tab' + ? 'child' + : (undefined as 'child' | undefined); + let allowsArrowNavigation = + autoFocusChildren && keyboardNavigationBehavior === 'tab' ? true : undefined; + return (
{ gridTemplate: args.layout === 'grid' ? 'repeat(3, 1fr) / repeat(3, 1fr)' : 'auto / 1fr', gridAutoFlow: args.orientation === 'horizontal' ? 'column' : 'row' }} - {...args}> - + {...otherArgs}> + RAC TextField - + Raw input - + TextField + Button {' '} - + ComboBox
@@ -1033,7 +1044,7 @@ export const GridListWithTextfield: GridListStory = args => { - + Toolbar @@ -1041,7 +1052,10 @@ export const GridListWithTextfield: GridListStory = args => { - + Menu {/* TODO: hitting escape to close the menu, returns focus to the row. Tabbing back from the external input also focuses the trggerbutton rather than the row. Tabbing back into the textfield row focuses the row */} @@ -1056,7 +1070,7 @@ export const GridListWithTextfield: GridListStory = args => { - + RadioGroup { - + CheckboxGroup @@ -1108,11 +1122,13 @@ export const GridListWithTextfield: GridListStory = args => { ); }; -GridListWithTextfield.story = { +export const GridListWithTextfield: StoryObj = { + render: args => , args: { layout: 'stack', orientation: 'vertical', - escapeKeyBehavior: 'clearSelection' + escapeKeyBehavior: 'clearSelection', + keyboardNavigationBehavior: 'tab' }, argTypes: { layout: { @@ -1127,6 +1143,9 @@ GridListWithTextfield.story = { control: 'radio', options: ['arrow', 'tab'] }, + autoFocusChildren: { + control: 'boolean' + }, selectionMode: { control: 'radio', options: ['none', 'single', 'multiple'] @@ -1139,6 +1158,11 @@ GridListWithTextfield.story = { control: 'radio', options: ['clearSelection', 'none'] } + }, + parameters: { + description: { + data: 'Note that toggling autoFocusChildren will make each row automatically focus its children. For the row with a menu, allowsArrowNavigation is also applied so that arrow keys can be used to move focus between rows still' + } } }; diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 35979dc6098..3e41436acf9 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -26,20 +26,26 @@ import { TableHeader, TableLoadMoreItem } from '../src/Table'; -import {Checkbox, CheckboxProps} from '../src/Checkbox'; +import {Checkbox, CheckboxGroup, CheckboxProps} from '../src/Checkbox'; import {Collection} from 'react-aria/Collection'; +import {ComboBox} from '../src/ComboBox'; import {Dialog, DialogTrigger} from '../src/Dialog'; import {DropIndicator, isTextDropItem, useDragAndDrop} from '../exports/useDragAndDrop'; import {Heading} from '../src/Heading'; -import {LoadingSpinner, MyMenuItem} from './utils'; -import {Menu, MenuTrigger} from '../src/Menu'; +import {Input} from '../src/Input'; +import {ListBox} from '../src/ListBox'; +import {LoadingSpinner, MyListBoxItem, MyMenuItem} from './utils'; +import {Menu, MenuItem, MenuTrigger} from '../src/Menu'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {Modal, ModalOverlay} from '../src/Modal'; import {Popover} from '../src/Popover'; +import {Radio, RadioGroup} from '../src/RadioGroup'; import React, {JSX, startTransition, Suspense, useState} from 'react'; import {Selection} from '@react-types/shared'; import styles from '../example/index.css'; import {TableLayout} from '../src/TableLayout'; +import {TextField} from '../src/TextField'; +import {Toolbar} from '../src/Toolbar'; import {useAsyncList} from 'react-stately/useAsyncList'; import {useListData} from 'react-stately/useListData'; import {Virtualizer} from '../src/Virtualizer'; @@ -2182,6 +2188,214 @@ export const TableSectionDnd: TableStory = args => { ); }; +let comboboxEmptyState = () => { + return
No results
; +}; + +type TableWithTextfieldArgs = { + autoFocusChildren?: boolean; + keyboardNavigationBehavior?: 'tab' | 'arrow'; + selectionMode?: 'none' | 'single' | 'multiple'; + selectionBehavior?: 'toggle' | 'replace'; +}; + +const TableWithTextfieldRender = (args: TableWithTextfieldArgs) => { + let { + autoFocusChildren, + keyboardNavigationBehavior = 'tab', + selectionMode = 'multiple', + ...otherArgs + } = args; + let focusMode = + autoFocusChildren && keyboardNavigationBehavior === 'tab' + ? 'child' + : (undefined as 'child' | undefined); + let allowsArrowNavigation = + autoFocusChildren && keyboardNavigationBehavior === 'tab' ? true : undefined; + return ( +
+ + + + + + + Col 1 + Col 2 + Col 3 + Col 4 + + + + + + + RAC Textfield + + + + + + Raw input + + + + + + + + + TextField + Button + + + + + + + Toolbar + + + + + + + + + + + + + Menu + + + + + + Cut + Copy + Paste + + + + + RadioGroup + + + + Dog + + + Cat + + + Dragon + + + + + + + + + CheckboxGroup + + + + + Soccer + + + + Baseball + + + + Basketball + + + + ComboBox + + +
+ + +
+ + + Foo + Bar + Baz + Google + + +
+
+
+
+
+ +
+ ); +}; + +export const TableWithTextfield: StoryObj = { + render: args => , + args: { + keyboardNavigationBehavior: 'tab' + }, + argTypes: { + keyboardNavigationBehavior: { + control: 'radio', + options: ['arrow', 'tab'] + }, + autoFocusChildren: { + control: 'boolean' + }, + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + } + }, + parameters: { + description: { + data: 'Note that toggling autoFocusChildren will make each cell automatically focus its children. For the selection checkboxes and menu, allowsArrowNavigation is also applied so that arrow keys can be used to move focus between cells and rows' + } + } +}; export const TreeGridTableDnd: TableStory = () => { let tree = useTreeData({ initialItems: [ diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index c5df18a2ff8..ebac9a247e8 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -34,6 +34,7 @@ import { GridListSection } from '../src/GridList'; import {GridListLoadMoreItem} from '../src/GridList'; +import {I18nProvider} from 'react-aria/I18nProvider'; import {Input} from '../src/Input'; import {installPointerEvent, User} from '@react-aria/test-utils'; import {Label} from '../src/Label'; @@ -1949,6 +1950,24 @@ describe('GridList', () => { } ); + it('should not trigger selection when clicking on a tabbable child in arrow navigation mode', async () => { + let onSelectionChange = jest.fn(); + let {getByRole} = render( + + + Apple + + + Banana + + + ); + let input = getByRole('textbox'); + await user.click(input); + expect(document.activeElement).toBe(input); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + it.each([ ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}], ['layout="grid"', {layout: 'grid'}] @@ -2082,4 +2101,214 @@ describe('GridList', () => { } ); }); + + describe('focusMode and allowsArrowNavigation', () => { + it('focusMode="child" auto-focuses first child when item receives focus', async () => { + let {getByRole} = render( + + + + + + + + + + + + ); + + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab({shift: true}); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + }); + + it('allowsArrowNavigation allows arrow key row navigation when focused on child', async () => { + let {getByRole} = render( + + + + + + + + + + + + ); + + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + }); + + describe('focusMode="child" with default arrow navigation', () => { + it('ArrowDown from child navigates to first child of next item', async () => { + let {getByRole} = render( + + + + + + + + + + + + + + + ); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 3 action'})); + }); + + it('ArrowUp from child navigates to first child of previous item', async () => { + let {getByRole} = render( + + + + + + + + + + + ); + await user.tab(); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + }); + + it('ArrowLeft/Right cycles through children and row element', async () => { + let {getByRole, getAllByRole} = render( + + + + + + + ); + let rows = getAllByRole('row'); + let firstBtn = getByRole('button', {name: 'Item 1 first'}); + let lastBtn = getByRole('button', {name: 'Item 1 last'}); + await user.tab(); + expect(document.activeElement).toBe(firstBtn); + // ArrowRight: first → last → row (wraps) + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(lastBtn); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(rows[0]); + // ArrowLeft from row wraps to last; from last goes to first; from first goes to row + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(lastBtn); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(firstBtn); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(rows[0]); + // ArrowRight from row enters first child + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(firstBtn); + }); + + describe('RTL', () => { + it('ArrowLeft/Right RTL cycle: ArrowLeft advances forward, ArrowRight wraps to last child', async () => { + // In RTL: ArrowLeft = forward (next), ArrowRight = backward (prev) + let {getByRole, getAllByRole} = render( + + + + + + + + + ); + let rows = getAllByRole('row'); + let firstBtn = getByRole('button', {name: 'Item 1 first'}); + let lastBtn = getByRole('button', {name: 'Item 1 last'}); + await user.tab(); + expect(document.activeElement).toBe(firstBtn); + // ArrowLeft in RTL moves forward: first → last → row (lines 243-244) + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(lastBtn); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(rows[0]); + // ArrowRight in RTL wraps back: row → last child (lines 277-282) + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(lastBtn); + }); + }); + }); + + describe('focusMode="row" (default) with tab navigation', () => { + it('Tab into list focuses row, Tab from row enters child, Shift+Tab returns to row', async () => { + let {getByRole, getAllByRole} = render( + + + + + + + ); + let rows = getAllByRole('row'); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action 2'})); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[0]); + }); + }); + }); }); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 92cf4d438e7..f8b602f4027 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -232,6 +232,97 @@ let EditableTable = ({ ); +let TabModeTable = ({actionCellProps = {}, ...tableProps}) => ( + + + Name + Type + Tags + Notes + + + + Games + File folder + + + + Action + RPG + + + + + + + + + + Program Files + File folder + + + + Office + + + + + + + + + bootmgr + System file + + + + Boot + + + + + + + + +
+); + +let ArrowModeTable = ({cellProps = {}, ...tableProps}) => ( + + + Name + Col 2 + Col 3 + + + + Row 1 + + + + + + + + + + + Row 2 + + + + + + + + + + +
+); + let DraggableTable = props => { let {dragAndDropHooks} = useDragAndDrop({ getItems: keys => [...keys].map(key => ({'text/plain': key})), @@ -3535,6 +3626,291 @@ describe('Table', () => { expect(tableTester.getFooterRows()).toHaveLength(1); expect(tableTester.getFooterRows()[0]).toHaveTextContent('Blah'); }); + + describe("keyboardNavigationBehavior='tab' and textfields in row", () => { + it('Tab from a focused cell moves focus to the first tabbable child', async () => { + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + expect(document.activeElement).toBe(getByRole('textbox', {name: 'Games notes'})); + }); + + it('Tab from a cell with no tabbable children or from the last child in a cell exits the table', async () => { + let {getAllByRole, getByRole} = render( +
+ + + +
+ ); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); + let rowheader = tableTester.getRowHeaders()[0]; + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(rowheader); + await user.tab(); + let buttons = getAllByRole('button'); + expect(document.activeElement).toBe(buttons[2]); + + await user.tab({shift: true}); + await user.keyboard('{ArrowLeft}'); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + expect(document.activeElement).toBe(getByRole('textbox', {name: 'Games notes'})); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Button next to input'})); + await user.tab(); + expect(document.activeElement).toBe(buttons[2]); + }); + + it('Shift+Tab from a child returns focus to the cell', async () => { + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + expect(document.activeElement).toBe(getByRole('textbox', {name: 'Games notes'})); + await user.tab({shift: true}); + expect(document.activeElement).toBe(cells[cells.length - 1]); + }); + + it('should not navigate to next cell when arrow keys are pressed while a text input child has focus', async () => { + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + let input = getByRole('textbox', {name: 'Games notes'}); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(input); + }); + + it('should not trigger typeahead when typing in a text input child', async () => { + let {getByRole} = render(); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + + let input = getByRole('textbox', {name: 'Games notes'}); + expect(document.activeElement).toBe(input); + await user.type(input, 'Games'); + expect(input).toHaveValue('Games'); + expect(document.activeElement).toBe(input); + }); + + it('should not trigger selection when pressing Space or Enter in a text input child', async () => { + let {getByRole} = render( + + ); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); + let row = tableTester.getRows()[0]; + let cells = tableTester.getCells({element: row}); + + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cells[cells.length - 1]); + await user.tab(); + let input = getByRole('textbox', {name: 'Games notes'}); + expect(document.activeElement).toBe(input); + + await user.keyboard(' '); + expect(input).toHaveValue(' '); + expect(onSelectionChange).not.toHaveBeenCalled(); + + await user.keyboard('{Enter}'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should not trigger selection when clicking on a tabbable child element', async () => { + let {getByRole} = render( + + ); + let input = getByRole('textbox', {name: 'Games notes'}); + + await user.click(input); + expect(document.activeElement).toBe(input); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should not trigger row selection when clicking a TagGroup tag nested inside a cell', async () => { + let {getByRole} = render( + + ); + + // click on actual grid cell since that mimics what would happen in browser + // note that grid cell is not tabbable nor has the data-collection/etc attributes since those + // are on a wrapping div + let tagGrid = getByRole('grid', {name: 'Games tags'}); + let tagInnerCell = within(tagGrid).getAllByRole('gridcell')[0]; + + await user.click(tagInnerCell); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should still trigger selection when clicking on a row with no tabbable children ', async () => { + let {getByRole} = render( + + ); + let tableTester = testUtilUser.createTester('Table', { + root: getByRole('grid', {name: 'Tab mode table'}) + }); + let row = tableTester.getRows()[0]; + + await user.click(row); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1'])); + }); + + describe("focusMode='child'", () => { + it('arrow navigating to a focusMode="child" cell focuses the child directly', async () => { + let {getByRole} = render(); + await user.tab(); + // ArrowLeft navigation uses childFocusStrategy='last', so focuses the last child (button) + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Button next to input'})); + }); + + it('allowsArrowNavigation allows arrow key row navigation when focused on child', async () => { + let {getByRole} = render( + + ); + await user.tab(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Button next to input'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('textbox', {name: 'Program Files notes'})); + }); + + it('Shift+Tab from a focusMode="child" child skips the cell and does not return to it', async () => { + let {getByRole} = render(); + let button = getByRole('button', {name: 'Button next to input'}); + let input = getByRole('textbox', {name: 'Games notes'}); + + await user.tab(); + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toBe(button); + + await user.tab({shift: true}); + expect(document.activeElement).toBe(input); + + await user.tab({shift: true}); + expect(document.activeElement).toBe(document.body); + }); + }); + }); + + describe('cells with focusable children in arrow navigation mode', () => { + it('default focusMode: ArrowRight crosses from last child to first child of next cell, ArrowLeft reverses', async () => { + let {getByRole} = render(); + await user.tab(); + // Tab → row, ArrowRight×2 → col2 first child (col1 is text-only, focuses cell then crosses) + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 first'})); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 last'})); + // ArrowRight from last child crosses into first child of next cell + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C3 first'})); + // ArrowLeft from first child crosses back to last child of previous cell + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 last'})); + }); + + describe("focusMode='cell'", () => { + it('arrow navigation with focusMode="cell": cell element stays focused on navigate, arrows enter/exit children within cell', async () => { + // walker.previousNode() from first child returns cell root → stays on same cell + // (contrast with default focusMode="child" which crosses to prev cell's last child) + let {getByRole, getAllByRole} = render(); + let rows = getAllByRole('row'); + let col2Cell = within(rows[1]).getAllByRole('gridcell')[0]; + let col3Cell = within(rows[1]).getAllByRole('gridcell')[1]; + + await user.tab(); + // ArrowRight×2: row → col1 (text-only, focuses cell) → col2Cell (focusMode='cell' stays on cell element) + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(col2Cell); + + // ArrowRight from cell element enters first child + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 first'})); + + // ArrowRight within cell advances to next child + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 last'})); + + // ArrowLeft within cell retreats to previous child + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C2 first'})); + + // ArrowLeft from first child returns to same cell element (not previous cell's last child) + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(col2Cell); + + // Navigate forward to reach col3: col2Cell → first → last → col3Cell + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + // ArrowRight from last child of col2 enters next cell element (not its first child) + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(col3Cell); + + // ArrowRight from col3Cell enters its first child + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'R1C3 first'})); + + // ArrowLeft from first child of col3 returns to col3Cell (same cell, not col2) + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(col3Cell); + + // ArrowLeft from cell element moves to previous cell element + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(col2Cell); + }); + }); + }); }); function HidingColumnsExample({dynamic = false}) { diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index ee5a6d7f891..c0087e78fd9 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -2940,6 +2940,196 @@ describe('Tree', () => { expect(onSelectionChange).not.toHaveBeenCalled(); }); }); + + describe('focusMode and allowsArrowNavigation', () => { + it('focusMode="child" auto-focuses first child when item receives focus', async () => { + let {getByRole} = render( + + + + {() => ( + + )} + + + + + {() => ( + + )} + + + + ); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab({shift: true}); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + }); + + it('allowsArrowNavigation allows arrow key navigation when focused on child', async () => { + let {getByRole} = render( + + + + {() => ( + + )} + + + + + {() => ( + + )} + + + + ); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + }); + + describe('focusMode="child" with default arrow navigation', () => { + it('ArrowDown from child navigates to first child of next item', async () => { + let {getByRole} = render( + + + + + + + + + + + + + + + + + + + + + ); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 3 action'})); + }); + + it('ArrowUp from child navigates to first child of previous item', async () => { + let {getByRole} = render( + + + + + + + + + + + + + + + ); + await user.tab(); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 2 action'})); + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + }); + }); + + describe('focusMode="row" (default) with tab navigation', () => { + it('Tab into tree focuses row, Tab from row enters child, Shift+Tab returns to row', async () => { + let {getByRole, getAllByRole} = render( + + + + + + + + + ); + let rows = getAllByRole('row'); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab(); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action 2'})); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByRole('button', {name: 'Item 1 action'})); + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[0]); + }); + + it('ArrowRight expands collapsed parent item in tab mode', async () => { + let {getAllByRole} = render( + + + + {({hasChildItems, isExpanded}) => ( + <> + {hasChildItems && } + Parent + + )} + + + {() => Child 1} + + + + {() => Item 2} + + + ); + let rows = getAllByRole('row'); + // Only parent and item2 visible initially (parent is collapsed) + expect(rows).toHaveLength(2); + await user.tab(); + expect(document.activeElement).toBe(rows[0]); + // ArrowRight on a collapsed parent with children expands it (handleTreeExpansionKeys returns true) + await user.keyboard('{ArrowRight}'); + let expandedRows = getAllByRole('row'); + expect(expandedRows).toHaveLength(3); + }); + }); + }); }); AriaTreeTests({ diff --git a/packages/react-aria/src/grid/useGrid.ts b/packages/react-aria/src/grid/useGrid.ts index b9897552a7f..88244b23e98 100644 --- a/packages/react-aria/src/grid/useGrid.ts +++ b/packages/react-aria/src/grid/useGrid.ts @@ -82,6 +82,13 @@ export interface GridProps extends DOMProps, AriaLabelingProps { escapeKeyBehavior?: 'clearSelection' | 'none'; /** Whether selection should occur on press up instead of press down. */ shouldSelectOnPressUp?: boolean; + /** + * Whether keyboard navigation to focusable elements within grid cells is + * via arrow keys or the tab key. + * + * @default 'arrow' + */ + keyboardNavigationBehavior?: 'arrow' | 'tab'; } export interface GridAria { @@ -113,7 +120,8 @@ export function useGrid( onRowAction, onCellAction, escapeKeyBehavior = 'clearSelection', - shouldSelectOnPressUp + shouldSelectOnPressUp, + keyboardNavigationBehavior = 'arrow' } = props; let {selectionManager: manager} = state; @@ -164,7 +172,8 @@ export function useGrid( gridMap.set(state, { keyboardDelegate: delegate, actions: {onRowAction, onCellAction}, - shouldSelectOnPressUp + shouldSelectOnPressUp, + keyboardNavigationBehavior }); let descriptionProps = useHighlightSelectionDescription({ diff --git a/packages/react-aria/src/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts index 586ea63b6f9..b998a245ab8 100644 --- a/packages/react-aria/src/grid/useGridCell.ts +++ b/packages/react-aria/src/grid/useGridCell.ts @@ -19,6 +19,7 @@ import { nodeContains } from '../utils/shadowdom/DOMFunctions'; import {getFocusableTreeWalker} from '../focus/FocusScope'; +import {getOwnerDocument} from '../utils/domHelpers'; import {getScrollParent} from '../utils/getScrollParent'; import { IGridCollection as GridCollection, @@ -43,9 +44,16 @@ export interface GridCellProps { isVirtualized?: boolean; /** * Whether the cell or its first focusable child element should be focused when the grid cell is - * focused. + * focused. Defaults to 'child' in arrow keyboard navigation mode and 'cell' in + * tab keyboard navigation mode. */ focusMode?: 'child' | 'cell'; + /** + * Whether the cell should support arrow key navigation even when the containing collection uses + * tab keyboard navigation. Allows users to navigate between rows and cells with arrow keys while + * focus is on an interactive child element within the cell. + */ + allowsArrowNavigation?: boolean; /** Whether selection should occur on press up instead of press down. */ shouldSelectOnPressUp?: boolean; /** Indicates how many columns the data cell spans. */ @@ -77,13 +85,22 @@ export function useGridCell>( state: GridState, ref: RefObject ): GridCellAria { - let {node, isVirtualized, focusMode = 'child', shouldSelectOnPressUp, onAction} = props; + let { + node, + isVirtualized, + focusMode: focusModeProp, + allowsArrowNavigation, + shouldSelectOnPressUp, + onAction + } = props; let {direction} = useLocale(); let { keyboardDelegate, - actions: {onCellAction} + actions: {onCellAction}, + keyboardNavigationBehavior } = gridMap.get(state)!; + let focusMode = focusModeProp ?? (keyboardNavigationBehavior === 'tab' ? 'cell' : 'child'); // We need to track the key of the item at the time it was last focused so that we force // focus to go to the item when the DOM node is reused for a different item in a virtualizer. @@ -96,7 +113,10 @@ export function useGridCell>( let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { // If focus is already on a focusable child within the cell, early return so we don't shift focus - if (isFocusWithin(ref.current) && ref.current !== getActiveElement()) { + if ( + isFocusWithin(ref.current) && + ref.current !== getActiveElement(getOwnerDocument(ref.current)) + ) { return; } @@ -131,7 +151,7 @@ export function useGridCell>( }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - let activeElement = getActiveElement(); + let activeElement = getActiveElement(getOwnerDocument(ref.current)); if ( !nodeContains(e.currentTarget, getEventTarget(e) as Element) || state.isKeyboardNavigationDisabled || @@ -252,6 +272,41 @@ export function useGridCell>( } }; + let onKeyDown = (e: ReactKeyboardEvent) => { + let activeElement = getActiveElement(getOwnerDocument(ref.current)); + if ( + !nodeContains(e.currentTarget, getEventTarget(e) as Element) || + state.isKeyboardNavigationDisabled || + !ref.current || + !activeElement + ) { + return; + } + + if (keyboardNavigationBehavior === 'tab') { + if (getEventTarget(e) !== ref.current && e.key !== 'Tab') { + e.stopPropagation(); + return; + } + } + + switch (e.key) { + case 'Tab': { + if (keyboardNavigationBehavior === 'tab') { + // If there is another focusable element within this item, stop propagation so the tab key + // is handled by the browser and not by useSelectableCollection (which would take us out of the list). + let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); + walker.currentNode = activeElement; + let next = e.shiftKey ? walker.previousNode() : walker.nextNode(); + + if (next) { + e.stopPropagation(); + } + } + } + } + }; + // Grid cells can have focusable elements inside them. In this case, focus should // be marshalled to that element rather than focusing the cell itself. let onFocus = e => { @@ -269,10 +324,23 @@ export function useGridCell>( return; } - // If the cell itself is focused, wait a frame so that focus finishes propagatating + // if focus goes back to cell from child, make sure we don't refocus the cell if we are in focusMode=child + // since that would be a focus trap + if ( + focusMode === 'child' && + e.relatedTarget && + nodeContains(ref.current, e.relatedTarget as Element) + ) { + return; + } + + // If the cell itself is focused, wait a frame so that focus finishes propagating // up to the tree, and move focus to a focusable child if possible. requestAnimationFrame(() => { - if (focusMode === 'child' && getActiveElement() === ref.current) { + if ( + focusMode === 'child' && + getActiveElement(getOwnerDocument(ref.current)) === ref.current + ) { focus(); } }); @@ -281,11 +349,16 @@ export function useGridCell>( // oxlint-disable-next-line react/react-compiler let gridCellProps: DOMAttributes = mergeProps(itemProps, { role: 'gridcell', - onKeyDownCapture, + onKeyDownCapture: + keyboardNavigationBehavior !== 'tab' || allowsArrowNavigation ? onKeyDownCapture : undefined, + onKeyDown: keyboardNavigationBehavior === 'tab' ? onKeyDown : undefined, 'aria-colspan': node.colSpan, 'aria-colindex': node.colIndex != null ? node.colIndex + 1 : undefined, // aria-colindex is 1-based colSpan: isVirtualized ? undefined : node.colSpan, - onFocus + onFocus, + // make sure shift tabbing from a child of a cell doesnt move focus back to cell if focusMode="child" and in tab nav + // consistent with arrow nav and focusMode="child" since you can't go back to the cell there either + ...(focusMode === 'child' && keyboardNavigationBehavior === 'tab' ? {tabIndex: -1} : {}) }); if (isVirtualized) { diff --git a/packages/react-aria/src/grid/utils.ts b/packages/react-aria/src/grid/utils.ts index 35d756903b2..1d785b96405 100644 --- a/packages/react-aria/src/grid/utils.ts +++ b/packages/react-aria/src/grid/utils.ts @@ -22,6 +22,7 @@ interface GridMapShared { onCellAction?: (key: Key) => void; }; shouldSelectOnPressUp?: boolean; + keyboardNavigationBehavior?: 'arrow' | 'tab'; } // Used to share: diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 0c3f3441e6e..106ab557fcf 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -27,17 +27,11 @@ import { nodeContains } from '../utils/shadowdom/DOMFunctions'; import {getFocusableTreeWalker} from '../focus/FocusScope'; +import {getOwnerDocument} from '../utils/domHelpers'; import {getRowId, listMap} from './utils'; import {getScrollParent} from '../utils/getScrollParent'; -import { - HTMLAttributes, - KeyboardEvent as ReactKeyboardEvent, - MouseEvent as ReactMouseEvent, - PointerEvent as ReactPointerEvent, - useRef -} from 'react'; +import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; import {isFocusVisible} from '../interactions/useFocusVisible'; -import {isTabbable} from '../utils/isFocusable'; import type {ListState} from 'react-stately/useListState'; import {mergeProps} from '../utils/mergeProps'; import {scrollIntoViewport} from '../utils/scrollIntoView'; @@ -59,6 +53,19 @@ export interface AriaGridListItemOptions { shouldSelectOnPressUp?: boolean; /** Whether this item has children, even if not loaded yet. */ hasChildItems?: boolean; + /** + * Whether the row or its first focusable child element should be focused when the row is + * focused. + * + * @default 'row' + */ + focusMode?: 'child' | 'row'; + /** + * Whether the row should support arrow key navigation even when the containing collection uses + * tab keyboard navigation. Allows users to navigate between rows with arrow keys while + * focus is on an interactive child element within the row. + */ + allowsArrowNavigation?: boolean; } export interface GridListItemAria extends SelectableItemStates { @@ -94,7 +101,7 @@ export function useGridListItem( ref: RefObject ): GridListItemAria { // Copied from useGridCell + some modifications to make it not so grid specific - let {node, isVirtualized} = props; + let {node, isVirtualized, focusMode = 'row', allowsArrowNavigation} = props; // let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/gridlist'); let {direction} = useLocale(); @@ -106,12 +113,33 @@ export function useGridListItem( // focus to go to the item when the DOM node is reused for a different item in a virtualizer. let keyWhenFocused = useRef(null); let focus = () => { + if (ref.current === null) { + return; + } + + if (focusMode === 'child') { + // If focus is already on a focusable child within the row, early return so we don't shift focus + if ( + isFocusWithin(ref.current) && + ref.current !== getActiveElement(getOwnerDocument(ref.current)) + ) { + return; + } + + let treeWalker = getFocusableTreeWalker(ref.current, {tabbable: true}); + let focusable = treeWalker.firstChild() as FocusableElement; + if (focusable) { + focusSafely(focusable); + scrollIntoViewport(focusable, {containingElement: getScrollParent(focusable)}); + return; + } + } + // Don't shift focus to the row if the active element is a element within the row already // (e.g. clicking on a row button) if ( - ref.current !== null && - ((keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !isFocusWithin(ref.current)) + (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || + !isFocusWithin(ref.current) ) { focusSafely(ref.current); } @@ -175,7 +203,7 @@ export function useGridListItem( }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - let activeElement = getActiveElement(); + let activeElement = getActiveElement(getOwnerDocument(ref.current)); if ( !nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || @@ -188,7 +216,16 @@ export function useGridListItem( walker.currentNode = activeElement; if ( - handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current) + handleTreeExpansionKeys( + e, + state, + node, + hasChildRows, + direction, + activeElement, + ref.current, + allowsArrowNavigation + ) ) { return; } @@ -288,10 +325,31 @@ export function useGridListItem( } return; } + + // if focus goes back to cell from child, make sure we don't refocus the cell if we are in focusMode=child + // since that would be a focus trap + if ( + focusMode === 'child' && + e.relatedTarget && + nodeContains(ref.current, e.relatedTarget as Element) + ) { + return; + } + + // If the cell itself is focused, wait a frame so that focus finishes propagating + // up to the tree, and move focus to a focusable child if possible. + requestAnimationFrame(() => { + if ( + focusMode === 'child' && + getActiveElement(getOwnerDocument(ref.current)) === ref.current + ) { + focus(); + } + }); }; let onKeyDown = (e: ReactKeyboardEvent) => { - let activeElement = getActiveElement(); + let activeElement = getActiveElement(getOwnerDocument(ref.current)); if ( !nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || @@ -310,7 +368,16 @@ export function useGridListItem( } if ( - handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current) + handleTreeExpansionKeys( + e, + state, + node, + hasChildRows, + direction, + activeElement, + ref.current, + allowsArrowNavigation + ) ) { return; } @@ -348,7 +415,10 @@ export function useGridListItem( // oxlint-disable-next-line react/react-compiler let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, { role: 'row', - onKeyDownCapture: keyboardNavigationBehavior === 'arrow' ? onKeyDownCapture : undefined, + onKeyDownCapture: + keyboardNavigationBehavior === 'arrow' || allowsArrowNavigation + ? onKeyDownCapture + : undefined, onFocus, // 'aria-label': [(node.textValue || undefined), rowAnnouncement].filter(Boolean).join(', '), 'aria-label': node['aria-label'] || node.textValue || undefined, @@ -363,6 +433,10 @@ export function useGridListItem( id: getRowId(state, node.key) }); + if (focusMode === 'child' && allowsArrowNavigation && keyboardNavigationBehavior === 'tab') { + rowProps.tabIndex = -1; + } + // we need to guard against space/enter triggering selection/row link via usePress (from itemProps) so check if propagation // is stopped. this also fixes space not working in a textfield in a tree parent row let baseOnKeyDown = rowProps.onKeyDown; @@ -373,28 +447,6 @@ export function useGridListItem( } }; - // guard against presses triggering row selecition when they happen on elements within the row - // am currently assuming if it is tabbable it is interactive, but maybe can use a different kind of check - let baseOnPointerDown = rowProps.onPointerDown; - rowProps.onPointerDown = (e: ReactPointerEvent) => { - let target = getEventTarget(e) as Element | null; - if (target && target !== ref.current && isTabbable(target)) { - e.stopPropagation(); - return; - } - baseOnPointerDown?.(e); - }; - - let baseOnMouseDown = rowProps.onMouseDown; - rowProps.onMouseDown = (e: ReactMouseEvent) => { - let target = getEventTarget(e) as Element | null; - if (target && target !== ref.current && isTabbable(target)) { - e.stopPropagation(); - return; - } - baseOnMouseDown?.(e); - }; - if (isVirtualized) { let {collection} = state; let nodes = [...collection]; @@ -431,9 +483,10 @@ function handleTreeExpansionKeys( hasChildRows: boolean | undefined, direction: string, activeElement: Element | null, - rowRef: FocusableElement | null + rowRef: FocusableElement | null, + allowsArrowNavigation: boolean | undefined ): boolean { - if (!('expandedKeys' in state) || activeElement !== rowRef) { + if (!('expandedKeys' in state) || (!allowsArrowNavigation && activeElement !== rowRef)) { return false; } if ( diff --git a/packages/react-aria/src/selection/useSelectableItem.ts b/packages/react-aria/src/selection/useSelectableItem.ts index 4f6d6173132..b45620fc071 100644 --- a/packages/react-aria/src/selection/useSelectableItem.ts +++ b/packages/react-aria/src/selection/useSelectableItem.ts @@ -26,6 +26,7 @@ import {focusSafely} from '../interactions/focusSafely'; import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {getCollectionId, isNonContiguousSelectionModifier} from './utils'; import {isCtrlKeyPressed} from '../utils/keyboard'; +import {isTabbable} from '../utils/isFocusable'; import {mergeProps} from '../utils/mergeProps'; import {moveVirtualFocus} from '../focus/virtualFocus'; import {MultipleSelectionManager} from 'react-stately/useMultipleSelectionState'; @@ -364,7 +365,8 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte }; } - itemProps['data-collection'] = getCollectionId(manager.collection); + let collectionId = getCollectionId(manager.collection); + itemProps['data-collection'] = collectionId; itemProps['data-key'] = key; // oxlint-disable-next-line react/react-compiler itemPressProps.preventFocusOnPress = shouldUseVirtualFocus; @@ -451,19 +453,54 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte } : undefined; + let mergedItemProps = mergeProps( + // oxlint-disable-next-line react/react-compiler + itemProps, + allowsSelection || hasPrimaryAction || (shouldUseVirtualFocus && !isDisabled) ? pressProps : {}, + longPressEnabled ? longPressProps : {}, + // oxlint-disable-next-line react/react-compiler + {onDoubleClick, onDragStartCapture, onClick, id}, + // Prevent DOM focus from moving on mouse down when using virtual focus + shouldUseVirtualFocus ? {onMouseDown: e => e.preventDefault()} : undefined + ); + + // Guard against presses triggering selection when they happen on interactive children or collection items from different collections + // will need to trigger selection if the target is itself a collection item belonging to the same collection parent (aka a cell in a row) but + // not if the target is a child of a different collections aka taggroup in table cell. + let isChildInteraction = (target: Element) => { + let el: Element | null = target; + while (el && el !== ref.current) { + let elCollection = el.getAttribute('data-collection'); + if (elCollection != null) { + return elCollection !== collectionId; + } + el = el.parentElement; + } + return isTabbable(target); + }; + + let baseOnPointerDown = mergedItemProps.onPointerDown; + mergedItemProps.onPointerDown = e => { + let target = getEventTarget(e) as Element | null; + if (target && target !== ref.current && isChildInteraction(target)) { + e.stopPropagation(); + return; + } + baseOnPointerDown?.(e); + }; + + let baseOnMouseDown = mergedItemProps.onMouseDown; + mergedItemProps.onMouseDown = e => { + let target = getEventTarget(e) as Element | null; + if (target && target !== ref.current && isChildInteraction(target)) { + e.stopPropagation(); + return; + } + baseOnMouseDown?.(e); + }; + return { - itemProps: mergeProps( - // oxlint-disable-next-line react/react-compiler - itemProps, - allowsSelection || hasPrimaryAction || (shouldUseVirtualFocus && !isDisabled) - ? pressProps - : {}, - longPressEnabled ? longPressProps : {}, - // oxlint-disable-next-line react/react-compiler - {onDoubleClick, onDragStartCapture, onClick, id}, - // Prevent DOM focus from moving on mouse down when using virtual focus - shouldUseVirtualFocus ? {onMouseDown: e => e.preventDefault()} : undefined - ), + itemProps: mergedItemProps, isPressed, isSelected: manager.isSelected(key), isFocused: manager.isFocused && manager.focusedKey === key, diff --git a/packages/react-aria/src/table/useTableCell.ts b/packages/react-aria/src/table/useTableCell.ts index d00542238c5..3acc17ef78d 100644 --- a/packages/react-aria/src/table/useTableCell.ts +++ b/packages/react-aria/src/table/useTableCell.ts @@ -33,6 +33,18 @@ export interface AriaTableCellProps { * @deprecated */ onAction?: () => void; + /** + * Whether the cell or its first focusable child element should be focused when the cell is + * focused. Defaults to 'child' in arrow keyboard navigation mode and 'cell' in + * tab keyboard navigation mode. + */ + focusMode?: 'child' | 'cell'; + /** + * Whether the cell should support arrow key navigation even when the containing table uses + * tab keyboard navigation.Allows users to navigate between rows and cells with arrow keys while + * focus is on an interactive child element within the cell. + */ + allowsArrowNavigation?: boolean; } export interface TableCellAria { diff --git a/packages/react-aria/src/table/useTableColumnHeader.ts b/packages/react-aria/src/table/useTableColumnHeader.ts index f73ed5bd3a8..7e1cbf1ac5d 100644 --- a/packages/react-aria/src/table/useTableColumnHeader.ts +++ b/packages/react-aria/src/table/useTableColumnHeader.ts @@ -35,6 +35,18 @@ export interface AriaTableColumnHeaderProps { * virtual scroller. */ isVirtualized?: boolean; + /** + * Whether the column header or its first focusable child element should be focused when the + * column header is focused. Defaults to 'child' in arrow keyboard navigation mode and 'cell' in + * tab keyboard navigation mode. + */ + focusMode?: 'child' | 'cell'; + /** + * Whether the column header should support arrow key navigation even when the containing table + * uses tab keyboard navigation. Allows users to navigate between columns with arrow keys while + * focus is on an interactive child element within the cell. + */ + allowsArrowNavigation?: boolean; } export interface TableColumnHeaderAria { @@ -58,8 +70,7 @@ export function useTableColumnHeader( ): TableColumnHeaderAria { let {node} = props; let allowsSorting = node.props.allowsSorting; - // if there are no focusable children, the column header will focus the cell - let {gridCellProps} = useGridCell({...props, focusMode: 'child'}, state, ref); + let {gridCellProps} = useGridCell({focusMode: 'child', ...props}, state, ref); let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single'; diff --git a/packages/react-stately/src/table/useTableState.ts b/packages/react-stately/src/table/useTableState.ts index 9db9edade1d..2bc9c95db02 100644 --- a/packages/react-stately/src/table/useTableState.ts +++ b/packages/react-stately/src/table/useTableState.ts @@ -49,6 +49,13 @@ export interface TableProps extends MultipleSelection, Sortable, Expandable { shouldSelectOnPressUp?: boolean; /** The id of the column that displays hierarchical data. */ treeColumn?: Key; + /** + * Whether keyboard navigation to focusable elements within the cells is + * via the left/right arrow keys or the tab key. + * + * @default 'arrow' + */ + keyboardNavigationBehavior?: 'arrow' | 'tab'; } export interface TableState extends GridState> { diff --git a/starters/docs/src/Table.tsx b/starters/docs/src/Table.tsx index f586b21b7fa..fa4c60ca315 100644 --- a/starters/docs/src/Table.tsx +++ b/starters/docs/src/Table.tsx @@ -75,6 +75,10 @@ export function TableHeader({columns, children, ...otherProps}: TableHeaderPr width={32} minWidth={32} style={{width: 32}} + ///- begin highlight -/// + focusMode="child" + allowsArrowNavigation + ///- end highlight -/// className="react-aria-Column button-base"> {selectionMode === 'multiple' && } @@ -97,7 +101,12 @@ export function Row({id, columns, children, ...otherProps}: RowProps) { )} {selectionBehavior === 'toggle' && ( - + )}