diff --git a/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx b/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx index b718f1702c0..9c152d539dd 100644 --- a/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx @@ -143,7 +143,7 @@ export const TreeStatic: StoryObj = { render: (args) => }; -export const TreeSelection: StoryObj = { +export const TreeCheckboxSelection: StoryObj = { ...TreeStatic, args: { selectionMode: 'multiple', @@ -151,6 +151,15 @@ export const TreeSelection: StoryObj = { } }; +export const TreeHighlightSelection: StoryObj = { + ...TreeStatic, + args: { + selectionMode: 'multiple', + selectionStyle: 'highlight', + defaultSelectedKeys: ['projects-2', 'projects-3'] + } +}; + export const TreeDisableSelection: StoryObj = { ...TreeStatic, args: { diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index defed595152..df2aa24a7a3 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -26,6 +26,7 @@ import { TreeItemContentProps, TreeLoadMoreItem, TreeLoadMoreItemProps, + TreeState, useContextProps, Virtualizer } from 'react-aria-components'; @@ -39,7 +40,7 @@ import {IconContext} from './Icon'; import intlMessages from '../intl/*.json'; import {ProgressCircle} from './ProgressCircle'; import {raw} from '../style/style-macro' with {type: 'macro'}; -import React, {createContext, forwardRef, JSXElementConstructor, ReactElement, ReactNode, useRef} from 'react'; +import React, {createContext, forwardRef, JSXElementConstructor, ReactElement, ReactNode, useContext, useRef} from 'react'; import {Text, TextContext} from './Content'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from '@react-spectrum/utils'; @@ -53,7 +54,15 @@ interface S2TreeProps { renderActionBar?: (selectedKeys: 'all' | Set) => ReactElement } -export interface TreeViewProps extends Omit, 'style' | 'className' | 'render' | 'onRowAction' | 'selectionBehavior' | 'onScroll' | 'onCellAction' | 'dragAndDropHooks' | keyof GlobalDOMAttributes>, UnsafeStyles, S2TreeProps { +interface TreeViewStyleProps { + /** + * How selection should be displayed. + * @default 'checkbox' + */ + selectionStyle?: 'highlight' | 'checkbox' +} + +export interface TreeViewProps extends Omit, 'style' | 'className' | 'render' | 'onRowAction' | 'selectionBehavior' | 'onScroll' | 'onCellAction' | 'dragAndDropHooks' | keyof GlobalDOMAttributes>, UnsafeStyles, S2TreeProps, TreeViewStyleProps { /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight } @@ -109,11 +118,13 @@ const tree = style({ } }); +let InternalTreeViewContext = createContext<{selectionStyle?: 'highlight' | 'checkbox'}>({}); + /** * A tree view provides users with a way to navigate nested hierarchical information. */ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function TreeView(props: TreeViewProps, ref: DOMRef) { - let {children, UNSAFE_className, UNSAFE_style} = props; + let {children, selectionStyle = 'checkbox', UNSAFE_className, UNSAFE_style} = props; let scale = useScale(); let renderer; @@ -137,20 +148,22 @@ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tr rowHeight: scale === 'large' ? 50 : 40 }}> - 0 ? actionBarHeight + 8 : 0, - scrollPaddingBottom: actionBarHeight > 0 ? actionBarHeight + 8 : 0 - }} - className={tree} - selectionBehavior="toggle" - selectedKeys={selectedKeys} - defaultSelectedKeys={undefined} - onSelectionChange={onSelectionChange} - ref={scrollRef}> - {props.children} - + + 0 ? actionBarHeight + 8 : 0, + scrollPaddingBottom: actionBarHeight > 0 ? actionBarHeight + 8 : 0 + }} + className={tree} + selectionBehavior={selectionStyle === 'highlight' ? 'replace' : 'toggle'} + selectedKeys={selectedKeys} + defaultSelectedKeys={undefined} + onSelectionChange={onSelectionChange} + ref={scrollRef}> + {props.children} + + {actionBar} @@ -162,7 +175,7 @@ const rowBackgroundColor = { default: '--s2-container-bg', isFocusVisibleWithin: colorMix('gray-25', 'gray-900', 7), isHovered: colorMix('gray-25', 'gray-900', 7), - isPressed: colorMix('gray-25', 'gray-900', 10), + isPressed: colorMix('gray-25', 'gray-900', 7), isSelected: { default: colorMix('gray-25', 'gray-900', 7), isFocusVisibleWithin: colorMix('gray-25', 'gray-900', 10), @@ -175,6 +188,17 @@ const rowBackgroundColor = { } as const; const treeRow = style({ + ...focusRing(), + outlineOffset: -2, + outlineColor: { + default: 'focus-ring', + forcedColors: 'Highlight', + selectionStyle: { + highlight: { + forcedColors: 'ButtonBorder' + } + } + }, position: 'relative', display: 'flex', height: 40, @@ -186,7 +210,6 @@ const treeRow = style({ isSelected: baseColor('neutral'), forcedColors: 'ButtonText' }, - outlineStyle: 'none', cursor: { default: 'default', isLink: 'pointer' @@ -195,13 +218,11 @@ const treeRow = style({ type: 'backgroundColor', value: rowBackgroundColor }, - '--rowFocusIndicatorColor': { - type: 'outlineColor', - value: { - default: 'focus-ring', - forcedColors: 'Highlight' - } - } + '--borderRadiusTreeItem': { + type: 'borderTopStartRadius', + value: 'sm' + }, + borderRadius: 'sm' }); const treeCellGrid = style({ @@ -209,6 +230,7 @@ const treeCellGrid = style({ width: 'full', height: 'full', boxSizing: 'border-box', + borderRadius: 'sm', alignContent: 'center', alignItems: 'center', gridTemplateColumns: ['auto', 'auto', 'auto', 'auto', 'auto', '1fr', 'minmax(0, auto)', 'auto'], @@ -216,12 +238,19 @@ const treeCellGrid = style({ gridTemplateAreas: [ 'drag-handle checkbox level-padding expand-button icon content actions actionmenu' ], - backgroundColor: '--rowBackgroundColor', paddingEnd: 4, // account for any focus rings on the last item in the cell color: { isDisabled: { default: 'gray-400', forcedColors: 'GrayText' + }, + forcedColors: 'ButtonText', + selectionStyle: { + highlight: { + isSelected: { + forcedColors: 'HighlightText' + } + } } }, '--rowSelectedBorderColor': { @@ -238,6 +267,82 @@ const treeCellGrid = style({ default: 'focus-ring', forcedColors: 'Highlight' } + }, + '--borderColor': { + type: 'borderColor', + value: { + default: 'transparent', + selectionStyle: { + highlight: 'blue-900' + }, + forcedColors: 'ButtonBorder' + } + }, + forcedColorAdjust: 'none' +}); + +const treeRowBackground = style({ + position: 'absolute', + zIndex: -1, + inset: 0, + backgroundColor: { + default: '--rowBackgroundColor', + forcedColors: 'Background', + selectionStyle: { + highlight: { + default: '--rowBackgroundColor', + isSelected: { + default: colorMix('gray-25', 'blue-900', 10), + isHovered: colorMix('gray-25', 'blue-900', 15), + isPressed: colorMix('gray-25', 'blue-900', 15), + isFocusVisible: colorMix('gray-25', 'blue-900', 15), + forcedColors: 'Highlight' + } + } + } + }, + borderTopStartRadius: { + default: '--borderRadiusTreeItem', + isPreviousSelected: { + default: '--borderRadiusTreeItem', + isSelected: 'none' + } + }, + borderTopEndRadius: { + default: '--borderRadiusTreeItem', + isPreviousSelected: { + default: '--borderRadiusTreeItem', + isSelected: 'none' + } + }, + borderBottomStartRadius: { + default: '--borderRadiusTreeItem', + isNextSelected: { + default: '--borderRadiusTreeItem', + isSelected: 'none' + } + }, + borderBottomEndRadius: { + default: '--borderRadiusTreeItem', + isNextSelected: { + default: '--borderRadiusTreeItem', + isSelected: 'none' + } + }, + borderTopWidth: { + default: 1, + isPreviousSelected: 0 + }, + borderBottomWidth: { + default: 1, + isNextSelected: 0 + }, + borderStartWidth: 1, + borderEndWidth: 1, + borderStyle: 'solid', + borderColor: { + default: 'transparent', + isSelected: '--borderColor' } }); @@ -298,13 +403,16 @@ export const TreeViewItem = (props: TreeViewItemProps): ReactNode => { let { href } = props; + let {selectionStyle} = useContext(InternalTreeViewContext); return ( treeRow({ ...renderProps, - isLink: !!href + isLink: !!href, + selectionStyle, + isPreviousSelected: isPrevSelected(renderProps.id, renderProps.state) }) + (renderProps.isFocusVisible ? ' ' + treeRowFocusIndicator : '')} /> ); }; @@ -320,19 +428,15 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode } = props; let scale = useScale(); + let {selectionStyle} = useContext(InternalTreeViewContext); + return ( - {({isExpanded, hasChildItems, selectionMode, selectionBehavior, isDisabled, isSelected, id, state}) => { - let isNextSelected = false; - let isNextFocused = false; - let keyAfter = state.collection.getKeyAfter(id); - if (keyAfter != null) { - isNextSelected = state.selectionManager.isSelected(keyAfter); - } - let isFirst = state.collection.getFirstKey() === id; + {({isExpanded, hasChildItems, selectionMode, selectionBehavior, isDisabled, isSelected, id, state, isHovered, isFocusVisible}) => { return ( -
- {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( +
+
+ {selectionMode !== 'none' && selectionBehavior === 'toggle' && selectionStyle !== 'highlight' && ( // TODO: add transition?
@@ -459,3 +563,27 @@ function ExpandableRowChevron(props: ExpandableRowChevronProps) { ); } + +function isNextSelected(id: Key | undefined, state: TreeState) { + if (id == null || !state) { + return false; + } + let keyAfter = state.collection.getKeyAfter(id); + + // We need to skip non-item nodes because the selection manager will map non-item nodes to their parent before checking selection + let node = keyAfter ? state.collection.getItem(keyAfter) : null; + while (node && node.type !== 'item' && keyAfter) { + keyAfter = state.collection.getKeyAfter(keyAfter); + node = keyAfter ? state.collection.getItem(keyAfter) : null; + } + + return keyAfter != null && state.selectionManager.isSelected(keyAfter); +} + +function isPrevSelected(id: Key | undefined, state: TreeState) { + if (id == null || !state) { + return false; + } + let keyBefore = state.collection.getKeyBefore(id); + return keyBefore != null && state.selectionManager.isSelected(keyBefore); +} diff --git a/packages/dev/s2-docs/pages/s2/TreeView.mdx b/packages/dev/s2-docs/pages/s2/TreeView.mdx index f8043e4e813..2bcb0ecaacb 100644 --- a/packages/dev/s2-docs/pages/s2/TreeView.mdx +++ b/packages/dev/s2-docs/pages/s2/TreeView.mdx @@ -14,7 +14,7 @@ export const description = 'Displays hierarchical data with selection and collap {docs.exports.TreeView.description} -```tsx render docs={docs.exports.TreeView} links={docs.links} props={['selectionMode']} initialProps={{selectionMode: 'multiple'}} type="s2" wide +```tsx render docs={docs.exports.TreeView} links={docs.links} props={['selectionMode', 'selectionStyle']} initialProps={{selectionMode: 'multiple', selectionStyle: 'checkbox'}} type="s2" wide "use client"; import {TreeView, TreeViewItem, TreeViewItemContent} from '@react-spectrum/s2'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'};