diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx new file mode 100644 index 00000000000..124ae8e1ba7 --- /dev/null +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -0,0 +1,828 @@ +/* + * 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, 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 { + getAllowedOverrides, + StylesPropWithHeight, + UnsafeStyles +} from './style-utils' with {type: 'macro'}; +import {getEventTarget} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import { + GridListSectionProps, + TreeItemProps as RACTreeItemProps, + TreeProps as RACTreeProps, + Tree, + TreeHeader, + TreeItem, + TreeItemContent, + TreeItemContentProps, + TreeItemRenderProps, + TreeRenderProps, + TreeSection +} from 'react-aria-components/Tree'; +import {IconContext} from './Icon'; +import {Link} from 'react-aria-components/Link'; +import {Provider, useContextProps} from 'react-aria-components/slots'; +import React, { + createContext, + forwardRef, + JSXElementConstructor, + ReactElement, + KeyboardEvent as ReactKeyboardEvent, + ReactNode, + RefObject, + useContext, + useEffect, + useRef, + useState +} from 'react'; +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' + | keyof GlobalDOMAttributes + >, + UnsafeStyles, + SideNavStyleProps { + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StylesPropWithHeight; + /** The route that is currently selected. */ + selectedRoute?: string; +} + +interface SideNavStyleProps {} + +export interface SideNavItemProps extends Omit< + RACTreeItemProps, + 'className' | 'style' | 'render' | 'onClick' | keyof GlobalDOMAttributes +> { + /** Whether this item has children, even if not loaded yet. */ + hasChildItems?: boolean; +} + +interface TreeRendererContextValue { + renderer?: (item) => ReactElement>; +} +const TreeRendererContext = createContext({}); + +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 { + /** + * Ref to the tree state, bridged up from SideNavItemContent so arrow-key handling can toggle + * expansion. + */ + stateRef?: RefObject | null>; + selectedRoute?: string; + /** The last route the focused key was synced to; dedupes the focus sync across items. */ + syncedRouteRef?: RefObject; +} +let InternalSideNavContext = createContext({}); + +/** + * A tree view provides users with a way to navigate nested hierarchical information. + */ +export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function SideNav( + props: SideNavProps, + ref: DOMRef +) { + let {children, UNSAFE_className, UNSAFE_style, selectedRoute} = props; + + let renderer; + if (typeof children === 'function') { + renderer = children; + } + + let domRef = useDOMRef(ref); + let scrollRef = useRef(null); + let stateRef = useRef | null>(null); + let {direction} = useLocale(); + + // 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); + + // RAC swallows arrow keys at the collection level (stopPropagation during capture), so a handler + // on the link never sees them. Intercept here on an ancestor, before RAC's row handler runs, and + // expand a collapsed row when the expand arrow is pressed while focus is on its link. + let onKeyDownCapture = (e: ReactKeyboardEvent) => { + let state = stateRef.current; + if (!state) { + return; + } + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') { + return; + } + let target = getEventTarget(e); + let link = target.closest?.('a'); + if (!link || link !== target) { + return; + } + let rowEl = link.closest('[role="row"]'); + if (!rowEl) { + return; + } + let key = rowEl.dataset.key; + if (key == null) { + return; + } + let node = state.collection.getItem(key); + // null = leaf, 'true' = expanded, 'false' = collapsed. + let ariaExpanded = rowEl.getAttribute('aria-expanded'); + let collapseKey = direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + let expandKey = direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + + // Move focus to the parent item. RAC's own parent-move (handleTreeExpansionKeys) only runs + // when the row itself has DOM focus, so with focusMode="child" (focus on the link) it never + // fires; replicate it here. Pointing the focused key at the parent makes useSelectableItem + // move DOM focus to it (and, in focusMode="child", into the parent's link). + let moveToParent = () => { + if (node?.parentKey != null && state.collection.getItem(node.parentKey)?.type === 'item') { + e.stopPropagation(); + e.preventDefault(); + state.selectionManager.setFocusedKey(node.parentKey); + } + }; + + if (e.key === collapseKey) { + if (ariaExpanded === 'true') { + // Expanded parent: collapse it (focus stays on the row). + e.stopPropagation(); + e.preventDefault(); + state.toggleKey(key); + } else { + // Leaf or already-collapsed row: step up to the parent. + moveToParent(); + } + } else if (e.key === expandKey && ariaExpanded === 'false') { + // Collapsed parent: expand it. + e.stopPropagation(); + e.preventDefault(); + state.toggleKey(key); + } + }; + + return ( +
+ + + tree(renderProps)} + selectionMode="single" + keyboardNavigationBehavior="arrow" + disallowEmptySelection + ref={scrollRef}> + {props.children} + + + +
+ ); +}); + +const treeRow = style({ + outlineStyle: 'none', + position: 'relative', + display: 'flex', + height: 32, + width: 'full', + boxSizing: 'border-box', + font: 'ui', + color: { + default: baseColor('neutral-subdued'), + forcedColors: 'ButtonText' + }, + cursor: { + default: 'default', + isLink: 'pointer' + }, + '--borderRadiusTreeItem': { + type: 'borderTopStartRadius', + value: 'sm' + }, + borderRadius: 'sm', + marginTop: { + ':not([aria-posinset="1"])': '[6px]', + ':first-child': 0 + } +}); + +const treeCellGrid = style({ + display: 'grid', + width: 'full', + height: 'full', + boxSizing: 'border-box', + alignContent: 'center', + alignItems: 'center', + gridTemplateColumns: [12, 'auto', '1fr', 'auto', 'auto'], + gridTemplateRows: '1fr', + gridTemplateAreas: ['. level-padding 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' + }, + '--rowSelectedBorderColor': { + type: 'outlineColor', + value: { + default: 'gray-800', + isFocusVisible: 'focus-ring', + forcedColors: 'Highlight' + } + }, + '--rowForcedFocusBorderColor': { + type: 'outlineColor', + value: { + default: 'focus-ring', + forcedColors: 'Highlight' + } + }, + '--borderColor': { + type: 'borderColor', + value: { + default: 'blue-900', + forcedColors: 'ButtonBorder' + } + }, + forcedColorAdjust: 'none' +}); + +const treeIcon = style({ + gridArea: 'icon', + marginEnd: 'text-to-visual', + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } +}); + +const treeContent = style({ + gridArea: 'content', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden' +}); + +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: 'pointer' +}); + +const treeActions = style({ + gridArea: 'actions', + marginStart: 2, + marginEnd: 4 +}); + +const treeActionMenu = style({ + gridArea: 'actionmenu' +}); + +const SideNavItemLinkContext = createContext<{ + 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; + + return ( + + 0 ? 'child' : undefined} + className={renderProps => treeRow(renderProps)} + /> + + ); +}; + +export interface SideNavItemContentProps extends Omit { + /** Rendered contents of the tree item or child items. */ + children: ReactNode; +} + +const selectedIndicator = style<{isDisabled: boolean; isSelected: boolean}>({ + position: 'absolute', + display: { + default: 'none', + isSelected: 'block' + }, + backgroundColor: { + default: 'neutral', + 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' +}); + +const hoveredIndicator = style({ + position: 'absolute', + backgroundColor: { + default: 'neutral-subdued', + forcedColors: 'Highlight' + }, + 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 here +// (rather than 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 +function useRouteFocusSync({state}: {state: TreeState}): void { + let {selectedRoute, syncedRouteRef} = useContext(InternalSideNavContext); + let {collection, selectionManager} = state; + useEffect(() => { + if ( + selectedRoute == null || + syncedRouteRef == null || + syncedRouteRef.current === selectedRoute + ) { + return; + } + let key = findKeyForRoute(collection, selectedRoute); + if (key != null) { + syncedRouteRef.current = selectedRoute; + selectionManager.setFocusedKey(key); + } + }, [selectedRoute, collection, syncedRouteRef, selectionManager]); +} + +export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => { + let {children} = props; + let scale = useScale(); + let linkProps = useContext(SideNavItemLinkContext); + let {stateRef, selectedRoute} = useContext(InternalSideNavContext); + + return ( + + {({ + isExpanded, + hasChildItems, + isDisabled, + isSelected, + id, + state, + isHovered, + isFocusVisible, + isFocusVisibleWithin + }) => { + return ( + + {children} + + ); + }} + + ); +}; + +const SideNaveItemContentInner = props => { + let { + isExpanded, + hasChildItems, + isDisabled, + isSelected, + linkProps, + scale, + id, + state, + stateRef, + 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); + // Bridge the tree state up to SideNav so its arrow-key handler can toggle expansion. + useEffect(() => { + if (stateRef) { + stateRef.current = state; + } + }, [state, stateRef]); + + useRouteFocusSync({state}); + + return ( + <> + {isHovered &&
} +
+
+ {(isFocusVisible || (isFocusVisibleWithin && isLinkFocused)) && ( +
+ )} +
+ + {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 GridListSectionProps {} + +export function SideNavSection(props: SideNavSectionProps) { + return ( + + {props.children} + + ); +} + +export const SideNavHeader = (props: {children: ReactNode}): ReactNode => { + return ( + + {props.children} + + ); +}; + +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?.['data-href'] === route) { + return key; + } + } + return null; +} + +// 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..d235c810fc8 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -0,0 +1,340 @@ +/** + * 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 {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 Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import {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, useState} from 'react'; +import {RouterProvider} from 'react-aria-components'; +import { + SideNav, + SideNavHeader, + SideNavItem, + SideNavItemContent, + SideNavItemLink, + SideNavProps, + SideNavSection +} from '../src/SideNav'; + +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: SideNavExampleStatic, + args: {} +}; + +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' +}; 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..882a1cd11ab --- /dev/null +++ b/packages/@react-spectrum/s2/test/SideNav.test.tsx @@ -0,0 +1,365 @@ +/* + * 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: SideNavProps = {}) { + 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: SideNavProps = {}) { + let [selectedRoute, setSelectedRoute] = React.useState('/files'); + return ( + + + + ); +} + +// Three levels deep: Your libraries > Projects 1 > Projects 1A. +function DeepSideNavExample(props: SideNavProps = {}) { + return ( + + + + + Your libraries + + + + + + Projects 1 + + + + + + Projects 1A + + + + + + + ); +} + +// An item whose row has both a link and a secondary action (ActionMenu). +function ActionMenuSideNavExample(props: SideNavProps = {}) { + return ( + + + + + Your files + + + + Edit + + + Delete + + + + + + ); +} + +// 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: SideNavProps = {}) { + return ( + + + + + Your files + + + + + + Section + + + Edit + + + Delete + + + + + + ); +} + +describe('SideNav', () => { + let user: UserEvent; + + beforeAll(function () { + user = userEvent.setup({delay: null, pointerMap}); + // jsdom doesn't implement getAnimations, which the selection indicator relies on. + Element.prototype.getAnimations = jest.fn().mockImplementation(() => []); + 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', () => { + // Projects 2 is nested under (an expanded) Your libraries — farther down and visible. + let {getByRole} = render( + + ); + act(() => { + jest.runAllTimers(); + }); + + // Focusing the tree moves focus to its focused key. That key was set from selectedRoute, so + // focus lands on Projects 2 rather than the first item. + act(() => { + getByRole('treegrid').focus(); + }); + expect(getByRole('link', {name: 'Projects 2'})).toHaveFocus(); + }); + + it('arrow left from a deep leaf steps to parent, collapses it, then moves to the grandparent', async () => { + let {getByRole, queryByRole} = render( + + ); + act(() => { + jest.runAllTimers(); + }); + + // Focus starts on the deepest leaf (synced from selectedRoute). + act(() => { + getByRole('treegrid').focus(); + }); + 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('shows the row focus ring only when the link is focused, not the ActionMenu', async () => { + let {getByRole} = render(); + act(() => { + jest.runAllTimers(); + }); + + // The row focus ring is an extra element rendered into the cell grid alongside the link. + let link = getByRole('link', {name: 'Your files'}); + let cell = link.parentElement!; + + // Focus the link (focused key was synced to /files): the ring is present. + act(() => { + getByRole('treegrid').focus(); + }); + act(() => { + jest.runAllTimers(); + }); + expect(link).toHaveFocus(); + let childrenWithLinkFocused = cell.children.length; + + // Move focus to the ActionMenu trigger in the same row: the ring is gone. + await user.keyboard('{ArrowRight}'); + act(() => { + jest.runAllTimers(); + }); + expect(getByRole('button', {name: 'More actions'})).toHaveFocus(); + expect(cell.children.length).toBe(childrenWithLinkFocused - 1); + }); + + it('keeps focus on the row (not the ActionMenu) for an item with no href/link', async () => { + let {getByRole} = render(); + act(() => { + jest.runAllTimers(); + }); + + // Tab lands on the first item's link, ArrowDown moves to the link-less "Section" row. + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + act(() => { + jest.runAllTimers(); + }); + + // 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('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( + + + + ); + + // Tab moves focus to the first item's link. + await user.tab(); + expect(getByRole('link', {name: 'Your files'})).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(navigate).toHaveBeenCalledWith('/files', undefined); + }); +}); 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,