From 7637f1d6393bdf0ce4a9e5a7978f70d7ae11e469 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 4 Aug 2026 17:20:19 +1000 Subject: [PATCH] feat: SideNav collapse behaviour in side panel --- packages/@react-spectrum/s2/intl/en-US.json | 2 + packages/@react-spectrum/s2/src/SideNav.tsx | 172 ++++++++++++++++-- .../s2/stories/SideNav.stories.tsx | 52 +++++- 3 files changed, 210 insertions(+), 16 deletions(-) diff --git a/packages/@react-spectrum/s2/intl/en-US.json b/packages/@react-spectrum/s2/intl/en-US.json index 1a1e1570bae..7c78b9935f2 100644 --- a/packages/@react-spectrum/s2/intl/en-US.json +++ b/packages/@react-spectrum/s2/intl/en-US.json @@ -28,6 +28,8 @@ "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Select…", "picker.selectedCount": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}", + "sidepanel.collapse": "Collapse side panel", + "sidepanel.expand": "Expand side panel", "slider.maximum": "Maximum", "slider.minimum": "Minimum", "table.cancel": "Cancel", diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index fb7b477a29f..037f8dfa150 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {ActionButton} from './ActionButton'; import {ActionButtonGroupContext} from './ActionButtonGroup'; import {ActionMenuContext} from './ActionMenu'; import {baseColor, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; @@ -23,8 +24,10 @@ import { } from './style-utils' with {type: 'macro'}; import Chevron from '../ui-icons/Chevron'; import {createContext, forwardRef, ReactNode, useContext, useRef, useState} from 'react'; +import {createIcon} from './Icon'; import {DOMRef, forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; import {IconContext} from './Icon'; +import intlMessages from '../intl/*.json'; import {Link} from 'react-aria-components/Link'; import {pressScale} from './pressScale'; import {Provider, useContextProps} from 'react-aria-components/slots'; @@ -41,8 +44,11 @@ import { SideNavItemContentRenderProps } from 'react-aria-components/SideNav'; import {Text, TextContext} from './Content'; +import {useControlledState} from 'react-stately/useControlledState'; import {useDOMRef} from './useDOMRef'; +import {useHover} from 'react-aria/useHover'; import {useLocale} from 'react-aria/I18nProvider'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useScale} from './utils'; export interface SideNavProps @@ -75,7 +81,10 @@ const sideNavWrapper = style( { minHeight: 0, height: 'full', - minWidth: 160, + minWidth: { + default: 160, + isInSidePanel: 'unset' + }, display: 'flex', isolation: 'isolate', disableTapHighlight: true, @@ -94,7 +103,10 @@ const tree = style({ userSelect: 'none', minHeight: 0, minWidth: 0, - width: 'full', + width: { + default: 'full', + isInSidePanel: 'unset' + }, height: 'full', overflowY: 'auto', overflowX: 'hidden', @@ -117,16 +129,17 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid let {children, UNSAFE_className, UNSAFE_style, selectedRoute, ...rest} = props; let domRef = useDOMRef(ref); + let isInSidePanel = !!useContext(SidePanelContext); return (
tree(renderProps)}> + className={renderProps => tree({...renderProps, isInSidePanel})}> {children}
@@ -168,7 +181,10 @@ const treeCellGrid = style({ boxSizing: 'border-box', alignContent: 'center', alignItems: 'center', - gridTemplateColumns: [12, 'auto', 'auto', '1fr', 'auto', 'auto'], + gridTemplateColumns: { + default: [12, 'auto', 'auto', '1fr', 'auto', 'auto'], + isCollapsed: [12, 'auto', 'auto', '1fr', 0, 12] + }, gridTemplateRows: '1fr', gridTemplateAreas: ['. level-padding icon content actions actionmenu'], paddingEnd: 4, // account for any focus rings on the last item in the cell @@ -192,14 +208,21 @@ const treeCellGrid = style({ const treeIcon = style({ gridArea: 'icon', - marginEnd: 'text-to-visual', + marginEnd: { + default: 'text-to-visual', + isCollapsed: 0 + }, '--iconPrimary': { type: 'fill', value: 'currentColor' } }); -const treeContent = style({ +const treeContent = style<{isCollapsed?: boolean}>({ + display: { + default: 'block', + isCollapsed: 'none' + }, gridArea: 'content', paddingY: `--centerPadding` }); @@ -326,6 +349,7 @@ export const SideNavItemContent = (props: SideNavItemContentProps): ReactNode => }; const SideNavItemContentInner = props => { + let {isCollapsed = false} = useContext(SidePanelContext); let { isExpanded, hasChildItems, @@ -351,7 +375,8 @@ const SideNavItemContentInner = props => { className={treeCellGrid({ isDisabled, isSelected: isCurrent, - isDescendantSelected: isCurrentAncestor && !isExpanded + isDescendantSelected: isCurrentAncestor && !isExpanded, + isCollapsed })}>
{ /> { [ IconContext, { - render: centerBaseline({slot: 'icon', styles: treeIcon}), + render: centerBaseline({slot: 'icon', styles: treeIcon({isCollapsed})}), styles: style({size: fontRelative(20), flexShrink: 0}) } ], @@ -390,6 +415,7 @@ const SideNavItemContentInner = props => {
{ interface ExpandableRowChevronProps { isExpanded?: boolean; + isCollapsed?: boolean; isDisabled?: boolean; isRTL?: boolean; scale: 'medium' | 'large'; @@ -408,6 +435,10 @@ interface ExpandableRowChevronProps { } const expandButton = style({ + display: { + default: 'flex', + isCollapsed: 'none' + }, gridArea: 'expand-button', color: { default: 'inherit', @@ -418,7 +449,6 @@ const expandButton = style({ }, height: 32, width: 32, - display: 'flex', flexWrap: 'wrap', alignContent: 'center', justifyContent: 'center', @@ -447,7 +477,7 @@ function ExpandableRowChevron(props: ExpandableRowChevronProps) { expandButtonRef, ButtonContext ); - let {isExpanded, scale, isHidden} = fullProps; + let {isExpanded, scale, isHidden, isCollapsed} = fullProps; let {direction} = useLocale(); return ( @@ -456,7 +486,14 @@ function ExpandableRowChevron(props: ExpandableRowChevronProps) { ref={ref} slot="chevron" className={renderProps => - expandButton({...renderProps, isExpanded, isRTL: direction === 'rtl', scale, isHidden}) + expandButton({ + ...renderProps, + isExpanded, + isCollapsed, + isRTL: direction === 'rtl', + scale, + isHidden + }) }> { let {children} = props; let linkFocus = useContext(SideNavItemLinkContext); + let {isCollapsed = false} = useContext(SidePanelContext); return ( { ); }; + +interface SidePanelProps extends Omit, 'children'> { + /** The content of the side panel. */ + children?: ReactNode; + /** Whether the side panel is collapsed (controlled). */ + isCollapsed?: boolean; + /** Whether the side panel is collapsed by default (uncontrolled). */ + defaultCollapsed?: boolean; + /** Handler that is called when the collapsed state changes. */ + onCollapsedChange?: (isCollapsed: boolean) => void; +} + +const SidePanelContext = createContext<{ + isCollapsed?: boolean; + setCollapsed?: (isCollapsed: boolean) => void; +}>({}); + +export const SidePanel = /*#__PURE__*/ (forwardRef as forwardRefType)(function SidePanelProps( + props: SidePanelProps, + ref: DOMRef +) { + let domRef = useDOMRef(ref); + let [isCollapsed, setCollapsed] = useControlledState( + props.isCollapsed, + props.defaultCollapsed ?? false, + props.onCollapsedChange + ); + + return ( + +
+
{props.children}
+
+ +
+
+
+ ); +}); + +function ExpandButton(props: {isCollapsed: boolean; setCollapsed: (isCollapsed: boolean) => void}) { + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + + let label = stringFormatter.format(`sidepanel.${props.isCollapsed ? 'expand' : 'collapse'}`); + + return ( + + ); +} + +function PanelToggleButton({isCollapsed, setCollapsed}: any) { + let [isHovered, setHovered] = useState(false); + let {hoverProps} = useHover({onHoverChange: setHovered}); + return ( +
+ { + setCollapsed(!isCollapsed); + setHovered(false); + }}> + {/* @ts-ignore */} + + +
+ ); +} + +const PanelIcon = createIcon(props => { + let {isCollapsed, isHovered, ...otherProps} = props as any; + return ( + + + + + ); +}); diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index f266bb2f972..08a0d7b14b0 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -14,12 +14,15 @@ import {action} from 'storybook/actions'; import {ActionMenu} from '../src/ActionMenu'; import {Avatar} from '../src/Avatar'; import {categorizeArgTypes, getActionArgs} from './utils'; +import CCLibrary from '@react-spectrum/s2/icons/CCLibrary'; 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 Files from '@react-spectrum/s2/icons/Files'; import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; import {Heading, Keyboard, Text} from '../src/Content'; +import Images from '@react-spectrum/s2/icons/Images'; import {MenuItem} from '../src/Menu'; import type {Meta, StoryObj} from '@storybook/react'; import Paste from '../s2wf-icons/S2_Icon_Paste_20_N.svg'; @@ -33,7 +36,8 @@ import { SideNavItemContent, SideNavItemLink, SideNavProps, - SideNavSection + SideNavSection, + SidePanel } from '../src/SideNav'; import {style} from '../style' with {type: 'macro'}; import {useLandmark} from 'react-aria'; @@ -534,3 +538,49 @@ export const WithLandmark: AppLayoutStoryObj = { } } }; + +export const SidePanelExample = { + render: args => ( +
+
+ + + + + + + Files + + + + + + + + Your Libraries + + + + + + + Photos + + + + + + +
+
+ ) +};