From 7637f1d6393bdf0ce4a9e5a7978f70d7ae11e469 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 4 Aug 2026 17:20:19 +1000 Subject: [PATCH 1/3] 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 + + + + + + +
+
+ ) +}; From b35f010a52d7fddf878c79ca1ab82da7f16be348 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 11 Aug 2026 09:50:02 +1000 Subject: [PATCH 2/3] add selection bar when sidepanel collapses but child is selected --- packages/@react-spectrum/s2/src/SideNav.tsx | 34 ++++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index 037f8dfa150..ab08f5e9a32 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -23,9 +23,9 @@ import { UnsafeStyles } from './style-utils' with {type: 'macro'}; import Chevron from '../ui-icons/Chevron'; -import {createContext, forwardRef, ReactNode, useContext, useRef, useState} from 'react'; +import {createContext, forwardRef, ReactNode, useContext, useMemo, useRef, useState} from 'react'; import {createIcon} from './Icon'; -import {DOMRef, forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; +import {DOMRef, forwardRefType, GlobalDOMAttributes, Key} from '@react-types/shared'; import {IconContext} from './Icon'; import intlMessages from '../intl/*.json'; import {Link} from 'react-aria-components/Link'; @@ -126,10 +126,32 @@ export const SideNav = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sid props: SideNavProps, ref: DOMRef ) { - let {children, UNSAFE_className, UNSAFE_style, selectedRoute, ...rest} = props; + let { + children, + UNSAFE_className, + UNSAFE_style, + selectedRoute, + expandedKeys: propExpandedKeys, + defaultExpandedKeys: propDefaultExpandedKeys, + onExpandedChange, + ...rest + } = props; let domRef = useDOMRef(ref); - let isInSidePanel = !!useContext(SidePanelContext); + let {isCollapsed} = useContext(SidePanelContext) ?? {}; + let isInSidePanel = isCollapsed !== undefined; + + let [expandedKeys, setExpandedKeys] = useControlledState( + propExpandedKeys ? new Set(propExpandedKeys) : undefined, + propDefaultExpandedKeys ? new Set(propDefaultExpandedKeys) : new Set(), + onExpandedChange + ); + + let collapseAwareExpandedKeys = expandedKeys; + let emptySet = useMemo(() => new Set(), []); + if (isCollapsed) { + collapseAwareExpandedKeys = emptySet; + } return (
tree({...renderProps, isInSidePanel})}> {children} @@ -381,7 +405,7 @@ const SideNavItemContentInner = props => {
From 3ad63536c6ce07a93d85623a7983fa49c7cc6c4c Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Tue, 11 Aug 2026 10:38:41 +1000 Subject: [PATCH 3/3] badges --- packages/@react-spectrum/s2/src/SideNav.tsx | 32 ++++++++++-- .../s2/stories/SideNav.stories.tsx | 51 ++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SideNav.tsx b/packages/@react-spectrum/s2/src/SideNav.tsx index ab08f5e9a32..d958a82655b 100644 --- a/packages/@react-spectrum/s2/src/SideNav.tsx +++ b/packages/@react-spectrum/s2/src/SideNav.tsx @@ -13,6 +13,7 @@ import {ActionButton} from './ActionButton'; import {ActionButtonGroupContext} from './ActionButtonGroup'; import {ActionMenuContext} from './ActionMenu'; +import {Badge, BadgeContext, BadgeProps} from './Badge'; import {baseColor, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; import {Button, ButtonContext} from 'react-aria-components/Button'; import {centerBaseline} from './CenterBaseline'; @@ -29,6 +30,8 @@ import {DOMRef, forwardRefType, GlobalDOMAttributes, Key} from '@react-types/sha import {IconContext} from './Icon'; import intlMessages from '../intl/*.json'; import {Link} from 'react-aria-components/Link'; +import {NotificationBadge, NotificationBadgeContext} from './NotificationBadge'; +import {NumberFormatter} from '@internationalized/number'; import {pressScale} from './pressScale'; import {Provider, useContextProps} from 'react-aria-components/slots'; import { @@ -271,8 +274,8 @@ let treeRowFocusRing = style({ const treeRowLink = style({ display: 'grid', gridArea: 'content', - gridTemplateColumns: ['auto', '1fr'], - gridTemplateAreas: ['icon content'], + gridTemplateColumns: ['auto', '1fr', 'auto'], + gridTemplateAreas: ['icon content badge'], alignItems: 'center', minWidth: 0, outlineStyle: 'none', @@ -433,7 +436,15 @@ const SideNavItemContentInner = props => { } ], [ActionButtonGroupContext, {styles: treeActions, isDisabled, size: 'S'}], - [ActionMenuContext, {styles: treeActionMenu, isQuiet: true, isDisabled, size: 'S'}] + [ActionMenuContext, {styles: treeActionMenu, isQuiet: true, isDisabled, size: 'S'}], + [BadgeContext, {size: 'S', fillStyle: 'subtle', styles: style({gridArea: 'badge'})}], + [ + NotificationBadgeContext, + { + size: 'S', + styles: style({position: 'absolute', insetEnd: 8, top: 4, gridArea: 'icon'}) + } + ] ]}> {typeof children === 'string' ? {children} : children} @@ -704,3 +715,18 @@ const PanelIcon = createIcon(props => { ); }); + +// TODO: NotificationBadge doesn't support all the colors +export const SidePanelBadge = (props: Omit & {value?: number | string}) => { + let {isCollapsed = false} = useContext(SidePanelContext) ?? {}; + let {locale} = useLocale(); + let formattedValue = props.value; + if (typeof props.value === 'number') { + formattedValue = new NumberFormatter(locale).format(Math.min(props.value, 99)); + } + if (isCollapsed) { + let value: number | undefined = typeof props.value === 'number' ? props.value : undefined; + return ; + } + return {formattedValue}; +}; diff --git a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx index 08a0d7b14b0..082e69ea145 100644 --- a/packages/@react-spectrum/s2/stories/SideNav.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SideNav.stories.tsx @@ -37,7 +37,8 @@ import { SideNavItemLink, SideNavProps, SideNavSection, - SidePanel + SidePanel, + SidePanelBadge } from '../src/SideNav'; import {style} from '../style' with {type: 'macro'}; import {useLandmark} from 'react-aria'; @@ -584,3 +585,51 @@ export const SidePanelExample = {
) }; + +export const SidePanelExample2 = { + render: args => ( +
+
+ + + + + + + Files + + + + + + + + + Your Libraries + + + + + + + Photos + + + + + + + +
+
+ ) +};