From 92902b719ddfde86269ee65796136968c34a7810 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 26 Feb 2026 16:48:52 -0600 Subject: [PATCH 1/6] feat(S2): add ActionBar support to TreeView (#9695) * add ActionBar support to TreeView * conditionally render wrapper if action bar is provided --- packages/@react-spectrum/s2/src/TreeView.tsx | 49 ++++- .../s2/stories/TreeView.stories.tsx | 167 ++++++++++++++++++ packages/dev/s2-docs/pages/s2/TreeView.mdx | 31 +++- 3 files changed, 236 insertions(+), 11 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index f0a167192c0..94f331a0fdd 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -41,13 +41,16 @@ import {ProgressCircle} from './ProgressCircle'; import {raw} from '../style/style-macro' with {type: 'macro'}; import React, {createContext, forwardRef, JSXElementConstructor, ReactElement, ReactNode, useRef} from 'react'; import {Text, TextContext} from './Content'; +import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from '@react-spectrum/utils'; import {useLocale, useLocalizedStringFormatter} from 'react-aria'; import {useScale} from './utils'; interface S2TreeProps { /** Handler that is called when a user performs an action on a row. */ - onAction?: (key: Key) => void + onAction?: (key: Key) => void, + /** Provides the ActionBar to display when items are selected in the TreeView. */ + renderActionBar?: (selectedKeys: 'all' | Set) => ReactElement } export interface TreeViewProps extends Omit, 'style' | 'className' | 'render' | 'onRowAction' | 'selectionBehavior' | 'onScroll' | 'onCellAction' | 'dragAndDropHooks' | keyof GlobalDOMAttributes>, UnsafeStyles, S2TreeProps { @@ -71,6 +74,16 @@ interface TreeRendererContextValue { const TreeRendererContext = createContext({}); +const treeViewWrapper = style({ + minHeight: 0, + minWidth: 0, + 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 @@ -94,7 +107,7 @@ const tree = style({ type: 'width', value: 16 } -}, getAllowedOverrides({height: true})); +}); /** * A tree view provides users with a way to navigate nested hierarchical information. @@ -109,8 +122,11 @@ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tr } let domRef = useDOMRef(ref); + let scrollRef = useRef(null); - return ( + let {selectedKeys, onSelectionChange, actionBar, actionBarHeight} = useActionBarContainer({...props, scrollRef}); + + let treeContent = ( (UNSAFE_className ?? '') + tree({...renderProps}, props.styles)} + style={{ + ...(!props.renderActionBar ? UNSAFE_style : {}), + paddingBottom: actionBarHeight > 0 ? actionBarHeight + 8 : 0, + scrollPaddingBottom: actionBarHeight > 0 ? actionBarHeight + 8 : 0 + }} + className={renderProps => (!props.renderActionBar ? (UNSAFE_className ?? '') : '') + tree({...renderProps})} selectionBehavior="toggle" - ref={domRef}> + selectedKeys={selectedKeys} + defaultSelectedKeys={undefined} + onSelectionChange={onSelectionChange} + ref={props.renderActionBar ? (scrollRef as any) : domRef}> {props.children} ); + + if (props.renderActionBar) { + return ( +
+ {treeContent} + {actionBar} +
+ ); + } + + return treeContent; }); const rowBackgroundColor = { diff --git a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx index 927287ae694..35d098674a1 100644 --- a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx @@ -12,6 +12,8 @@ import {action} from 'storybook/actions'; import { + ActionBar, + ActionButton, ActionMenu, Collection, Content, @@ -29,13 +31,16 @@ import { TreeViewProps } from '../src'; import {categorizeArgTypes, getActionArgs} from './utils'; +import Copy from '../s2wf-icons/S2_Icon_Copy_20_N.svg'; import Delete from '../s2wf-icons/S2_Icon_Delete_20_N.svg'; import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import FileTxt from '../s2wf-icons/S2_Icon_FileText_20_N.svg'; import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; +import {Key} from 'react-aria'; import type {Meta, StoryObj} from '@storybook/react'; import React, {ReactElement, useCallback, useState} from 'react'; +import {style} from '../style' with {type: 'macro'}; import {useAsyncList, useListData} from 'react-stately'; let onActionFunc = action('onAction'); @@ -759,3 +764,165 @@ const DynamicTreeItem = (props: Omit & TreeViewIt } } }; + +function ActionBarExample(args: TreeViewProps) { + let [selectedKeys, setSelectedKeys] = useState(new Set()); + + return ( +
+ { + let selection = keys === 'all' ? 'all' : [...keys].join(', '); + return ( + + action('edit')(selection)}> + + Edit + + action('copy')(selection)}> + + Copy + + action('delete')(selection)}> + + Delete + + + ); + }}> + + + Photos + + + + + + Projects + + + + + Projects-1 + + + + + Projects-1A + + + + + + + Projects-2 + + + + + + Projects-3 + + + + + +
+ ); +} + +export const WithActionBar: StoryObj = { + render: ActionBarExample, + args: { + selectionMode: 'multiple' + }, + name: 'with ActionBar' +}; + +function ActionBarEmphasizedExample(args: TreeViewProps) { + let [selectedKeys, setSelectedKeys] = useState(new Set()); + + return ( +
+ { + let selection = keys === 'all' ? 'all' : [...keys].join(', '); + return ( + + action('edit')(selection)}> + + Edit + + action('copy')(selection)}> + + Copy + + action('delete')(selection)}> + + Delete + + + ); + }}> + + + Photos + + + + + + Projects + + + + + Projects-1 + + + + + Projects-1A + + + + + + + Projects-2 + + + + + + Projects-3 + + + + + +
+ ); +} + +export const WithActionBarEmphasized: StoryObj = { + render: ActionBarEmphasizedExample, + args: { + selectionMode: 'multiple' + }, + name: 'with ActionBar (emphasized)' +}; diff --git a/packages/dev/s2-docs/pages/s2/TreeView.mdx b/packages/dev/s2-docs/pages/s2/TreeView.mdx index 6bab6e3ac6a..a22fe585fc2 100644 --- a/packages/dev/s2-docs/pages/s2/TreeView.mdx +++ b/packages/dev/s2-docs/pages/s2/TreeView.mdx @@ -1,7 +1,7 @@ import {Layout} from '../../src/Layout'; export default Layout; -import {TreeView, TreeViewItem, TreeViewItemContent, Collection, Text, ActionMenu, MenuItem, InlineAlert, Heading, Content} from '@react-spectrum/s2'; +import {TreeView, TreeViewItem, TreeViewItemContent, Collection, Text, ActionMenu, ActionBar, ActionButton, MenuItem, InlineAlert, Heading, Content} from '@react-spectrum/s2'; import docs from 'docs:@react-spectrum/s2'; export const tags = ['hierarchy', 'data', 'nested']; @@ -293,11 +293,14 @@ import FolderOpen from '@react-spectrum/s2/illustrations/linear/FolderOpen'; ## Selection and actions -Use the `selectionMode` prop to enable single or multiple selection. The selected items can be controlled via the `selectedKeys` prop, matching the `id` prop of the items. The `onAction` event handles item actions. Items can be disabled with the `isDisabled` prop. See the [selection guide](selection?component=Tree) for more details. +Use the `selectionMode` prop to enable single or multiple selection. The selected items can be controlled via the `selectedKeys` prop, matching the `id` prop of the items. Return an [ActionBar](ActionBar) from `renderActionBar` to handle bulk actions, and use `onAction` for item navigation. Items can be disabled with the `isDisabled` prop. See the [selection guide](selection?component=Tree) for more details. ```tsx render docs={docs.exports.TreeView} links={docs.links} props={['selectionMode', 'disabledBehavior', 'disallowEmptySelection']} initialProps={{selectionMode: 'multiple'}} wide "use client"; -import {TreeView, TreeViewItem, TreeViewItemContent, type Selection} from '@react-spectrum/s2'; +import {TreeView, TreeViewItem, TreeViewItemContent, ActionBar, ActionButton, Text, type Selection} from '@react-spectrum/s2'; +import Edit from '@react-spectrum/s2/icons/Edit'; +import Copy from '@react-spectrum/s2/icons/Copy'; +import Delete from '@react-spectrum/s2/icons/Delete'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useState} from 'react'; @@ -309,14 +312,32 @@ function Example(props) { alert(`Clicked ${key}`)} + renderActionBar={(selectedKeys) => { ///- end highlight -/// - > + let selection = selectedKeys === 'all' ? 'all' : [...selectedKeys].join(', '); + return ( + + alert(`Edit ${selection}`)}> + + Edit + + alert(`Copy ${selection}`)}> + + Copy + + alert(`Delete ${selection}`)}> + + Delete + + + ); + }}> Bulbasaur From b14bd618256b90d89e68d2f206152b424d37ca93 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 26 Feb 2026 16:49:32 -0600 Subject: [PATCH 2/6] docs(S2): fix icon import clipboard content to add underscore for icons starting with number (#9698) * docs(S2): fix icon import copied content to add underscore for icons starting with number (matches build) * fix import for illustrations * Revert "fix import for illustrations" This reverts commit 0f2b8be0037050a2e54f28a3daf07c635a886da2. * fix for illustrations docs --- packages/dev/s2-docs/src/IconSearchView.tsx | 4 +++- packages/dev/s2-docs/src/IllustrationCards.tsx | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/dev/s2-docs/src/IconSearchView.tsx b/packages/dev/s2-docs/src/IconSearchView.tsx index 4a226bfbd27..1cb8f7add77 100644 --- a/packages/dev/s2-docs/src/IconSearchView.tsx +++ b/packages/dev/s2-docs/src/IconSearchView.tsx @@ -51,7 +51,9 @@ export function useCopyImport() { if (timeout.current) { clearTimeout(timeout.current); } - navigator.clipboard.writeText(`import ${id} from '@react-spectrum/s2/icons/${id}';`).then(() => { + // Use underscore prefix for names starting with a number (invalid JS identifier) + let importName = id.replace(/^(\d)/, '_$1'); + navigator.clipboard.writeText(`import ${importName} from '@react-spectrum/s2/icons/${id}';`).then(() => { setCopiedId(id); timeout.current = setTimeout(() => setCopiedId(null), 2000); }).catch(() => { diff --git a/packages/dev/s2-docs/src/IllustrationCards.tsx b/packages/dev/s2-docs/src/IllustrationCards.tsx index ecb49b877c7..1ecb7a5423e 100644 --- a/packages/dev/s2-docs/src/IllustrationCards.tsx +++ b/packages/dev/s2-docs/src/IllustrationCards.tsx @@ -119,9 +119,11 @@ function useCopyImport(variant: string, gradientStyle: string) { if (timeout.current) { clearTimeout(timeout.current); } + // Use underscore prefix for names starting with a number (invalid JS identifier) + let importName = id.replace(/^(\d)/, '_$1'); let importText = variant === 'gradient' ? - `import ${id} from '@react-spectrum/s2/illustrations/gradient/${gradientStyle}/${id}';` : - `import ${id} from '@react-spectrum/s2/illustrations/linear/${id}';`; + `import ${importName} from '@react-spectrum/s2/illustrations/gradient/${gradientStyle}/${id}';` : + `import ${importName} from '@react-spectrum/s2/illustrations/linear/${id}';`; navigator.clipboard.writeText(importText).then(() => { setCopiedId(id); timeout.current = setTimeout(() => setCopiedId(null), 2000); From e60cf20caa96c9388c02f04fc02e9389a7271097 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 26 Feb 2026 14:50:08 -0800 Subject: [PATCH 3/6] tentative fix (#9635) --- packages/@react-aria/overlays/src/usePreventScroll.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/@react-aria/overlays/src/usePreventScroll.ts b/packages/@react-aria/overlays/src/usePreventScroll.ts index 4c1698093b2..81e2dff5162 100644 --- a/packages/@react-aria/overlays/src/usePreventScroll.ts +++ b/packages/@react-aria/overlays/src/usePreventScroll.ts @@ -92,6 +92,10 @@ function preventScrollStandard() { // Safari from scrolling the page. After a small delay, focus the real input and scroll it into view // ourselves, without scrolling the whole page. function preventScrollMobileSafari() { + // Set overflow hidden so scrollIntoViewport() (useSelectableCollection) sees isScrollPrevented and + // scrolls only scroll parents instead of calling native scrollIntoView() which moves the window. + let restoreOverflow = setStyle(document.documentElement, 'overflow', 'hidden'); + let scrollable: Element; let allowTouchMove = false; let onTouchStart = (e: TouchEvent) => { @@ -201,6 +205,7 @@ function preventScrollMobileSafari() { ); return () => { + restoreOverflow(); removeEvents(); style.remove(); HTMLElement.prototype.focus = focus; From 088e4bfc9b1e891282d1a923fcc2eb30788e3f70 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 26 Feb 2026 14:50:30 -0800 Subject: [PATCH 4/6] fix: patch additional methods so React doesnt break with template elements (#9385) * fix: patch additional methods so React doesnt break with template elements fixes https://github.com/adobe/react-spectrum/issues/9376 * fix case where user navigates to Picker page from another page * fix template node getting direct children --- .../@react-aria/collections/src/Hidden.tsx | 59 ++++++++++++++++++- .../@react-spectrum/s2/test/Combobox.test.tsx | 5 +- .../@react-spectrum/s2/test/Picker.test.tsx | 5 +- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/@react-aria/collections/src/Hidden.tsx b/packages/@react-aria/collections/src/Hidden.tsx index b6f2fa46607..6ff7465d909 100644 --- a/packages/@react-aria/collections/src/Hidden.tsx +++ b/packages/@react-aria/collections/src/Hidden.tsx @@ -11,15 +11,21 @@ */ import {forwardRefType} from '@react-types/shared'; -import React, {Context, createContext, forwardRef, JSX, ReactElement, ReactNode, useContext} from 'react'; +import React, {Context, createContext, forwardRef, JSX, ReactElement, ReactNode, useContext, useRef} from 'react'; +import {useLayoutEffect} from '@react-aria/utils'; // React doesn't understand the