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..499af41e28d --- /dev/null +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -0,0 +1,770 @@ +/* + * 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 { + centerPadding, + getAllowedOverrides, + StylesPropWithHeight, + UnsafeStyles +} from './style-utils' with {type: 'macro'}; +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 {IconContext} from './Icon'; +import {Link} from 'react-aria-components/Link'; +import {pressScale} from './pressScale'; +import {Provider, useContextProps} from 'react-aria-components/slots'; +import { + TreeItemProps as RACTreeItemProps, + TreeProps as RACTreeProps, + Tree, + TreeHeader, + TreeHeaderProps, + TreeItem, + TreeItemContent, + TreeItemContentProps, + TreeItemRenderProps, + TreeRenderProps, + TreeSection, + TreeSectionProps +} 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' + | 'onAction' + | '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' + }, + 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', + paddingBottom: 0, + scrollPaddingBottom: 0, + '--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 + }, + '--centerPadding': { + type: 'paddingTop', + value: centerPadding() + } +}); + +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: { + isSelected: 'bold', + isDescendantSelected: 'bold' + }, + transition: 'default', + forcedColorAdjust: 'none' +}); + +const treeIcon = style({ + gridArea: 'icon', + marginEnd: 'text-to-visual', + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } +}); + +const treeContent = style({ + gridArea: 'content', + paddingY: `--centerPadding` +}); + +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; + // So we can scale the row when the link is pressed. + onPressChange?: (isPressed: 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, + isPressed, + isFocusVisible, + isFocusVisibleWithin + }) => { + return ( + + {children} + + ); + }} + + ); +}; + +const SideNavItemContentInner = props => { + let { + isExpanded, + hasChildItems, + isDisabled, + isSelected, + linkProps, + scale, + id, + state, + selectedRoute, + isHovered, + isPressed, + isFocusVisible, + isFocusVisibleWithin, + children + } = props; + + useRouteFocusSync({state}); + + // 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); + let [isLinkPressed, setLinkPressed] = useState(false); + let cellRef = useRef(null); + + let hasLink = linkProps.href != null && linkProps.href.length > 0; + + // oxlint-disable-next-line react-compiler + let itemScaling = pressScale(cellRef)({isPressed: isLinkPressed || isPressed}); + + 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< + TreeSectionProps, + 'value' | 'render' | 'style' | 'className' +> {} + +export function SideNavSection(props: SideNavSectionProps) { + return ( + + {props.children} + + ); +} + +export interface SideNavHeaderProps extends Omit< + TreeHeaderProps, + '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) { + let parent = collection.getItem(node.parentKey); + if (parent?.type === 'item' && !expandedKeys.has(node.parentKey)) { + target = node.parentKey; + } + node = parent; + } + 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/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx new file mode 100644 index 00000000000..f266bb2f972 --- /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, height: 240}) + }, + parameters: { + docs: { + disable: true + } + } +}; + +export const ExampleAutoWidth: SideNavStoryObj = { + render: SideNavExampleStatic, + args: { + styles: style({width: 'auto', height: 240}) + }, + 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..1e95cb8199c --- /dev/null +++ b/packages/@react-spectrum/s2/test/SideNav.test.tsx @@ -0,0 +1,469 @@ +/* + * 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, + SideNavHeader, + SideNavItem, + SideNavItemContent, + SideNavItemLink, + SideNavProps, + SideNavSection +} 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('can tab onto the selected item when items are grouped in sections', async () => { + // A Section ancestor is always visible; it must not be treated as a collapsed parent (which + // would move the tab stop onto the section, leaving no focusable link and breaking tabbing in). + function RoutedSideNavExample(props: Partial>) { + let [selectedRoute, setSelectedRoute] = React.useState('/projects'); + return ( + + + + Photography + + + + Your files + + + + + + Work + + + + Your libraries + + + + + + Projects 2 + + + + + + + + ); + } + let {getByRole} = render(); + act(() => { + jest.runAllTimers(); + }); + + await user.tab(); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowDown}'); + expect(getByRole('link', {name: 'Projects 2'})).toHaveFocus(); + await user.keyboard('{Enter}'); + expect(getByRole('link', {name: 'Projects 2'})).toHaveAttribute('aria-current', 'page'); + + await user.keyboard('{ArrowUp}'); + expect(getByRole('link', {name: 'Your libraries'})).toHaveFocus(); + await user.keyboard('{ArrowUp}'); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + await user.keyboard('{Enter}'); + expect(getByRole('link', {name: 'Your files'})).toHaveAttribute('aria-current', 'page'); + + await user.keyboard('{ArrowDown}'); + 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/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..1923129fab3 --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/SideNav.mdx @@ -0,0 +1,390 @@ +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 version = 'alpha'; +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 + + + + )} + +``` + +## 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} + } + + ); + }} + + )} + +``` + +## Routing + +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. The example below derives `selectedRoute` from the router's current location, +and provides React Aria's `RouterProvider` with the router's `navigate` function so that clicking an item performs a client side navigation. + +If a SideNavItem has an `href`, then you must pass a `SideNavItemLink` as a child of the `SideNavItemContent`. + +See Routing setup in [Getting Started](./getting-started) for more info. + +```tsx render type="s2" files={['packages/dev/s2-docs/pages/s2/router.tsx']} +"use client"; +import {SideNav, SideNavItem, SideNavItemContent, SideNavItemLink, Text} from '@react-spectrum/s2/SideNav'; +import {RouterProvider} from 'react-aria-components'; +import {MemoryRouter, useLocation, useNavigate} from './router'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import CCLibrary from '@react-spectrum/s2/icons/CCLibrary'; +import Files from '@react-spectrum/s2/icons/Files'; +import Images from '@react-spectrum/s2/icons/Images'; + +function FilesSideNav() { + /*- begin highlight -*/ + let navigate = useNavigate(); + let {pathname} = useLocation(); + + return ( + + + {/*- end highlight -*/} + + + + + Files + + + + + + + + Your Libraries + + + + + + + Photos + + + + + + + ); +} + + + + +``` + +## 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 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 Download from '@react-spectrum/s2/icons/Download'; +import Apps from '@react-spectrum/s2/icons/Apps'; + + + {({selectedRoute}) => ( + + {/*- begin highlight -*/} + + Favorites + {/*- end highlight -*/} + + + + + Applications + + + + + + + + Downloads + + + + + + Workspaces + + + + + Files + + + + + + + + Your Libraries + + + + + + + Photos + + + + + + + + + Shared with You + + + + + + + Animations + + + + + + + )} + +``` + +## 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/router.tsx b/packages/dev/s2-docs/pages/s2/router.tsx new file mode 100644 index 00000000000..4e37c970ee8 --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/router.tsx @@ -0,0 +1,37 @@ +'use client'; +import React, {createContext, ReactNode, useContext, useState} from 'react'; + +// A tiny in-memory router that mirrors the parts of the `react-router` API used +// to integrate with React Aria. In a real app, `MemoryRouter`, `useNavigate`, and +// `useLocation` would come from the `react-router` package — the wiring above is +// identical. + +interface Location { + pathname: string; +} + +interface RouterContextValue { + location: Location; + navigate: (pathname: string) => void; +} + +const RouterContext = createContext({ + location: {pathname: '/'}, + navigate: () => {} +}); + +export function MemoryRouter(props: {initialEntries?: string[]; children: ReactNode}) { + let {initialEntries = ['/'], children} = props; + let [location, setLocation] = useState({pathname: initialEntries[0]}); + let navigate = (pathname: string) => setLocation({pathname}); + + return {children}; +} + +export function useLocation(): Location { + return useContext(RouterContext).location; +} + +export function useNavigate(): (pathname: string) => void { + return useContext(RouterContext).navigate; +} 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..532aa53e9b6 100644 --- a/packages/react-aria-components/exports/Tree.ts +++ b/packages/react-aria-components/exports/Tree.ts @@ -25,6 +25,8 @@ export { TreeStateContext } from '../src/Tree'; export type { + TreeSectionProps, + TreeHeaderProps, TreeProps, TreeRenderProps, TreeEmptyStateRenderProps, diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index b30c850f97f..e72133c7b1d 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -378,6 +378,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/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 1d2aed86b81..0822fd90294 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -1260,19 +1260,14 @@ function RootDropIndicator() { ); } -export interface GridListSectionProps - extends SectionProps, DOMRenderProps<'div', undefined> {} +export interface TreeSectionProps extends SectionProps, DOMRenderProps<'div', undefined> {} /** * A TreeSection represents a section within a Tree. */ export const TreeSection = /*#__PURE__*/ createBranchComponent( SectionNode, - ( - props: GridListSectionProps, - ref: ForwardedRef, - item: Node - ) => { + (props: TreeSectionProps, ref: ForwardedRef, item: Node) => { let state = useContext(TreeStateContext)!; let {CollectionBranch} = useContext(CollectionRendererContext); let headingRef = useRef(null); @@ -1309,7 +1304,9 @@ export const TreeSection = /*#__PURE__*/ createBranchComponent( } ); -export const TreeHeader = (props: GridListHeaderProps): ReactNode => { +export interface TreeHeaderProps extends GridListHeaderProps {} + +export const TreeHeader = (props: TreeHeaderProps): ReactNode => { return ( {props.children} diff --git a/packages/react-aria-components/test/Link.test.js b/packages/react-aria-components/test/Link.test.js index 93b483ad9ef..aca49b9be42 100644 --- a/packages/react-aria-components/test/Link.test.js +++ b/packages/react-aria-components/test/Link.test.js @@ -142,12 +142,18 @@ describe('Link', () => { it('should support press state', async () => { let onPress = jest.fn(); + let onPressStart = jest.fn(); + let onPressEnd = jest.fn(); + let onPressChange = jest.fn(); let onClick = jest.fn(); let onClickCapture = jest.fn(); let {getByRole} = render( (isPressed ? 'pressed' : '')} onPress={onPress} + onPressStart={onPressStart} + onPressEnd={onPressEnd} + onPressChange={onPressChange} onClick={onClick} onClickCapture={onClickCapture}> Test @@ -166,6 +172,9 @@ describe('Link', () => { expect(link).not.toHaveAttribute('data-pressed'); expect(link).not.toHaveClass('pressed'); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledTimes(2); expect(onPress).toHaveBeenCalledTimes(1); expect(onClick).toHaveBeenCalledTimes(1); expect(onClickCapture).toHaveBeenCalledTimes(1); diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 8d3a2139747..106ab557fcf 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -216,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; } @@ -359,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; } @@ -415,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; @@ -461,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/link/useLink.ts b/packages/react-aria/src/link/useLink.ts index ec49c5e578f..fc2ad403476 100644 --- a/packages/react-aria/src/link/useLink.ts +++ b/packages/react-aria/src/link/useLink.ts @@ -59,6 +59,7 @@ export function useLink(props: AriaLinkOptions, ref: RefObject