From ecf9415c70080b81f7aad60f1374938d903a9d6b Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 3 Jul 2025 18:43:26 +1000 Subject: [PATCH 01/16] feat: s2 ListView --- packages/@react-spectrum/s2/src/ListView.tsx | 286 ++++++++++++++++++ packages/@react-spectrum/s2/src/Picker.tsx | 21 -- packages/@react-spectrum/s2/src/TreeView.tsx | 7 +- packages/@react-spectrum/s2/src/index.ts | 4 +- .../s2/stories/ListView.stories.tsx | 179 +++++++++++ .../react-aria-components/src/GridList.tsx | 2 + 6 files changed, 476 insertions(+), 23 deletions(-) create mode 100644 packages/@react-spectrum/s2/src/ListView.tsx create mode 100644 packages/@react-spectrum/s2/stories/ListView.stories.tsx diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx new file mode 100644 index 00000000000..443ac565a89 --- /dev/null +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -0,0 +1,286 @@ +/* + * 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 { + DEFAULT_SLOT, + GridList, + GridListItem, + GridListItemProps, + GridListItemRenderProps, + GridListProps, + GridListRenderProps, + ListLayout, + ListStateContext, + Provider, + useContextProps, + useLocale, + Virtualizer +} from 'react-aria-components'; +import {JSXElementConstructor, ReactElement, ReactNode, createContext, forwardRef, useContext, useRef} from 'react'; +import {ContextValue, SlotProps} from 'react-aria-components'; +import {DOMProps, DOMRef, DOMRefValue} from '@react-types/shared'; +import {control, controlFont, controlSize, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {useSpectrumContextProps} from './useSpectrumContextProps'; +import {baseColor, edgeToText, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; +import { useScale } from './utils'; +import { useDOMRef } from '@react-spectrum/utils'; +import { pressScale } from './pressScale'; +import { IconContext } from './Icon'; +import { centerBaseline } from './CenterBaseline'; +import { Text, TextContext } from './Content'; +import { ImageContext } from './Image'; +import { ActionButtonGroupContext } from './ActionButtonGroup'; + +export interface ListViewProps extends GridListProps, DOMProps, StyleProps, ListViewStylesProps, SlotProps { + /** + * Whether to automatically focus the Inline Alert when it first renders. + */ + autoFocus?: boolean +} + +interface ListViewStylesProps { + isQuiet?: boolean +} + +export interface ListViewItemProps extends Omit, StyleProps { + /** + * The contents of the item. + */ + children: ReactNode +} + +interface ListViewRendererContextValue { + renderer?: (item) => ReactElement> +} +const ListViewRendererContext = createContext({}); + +export const ListViewContext = createContext>, DOMRefValue>>(null); + +let InternalListViewContext = createContext<{isQuiet?: boolean}>({}); + +const listView = style({ + ...focusRing(), + outlineOffset: -2, // make certain we are visible inside overflow hidden containers + userSelect: 'none', + minHeight: 0, + minWidth: 0, + width: 'full', + height: 'full', + boxSizing: 'border-box', + overflow: 'auto', + fontSize: controlFont(), + borderRadius: 'default', + borderColor: 'gray-300', + borderWidth: 1, + borderStyle: 'solid' +}, getAllowedOverrides()); + +export const ListView = /*#__PURE__*/ forwardRef(function ListView( + props: ListViewProps, + ref: DOMRef +): ReactNode { + [props, ref] = useSpectrumContextProps(props, ref, ListViewContext); + let {children, isQuiet} = props; + let scale = useScale(); + + let renderer; + if (typeof children === 'function') { + renderer = children; + } + + let domRef = useDOMRef(ref); + + return ( + + + + (props.UNSAFE_className || '') + listView({ + ...renderProps, + isQuiet + }, props.styles)}> + {children} + + + + + ); +}); + +const listitem = style({ + ...focusRing(), + outlineOffset: 0, + columnGap: 0, + paddingX: 0, + paddingBottom: '--labelPadding', + backgroundColor: { + default: 'transparent', + isFocused: baseColor('gray-100').isFocusVisible + }, + color: { + default: baseColor('neutral'), + isDisabled: { + default: 'disabled', + forcedColors: 'GrayText' + } + }, + position: 'relative', + gridColumnStart: 1, + gridColumnEnd: -1, + display: 'grid', + gridTemplateAreas: [ + '. checkmark icon label actions chevron .', + '. . . description actions chevron .' + ], + gridTemplateColumns: [edgeToText(12), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', edgeToText(12)], + gridTemplateRows: '1fr auto', + rowGap: { + ':has([slot=description])': space(1) + }, + alignItems: 'baseline', + height: 'full', + textDecoration: 'none', + cursor: { + default: 'default', + isLink: 'pointer' + }, + transition: 'default', + borderColor: { + default: 'gray-300', + forcedColors: 'ButtonBorder' + }, + borderBottomWidth: 1, + borderTopWidth: 0, + borderXWidth: 0, + borderStyle: 'solid', + borderTopRadius: { + default: 'none', + isFirstItem: 'default' + }, + borderBottomRadius: { + default: 'none', + isLastItem: 'default' + } +}, getAllowedOverrides()); + +export let label = style({ + gridArea: 'label', + alignSelf: 'center', + font: controlFont(), + color: 'inherit', + fontWeight: 'medium', + // TODO: token values for padding not defined yet, revisit + marginTop: '--labelPadding' +}); + +export let description = style({ + gridArea: 'description', + alignSelf: 'center', + font: 'ui-sm', + color: { + default: baseColor('neutral-subdued'), + // Ideally this would use the same token as hover, but we don't have access to that here. + // TODO: should we always consider isHovered and isFocused to be the same thing? + isFocused: 'gray-800', + isDisabled: 'disabled' + }, + transition: 'default' +}); + +export let iconCenterWrapper = style({ + display: 'flex', + gridArea: 'icon', + alignSelf: 'center' +}); + +export let icon = style({ + display: 'block', + size: fontRelative(20), + // too small default icon size is wrong, it's like the icons are 1 tshirt size bigger than the rest of the component? check again after typography changes + // reminder, size of WF is applied via font size + marginEnd: 'text-to-visual', + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } +}); + +let image = style({ + gridArea: 'icon', + gridRowEnd: 'span 2', + marginEnd: 'text-to-visual', + alignSelf: 'center', + borderRadius: 'sm', + height: 'calc(100% - 12px)', + aspectRatio: 'square', + objectFit: 'contain' +}); + +let actionButtonGroup = style({ + gridArea: 'actions', + gridRowEnd: 'span 2', + alignSelf: 'center', + justifySelf: 'end' +}); + +export function ListViewItem(props: ListViewItemProps): ReactNode { + let ref = useRef(null); + let isLink = props.href != null; + let isLinkOut = isLink && props.target === '_blank'; + let {isQuiet} = useContext(InternalListViewContext); + let textValue = props.textValue || (typeof props.children === 'string' ? props.children : undefined); + let {direction} = useLocale(); + return ( + (props.UNSAFE_className || '') + listitem({ + ...renderProps, + isLink, + isQuiet + }, props.styles)}> + {(renderProps) => { + let {children} = props; + return ( + + {typeof children === 'string' ? {children} : children} + + ); + }} + + ); +} diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index edc7b55383e..e57231ea6bb 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -178,27 +178,6 @@ const quietFocusLine = style({ } }); -export let menu = style({ - outlineStyle: 'none', - display: 'grid', - width: 'full', - gridTemplateColumns: { - size: { - S: [edgeToText(24), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(24)], - M: [edgeToText(32), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(32)], - L: [edgeToText(40), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(40)], - XL: [edgeToText(48), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(48)] - } - }, - boxSizing: 'border-box', - maxHeight: 'inherit', - overflow: 'auto', - padding: 8, - fontFamily: 'sans', - fontSize: controlFont(), - gridAutoRows: 'min-content' -}); - const invalidBorder = style({ ...controlBorderRadius(), position: 'absolute', diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 9c6062710a9..2cb632a9726 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -15,6 +15,7 @@ import {ActionMenuContext} from './ActionMenu'; import { Button, ButtonContext, + ContextValue, ListLayout, Provider, TreeItemProps as RACTreeItemProps, @@ -30,7 +31,7 @@ import {centerBaseline} from './CenterBaseline'; import {Checkbox} from './Checkbox'; import Chevron from '../ui-icons/Chevron'; import {colorMix, focusRing, fontRelative, lightDark, style} from '../style' with {type: 'macro'}; -import {DOMRef, Key} from '@react-types/shared'; +import {DOMRef, DOMRefValue, Key} from '@react-types/shared'; import {getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {IconContext} from './Icon'; import {raw} from '../style/style-macro' with {type: 'macro'}; @@ -39,6 +40,7 @@ import {TextContext} from './Content'; import {useDOMRef} from '@react-spectrum/utils'; import {useLocale} from 'react-aria'; import {useScale} from './utils'; +import { useSpectrumContextProps } from './useSpectrumContextProps'; interface S2TreeProps { // Only detatched is supported right now with the current styles from Spectrum @@ -63,6 +65,8 @@ interface TreeRendererContextValue { } const TreeRendererContext = createContext({}); +export const TreeViewContext = createContext, DOMRefValue>>(null); + let InternalTreeContext = createContext<{isDetached?: boolean, isEmphasized?: boolean}>({}); @@ -92,6 +96,7 @@ const tree = style({ }, getAllowedOverrides({height: true})); function TreeView(props: TreeViewProps, ref: DOMRef) { + [props, ref] = useSpectrumContextProps(props, ref, TreeViewContext); let {children, isDetached, isEmphasized, UNSAFE_className, UNSAFE_style} = props; let scale = useScale(); diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index bd022c26ddc..8df90c6fe2c 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -53,6 +53,7 @@ export {Image, ImageContext} from './Image'; export {ImageCoordinator} from './ImageCoordinator'; export {InlineAlert, InlineAlertContext} from './InlineAlert'; export {Link, LinkContext} from './Link'; +export {ListView, ListViewItem} from './ListView'; export {MenuItem, MenuTrigger, Menu, MenuSection, SubmenuTrigger, MenuContext} from './Menu'; export {Meter, MeterContext} from './Meter'; export {NotificationBadge, NotificationBadgeContext} from './NotificationBadge'; @@ -80,7 +81,7 @@ export {ToastContainer as UNSTABLE_ToastContainer, ToastQueue as UNSTABLE_ToastQ export {ToggleButton, ToggleButtonContext} from './ToggleButton'; export {ToggleButtonGroup, ToggleButtonGroupContext} from './ToggleButtonGroup'; export {Tooltip, TooltipTrigger} from './Tooltip'; -export {TreeView, TreeViewItem, TreeViewItemContent} from './TreeView'; +export {TreeView, TreeViewItem, TreeViewItemContent, TreeViewContext} from './TreeView'; export {pressScale} from './pressScale'; @@ -126,6 +127,7 @@ export type {InlineAlertProps} from './InlineAlert'; export type {ImageProps} from './Image'; export type {ImageCoordinatorProps} from './ImageCoordinator'; export type {LinkProps} from './Link'; +export type {ListViewProps, ListViewItemProps} from './ListView'; export type {MenuTriggerProps, MenuProps, MenuItemProps, MenuSectionProps, SubmenuTriggerProps} from './Menu'; export type {MeterProps} from './Meter'; export type {NotificationBadgeProps} from './NotificationBadge'; diff --git a/packages/@react-spectrum/s2/stories/ListView.stories.tsx b/packages/@react-spectrum/s2/stories/ListView.stories.tsx new file mode 100644 index 00000000000..744a5e1e139 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/ListView.stories.tsx @@ -0,0 +1,179 @@ +/* + * 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 {categorizeArgTypes} from './utils'; +import {ListView, ListViewItem, Text, Image, ActionButton, ActionButtonGroup} from '../'; +import type {Meta, StoryObj} from '@storybook/react'; +import { Key } from 'react-aria'; +import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import File from '../s2wf-icons/S2_Icon_File_20_N.svg'; +import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; +import Delete from '../s2wf-icons/S2_Icon_Delete_20_N.svg'; +import { ReactNode } from 'react'; + +const meta: Meta = { + component: ListView, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + ...categorizeArgTypes('Events', ['onSelectionChange']) + }, + title: 'ListView', + decorators: [ + (Story) => ( +
+ +
+ ) + ] +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + args: { + 'aria-label': 'Birthday', + children: ( + <> + + Item 1 + + + Item 2 + + + Item 3 + + + ) + } +}; + +interface Item { + id: Key, + name: string, + type?: 'file' | 'folder', + children?: Item[] +} + +const items: Item[] = [ + {id: 'a', name: 'Adobe Photoshop', type: 'file'}, + {id: 'b', name: 'Adobe XD', type: 'file'}, + {id: 'c', name: 'Documents', type: 'folder', children: [ + {id: 1, name: 'Sales Pitch'}, + {id: 2, name: 'Demo'}, + {id: 3, name: 'Taxes'} + ]}, + {id: 'd', name: 'Adobe InDesign', type: 'file'}, + {id: 'e', name: 'Utilities', type: 'folder', children: [ + {id: 1, name: 'Activity Monitor'} + ]}, + {id: 'f', name: 'Adobe AfterEffects', type: 'file'}, + {id: 'g', name: 'Adobe Illustrator', type: 'file'}, + {id: 'h', name: 'Adobe Lightroom', type: 'file'}, + {id: 'i', name: 'Adobe Premiere Pro', type: 'file'}, + {id: 'j', name: 'Adobe Fresco', type: 'file'}, + {id: 'k', name: 'Adobe Dreamweaver', type: 'file'}, + {id: 'l', name: 'Adobe Connect', type: 'file'}, + {id: 'm', name: 'Pictures', type: 'folder', children: [ + {id: 1, name: 'Yosemite'}, + {id: 2, name: 'Jackson Hole'}, + {id: 3, name: 'Crater Lake'} + ]}, + {id: 'n', name: 'Adobe Acrobat', type: 'file'} +]; + +export const Dynamic: Story = { + render: (args) => ( + + {item => ( + {item.name} + )} + + ), + args: { + 'aria-label': 'Birthday' + } +}; + + +// taken from https://random.dog/ +const itemsWithThumbs: Array<{id: string, title: string, url: string}> = [ + {id: '1', title: 'swimmer', url: 'https://random.dog/b2fe2172-cf11-43f4-9c7f-29bd19601712.jpg'}, + {id: '2', title: 'chocolate', url: 'https://random.dog/2032518a-eec8-4102-9d48-3dca5a26eb23.png'}, + {id: '3', title: 'good boi', url: 'https://random.dog/191091b2-7d69-47af-9f52-6605063f1a47.jpg'}, + {id: '4', title: 'polar bear', url: 'https://random.dog/c22c077e-a009-486f-834c-a19edcc36a17.jpg'}, + {id: '5', title: 'cold boi', url: 'https://random.dog/093a41da-e2c0-4535-a366-9ef3f2013f73.jpg'}, + {id: '6', title: 'pilot', url: 'https://random.dog/09f8ecf4-c22b-49f4-af24-29fb5c8dbb2d.jpg'}, + {id: '7', title: 'nerd', url: 'https://random.dog/1a0535a6-ca89-4059-9b3a-04a554c0587b.jpg'}, + {id: '8', title: 'audiophile', url: 'https://random.dog/32367-2062-4347.jpg'} +]; + +export const DynamicWithThumbs: Story = { + render: (args) => ( + + {item => ( + + {item.title} + {item.url ? {item.title} : null} + + )} + + ), + args: { + 'aria-label': 'Birthday' + } +}; + + +// taken from https://random.dog/ +const itemsWithIcons: Array<{id: string, title: string, icons: ReactNode}> = [ + {id: '0', title: 'folder of good bois', icons: }, + {id: '1', title: 'swimmer', icons: }, + {id: '2', title: 'chocolate', icons: }, + {id: '3', title: 'good boi', icons: }, + {id: '4', title: 'polar bear', icons: }, + {id: '5', title: 'cold boi', icons: }, + {id: '6', title: 'pilot', icons: }, + {id: '8', title: 'audiophile', icons: }, + {id: '9', title: 'file of great boi', icons: }, + {id: '10', title: 'fuzzy boi', icons: }, + {id: '11', title: 'i know what i am doing', icons: }, + {id: '12', title: 'kisses', icons: }, + {id: '13', title: 'belly rubs', icons: }, + {id: '14', title: 'long boi', icons: }, + {id: '15', title: 'floof', icons: }, + {id: '16', title: 'german sheparpadom', icons: }, +]; + +export const DynamicWithIcon: Story = { + render: (args) => ( + + {item => ( + + {item.title} + {item.icons ? item.icons : null} + + + + + + )} + + ), + args: { + 'aria-label': 'Birthday' + } +}; diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index ead3a3b1037..e7421e5fded 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -335,6 +335,8 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent('item', function G defaultClassName: 'react-aria-GridListItem', values: { ...states, + isFirstItem: item.key === state.collection.getFirstKey(), + isLastItem: item.key === state.collection.getLastKey(), isHovered, isFocusVisible, selectionMode: state.selectionManager.selectionMode, From 379dac396cb8411e01274f18ff353b26a1591af9 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 17 Sep 2025 15:45:21 +1000 Subject: [PATCH 02/16] Explore highlight selection --- packages/@react-spectrum/s2/src/ListView.tsx | 43 ++- packages/@react-spectrum/s2/src/Picker.tsx | 4 +- packages/@react-spectrum/s2/src/TreeView.tsx | 193 ++++++++++--- .../s2/stories/HighlightSelection.stories.tsx | 269 ++++++++++++++++++ .../s2/stories/ListView.stories.tsx | 18 +- .../s2/stories/assets/check.tsx | 2 + .../s2/style/spectrum-theme.ts | 3 +- .../__snapshots__/imports.test.ts.snap | 4 +- .../multi-collection.test.ts.snap | 4 +- 9 files changed, 467 insertions(+), 73 deletions(-) create mode 100644 packages/@react-spectrum/s2/stories/HighlightSelection.stories.tsx create mode 100644 packages/@react-spectrum/s2/stories/assets/check.tsx diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 443ac565a89..a086359102e 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -10,7 +10,11 @@ * governing permissions and limitations under the License. */ +import {ActionButtonGroupContext} from './ActionButtonGroup'; +import {baseColor, edgeToText, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; +import {centerBaseline} from './CenterBaseline'; import { + ContextValue, DEFAULT_SLOT, GridList, GridListItem, @@ -19,32 +23,27 @@ import { GridListProps, GridListRenderProps, ListLayout, - ListStateContext, Provider, - useContextProps, - useLocale, + SlotProps, Virtualizer } from 'react-aria-components'; -import {JSXElementConstructor, ReactElement, ReactNode, createContext, forwardRef, useContext, useRef} from 'react'; -import {ContextValue, SlotProps} from 'react-aria-components'; -import {DOMProps, DOMRef, DOMRefValue} from '@react-types/shared'; -import {control, controlFont, controlSize, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {controlFont, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {createContext, forwardRef, JSXElementConstructor, ReactElement, ReactNode, useContext, useRef} from 'react'; +import {DOMProps, DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; +import {IconContext} from './Icon'; +import {ImageContext} from './Image'; +import {pressScale} from './pressScale'; +import {Text, TextContext} from './Content'; +import {useDOMRef} from '@react-spectrum/utils'; +import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -import {baseColor, edgeToText, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; -import { useScale } from './utils'; -import { useDOMRef } from '@react-spectrum/utils'; -import { pressScale } from './pressScale'; -import { IconContext } from './Icon'; -import { centerBaseline } from './CenterBaseline'; -import { Text, TextContext } from './Content'; -import { ImageContext } from './Image'; -import { ActionButtonGroupContext } from './ActionButtonGroup'; -export interface ListViewProps extends GridListProps, DOMProps, StyleProps, ListViewStylesProps, SlotProps { +export interface ListViewProps extends Omit, 'className' | 'style' | 'children' | keyof GlobalDOMAttributes>, DOMProps, StyleProps, ListViewStylesProps, SlotProps { /** * Whether to automatically focus the Inline Alert when it first renders. */ - autoFocus?: boolean + autoFocus?: boolean, + children: ReactNode | ((item: T) => ReactNode) } interface ListViewStylesProps { @@ -84,10 +83,10 @@ const listView = style({ borderStyle: 'solid' }, getAllowedOverrides()); -export const ListView = /*#__PURE__*/ forwardRef(function ListView( +export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function ListView( props: ListViewProps, ref: DOMRef -): ReactNode { +) { [props, ref] = useSpectrumContextProps(props, ref, ListViewContext); let {children, isQuiet} = props; let scale = useScale(); @@ -242,10 +241,10 @@ let actionButtonGroup = style({ export function ListViewItem(props: ListViewItemProps): ReactNode { let ref = useRef(null); let isLink = props.href != null; - let isLinkOut = isLink && props.target === '_blank'; + // let isLinkOut = isLink && props.target === '_blank'; let {isQuiet} = useContext(InternalListViewContext); let textValue = props.textValue || (typeof props.children === 'string' ? props.children : undefined); - let {direction} = useLocale(); + // let {direction} = useLocale(); return ( void, /** Whether the tree should be displayed with a [emphasized style](https://spectrum.adobe.com/page/tree-view/#Emphasis). */ - isEmphasized?: boolean + isEmphasized?: boolean, + selectionStyle?: 'highlight' | 'checkbox', + selectionCornerStyle?: 'square' | 'round' } export interface TreeViewProps extends Omit, 'style' | 'className' | 'onRowAction' | 'selectionBehavior' | 'onScroll' | 'onCellAction' | 'dragAndDropHooks' | keyof GlobalDOMAttributes>, UnsafeStyles, S2TreeProps { @@ -78,10 +81,10 @@ interface TreeRendererContextValue { } const TreeRendererContext = createContext({}); -export const TreeViewContext = createContext, DOMRefValue>>(null); +export const TreeViewContext = createContext>, DOMRefValue>>(null); -let InternalTreeContext = createContext<{isDetached?: boolean, isEmphasized?: boolean}>({}); +let InternalTreeContext = createContext<{isDetached?: boolean, isEmphasized?: boolean, selectionStyle: 'highlight' | 'checkbox', selectionCornerStyle: 'square' | 'round'}>({selectionStyle: 'checkbox', selectionCornerStyle: 'round'}); // 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 @@ -113,7 +116,7 @@ const tree = style({ */ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function TreeView(props: TreeViewProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, TreeViewContext); - let {children, isDetached, isEmphasized, UNSAFE_className, UNSAFE_style} = props; + let {children, isDetached, isEmphasized, selectionStyle = 'checkbox', selectionCornerStyle = 'round', UNSAFE_className, UNSAFE_style} = props; let scale = useScale(); let renderer; @@ -131,12 +134,12 @@ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tr gap: isDetached ? 2 : 0 }}> - + (UNSAFE_className ?? '') + tree({isDetached, ...renderProps}, props.styles)} - selectionBehavior="toggle" + selectionBehavior={selectionStyle === 'highlight' ? 'replace' : 'toggle'} ref={domRef}> {props.children} @@ -150,28 +153,45 @@ const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), const selectedActiveBackground = lightDark(colorMix('gray-25', 'informative-900', 15), colorMix('gray-25', 'informative-700', 15)); 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), - isSelected: { - default: colorMix('gray-25', 'gray-900', 7), - isEmphasized: selectedBackground, - isFocusVisibleWithin: { - default: colorMix('gray-25', 'gray-900', 10), - isEmphasized: selectedActiveBackground - }, - isHovered: { - default: colorMix('gray-25', 'gray-900', 10), - isEmphasized: selectedActiveBackground + selectionStyle: { + checkbox: { + 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), + isSelected: { + default: colorMix('gray-25', 'gray-900', 7), + isEmphasized: selectedBackground, + isFocusVisibleWithin: { + default: colorMix('gray-25', 'gray-900', 10), + isEmphasized: selectedActiveBackground + }, + isHovered: { + default: colorMix('gray-25', 'gray-900', 10), + isEmphasized: selectedActiveBackground + }, + isPressed: { + default: colorMix('gray-25', 'gray-900', 10), + isEmphasized: selectedActiveBackground + } + }, + forcedColors: { + default: 'Background' + } }, - isPressed: { - default: colorMix('gray-25', 'gray-900', 10), - isEmphasized: selectedActiveBackground + highlight: { + default: '--s2-container-bg', + isFocusVisibleWithin: 'gray-100', + isHovered: 'gray-100', + isPressed: 'gray-100', + isSelected: { + default: 'gray-100', + isEmphasized: 'blue-200' + }, + forcedColors: { + default: 'Background' + } } - }, - forcedColors: { - default: 'Background' } } as const; @@ -213,14 +233,35 @@ 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: { + default: 'gray-700', + isHovered: 'gray-800', + isSelected: 'gray-900', isDisabled: { default: 'gray-400', forcedColors: 'GrayText' } }, + '--thumbnailBorderColor': { + type: 'color', + value: { + default: 'gray-500', + isHovered: 'gray-800', + isSelected: 'gray-900', + isEmphasized: { + isSelected: 'blue-900' + }, + isDisabled: { + default: 'gray-400', + forcedColors: 'GrayText' + } + } + }, + fontWeight: { + default: 'normal', + isSelected: 'medium' + }, '--rowSelectedBorderColor': { type: 'outlineColor', value: { @@ -253,6 +294,63 @@ const treeCellGrid = style({ } }); +const treeRowBackground = style({ + position: 'absolute', + zIndex: -1, + inset: 0, + backgroundColor: '--rowBackgroundColor', + borderTopStartRadius: { + selectionStyle: { + default: 'default', + highlight: { + default: 'default', + isPreviousSelected: 'none' + } + }, + selectionCornerStyle: { + square: 'none' + } + }, + borderTopEndRadius: { + selectionStyle: { + default: 'default', + highlight: { + default: 'default', + isPreviousSelected: 'none' + } + }, + selectionCornerStyle: { + square: 'none' + } + }, + borderBottomStartRadius: { + selectionStyle: { + default: 'default', + highlight: { + default: 'default', + isNextSelected: 'none' + } + }, + selectionCornerStyle: { + square: 'none' + } + }, + borderBottomEndRadius: { + selectionStyle: { + default: 'default', + highlight: { + default: 'default', + isNextSelected: 'none' + } + }, + selectionCornerStyle: { + square: 'none' + } + }, + borderWidth: 0, + borderStyle: 'solid' +}); + const treeCheckbox = style({ gridArea: 'checkbox', marginStart: 12, @@ -269,6 +367,21 @@ const treeIcon = style({ } }); +const treeThumbnail = style({ + gridArea: 'icon', + marginEnd: 'text-to-visual', + width: 32, + aspectRatio: 'square', + objectFit: 'contain', + borderRadius: 'sm', + borderWidth: 1, + borderColor: '--thumbnailBorderColor', + borderStyle: 'solid', + padding: 2, + backgroundColor: 'white', + boxSizing: 'border-box' +}); + const treeContent = style({ gridArea: 'content', textOverflow: 'ellipsis', @@ -317,15 +430,16 @@ export const TreeViewItem = (props: TreeViewItemProps): ReactNode => { let { href } = props; - let {isDetached, isEmphasized} = useContext(InternalTreeContext); + let {isDetached, isEmphasized, selectionStyle} = useContext(InternalTreeContext); return ( treeRow({ ...renderProps, - isLink: !!href, isEmphasized - }) + (renderProps.isFocusVisible && !isDetached ? ' ' + treeRowFocusIndicator : '')} /> + isLink: !!href, isEmphasized, + selectionStyle + }) + (renderProps.isFocusVisible && !isDetached && selectionStyle !== 'highlight' ? ' ' + treeRowFocusIndicator : '')} /> ); }; @@ -338,21 +452,27 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode let { children } = props; - let {isDetached, isEmphasized} = useContext(InternalTreeContext); + let {isDetached, isEmphasized, selectionStyle, selectionCornerStyle} = useContext(InternalTreeContext); let scale = useScale(); return ( - {({isExpanded, hasChildItems, selectionMode, selectionBehavior, isDisabled, isFocusVisible, isSelected, id, state}) => { + {({isExpanded, hasChildItems, selectionMode, selectionBehavior, isDisabled, isFocusVisible, isSelected, id, state, isHovered}) => { let isNextSelected = false; let isNextFocused = false; + let isPreviousSelected = false; + let keyBefore = state.collection.getKeyBefore(id); let keyAfter = state.collection.getKeyAfter(id); + if (keyBefore != null) { + isPreviousSelected = state.selectionManager.isSelected(keyBefore); + } if (keyAfter != null) { isNextSelected = state.selectionManager.isSelected(keyAfter); } let isFirst = state.collection.getFirstKey() === id; return ( -
+
+
{selectionMode !== 'none' && selectionBehavior === 'toggle' && ( // TODO: add transition?
@@ -370,12 +490,13 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode {typeof children === 'string' ? {children} : children} diff --git a/packages/@react-spectrum/s2/stories/HighlightSelection.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelection.stories.tsx new file mode 100644 index 00000000000..2bf94dda138 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/HighlightSelection.stories.tsx @@ -0,0 +1,269 @@ +/** + * 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 { + ActionButton, + ActionButtonGroup, + Collection, + Image, + Key, + Text, + TreeView, + TreeViewItem, + TreeViewItemContent, + TreeViewItemProps, + TreeViewLoadMoreItem, + TreeViewLoadMoreItemProps, + TreeViewProps +} from '../src'; +import {categorizeArgTypes, getActionArgs} from './utils'; +import {checkers} from './assets/check'; +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 '../s2wf-icons/S2_Icon_FolderOpen_20_N.svg'; +import Lock from '../s2wf-icons/S2_Icon_Lock_20_N.svg'; +import LockOpen from '../s2wf-icons/S2_Icon_LockOpen_20_N.svg'; +import type {Meta, StoryObj} from '@storybook/react'; +import React, {ReactElement, useState} from 'react'; +import Visibility from '../s2wf-icons/S2_Icon_Visibility_20_N.svg'; + +import VisibilityOff from '../s2wf-icons/S2_Icon_VisibilityOff_20_N.svg'; + +const events = ['onSelectionChange']; + +const meta: Meta = { + component: TreeView, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + args: {...getActionArgs(events)}, + argTypes: { + ...categorizeArgTypes('Events', events), + children: {table: {disable: true}} + } +}; + +export default meta; + + +interface TreeViewLayersItemType { + id?: string, + name: string, + icon?: ReactElement, + childItems?: TreeViewLayersItemType[], + isLocked?: boolean, + isVisible?: boolean +} + +let layersRows: TreeViewLayersItemType[] = [ + {id: 'layer-1', name: 'Layer', icon: }, + {id: 'layer-2', name: 'Layer', icon: , isVisible: false}, + {id: 'layer-group-1', name: 'Layer group', icon: , isVisible: false, childItems: [ + {id: 'layer-group-1-1', name: 'Layer', icon: }, + {id: 'layer-group-1-2', name: 'Layer', icon: }, + {id: 'layer-group-1-3', name: 'Layer', icon: }, + {id: 'layer-group-1-4', name: 'Layer', icon: }, + {id: 'layer-group-1-group-1', name: 'Layer Group', icon: , childItems: [ + {id: 'layer-group-1-group-1-1', name: 'Layer', icon: }, + {id: 'layer-group-1-group-1-2', name: 'Layer', icon: }, + {id: 'layer-group-1-group-1-3', name: 'Layer', icon: } + ]} + ]}, + {id: 'layer-group-2', name: 'Layer group', icon: , isLocked: true, childItems: [ + {id: 'layer-group-2-1', name: 'Layer', icon: }, + {id: 'layer-group-2-2', name: 'Layer', icon: , isVisible: false}, + {id: 'layer-group-2-3', name: 'Layer', icon: , isLocked: true}, + {id: 'layer-group-2-4', name: 'Layer', icon: }, + {id: 'layer-group-2-group-1', name: 'Layer Group', icon: } + ]}, + {id: 'layer-group-3', name: 'Layer group', icon: , childItems: [ + {id: 'reports-1', name: 'Reports 1', icon: , childItems: [ + {id: 'layer-group-3-1', name: 'Layer', icon: }, + {id: 'layer-group-3-2', name: 'Layer', icon: }, + {id: 'layer-group-3-3', name: 'Layer', icon: }, + {id: 'layer-group-3-4', name: 'Layer', icon: }, + {id: 'layer-group-3-group-1', name: 'Layer Group', icon: , childItems: [ + {id: 'layer-group-3-group-1-1', name: 'Layer', icon: }, + {id: 'layer-group-3-group-1-2', name: 'Layer', icon: }, + {id: 'layer-group-3-group-1-3', name: 'Layer', icon: } + ]} + ]}, + {id: 'layer-group-3-2', name: 'Layer', icon: }, + {id: 'layer-group-3-3', name: 'Layer', icon: }, + {id: 'layer-group-3-4', name: 'Layer', icon: }, + ...Array.from({length: 100}, (_, i) => ({id: `layer-group-3-repeat-${i}`, name: 'Layer', icon: })) + ]}, + {id: 'layer-4', name: 'Layer', icon: , isLocked: true, isVisible: false} +]; + +const TreeExampleLayersItem = (props: Omit & TreeViewLayersItemType & TreeViewLoadMoreItemProps): ReactElement => { + let {childItems, name, icon = , loadingState, onLoadMore, isLocked = false, isVisible = true} = props; + return ( + <> + + + {name} + {icon} + + {isLocked ? : } + {isVisible ? : } + + + + {(item) => ( + + )} + + {onLoadMore && loadingState && } + + + ); +}; + +const TreeExampleLayers = (args: TreeViewProps): ReactElement => ( +
+ + {(item) => ( + + )} + +
+); + +export const LayersTree: StoryObj = { + render: TreeExampleLayers, + args: { + defaultExpandedKeys: ['layer-group-2'], + selectionMode: 'multiple', + selectionStyle: 'highlight' + } +}; + +interface TreeViewFileItemType { + id?: string, + name: string, + icon?: ReactElement, + childItems?: TreeViewFileItemType[], + isExpanded?: boolean +} + +let rows: TreeViewFileItemType[] = [ + {id: 'documentation', name: 'Documentation', icon: , childItems: [ + {id: 'project-1', name: 'Project 1 Level 1', icon: }, + {id: 'project-2', name: 'Project 2 Level 1', icon: , childItems: [ + {id: 'project-2A', name: 'Project 2A Level 2', icon: }, + {id: 'project-2B', name: 'Project 2B Level 2', icon: }, + {id: 'project-2C', name: 'Project 2C Level 3', icon: } + ]}, + {id: 'project-3', name: 'Project 3', icon: }, + {id: 'project-4', name: 'Project 4', icon: }, + {id: 'project-5', name: 'Project 5', icon: , childItems: [ + {id: 'project-5A', name: 'Project 5A', icon: }, + {id: 'project-5B', name: 'Project 5B', icon: }, + {id: 'project-5C', name: 'Project 5C', icon: } + ]}, + ...Array.from({length: 100}, (_, i) => ({id: `projects-repeat-${i}`, name: `Reports ${i}`, icon: })) + ]}, + {id: 'branding', name: 'Branding', icon: , childItems: [ + {id: 'proposals', name: 'Proposals', icon: }, + {id: 'explorations', name: 'Explorations', icon: }, + {id: 'assets', name: 'Assets', icon: } + ]}, + {id: 'file01', name: 'File 01', icon: }, + {id: 'file02', name: 'File 02', icon: }, + {id: 'file03', name: 'File 03', icon: } +]; + +const TreeExampleFileItem = (props: Omit & TreeViewFileItemType & TreeViewLoadMoreItemProps & {expandedKeys: Set}): ReactElement => { + let {childItems, name, icon = , loadingState, onLoadMore, expandedKeys} = props; + let isExpanded = expandedKeys.has(props.id as Key); + return ( + <> + + + {name} + {isExpanded ? : icon} + + + {(item) => ( + + )} + + {onLoadMore && loadingState && } + + + ); +}; + +const TreeExampleFiles = (args: TreeViewProps): ReactElement => { + let [expandedKeys, setExpandedKeys] = useState>(new Set(['branding'])); + let [items, setItems] = useState(rows); + let onExpandedChange = (keys: Set) => { + setExpandedKeys(keys); + // Iterate over depth first all items in 'rows' that are in the keys set, add a property 'isExpanded' to the item. we must maintain the tree structure. + // This is to work around the fact that we cannot change the icon inside the TreeViewItemContent because it doesn't re-render for the expanded state change. + let newItems = rows.reduce((acc, item) => { + let iterator = (children: TreeViewFileItemType[]) => { + return children.map(child => { + let newChild = {...child}; + if (keys.has(child.id as Key)) { + newChild.isExpanded = true; + } + if (child.childItems) { + newChild.childItems = iterator(child.childItems); + } + return newChild; + }); + }; + let newChildren; + if (item.childItems) { + newChildren = iterator(item.childItems); + } + acc.push({...item, isExpanded: keys.has(item.id as Key), childItems: newChildren}); + return acc; + }, [] as TreeViewFileItemType[]); + setItems(newItems); + }; + return ( +
+ + {(item) => ( + + )} + +
+ ); +}; + +export const FileTree: StoryObj = { + render: TreeExampleFiles, + args: { + selectionMode: 'multiple', + selectionStyle: 'highlight' + } +}; diff --git a/packages/@react-spectrum/s2/stories/ListView.stories.tsx b/packages/@react-spectrum/s2/stories/ListView.stories.tsx index 744a5e1e139..e30795267ba 100644 --- a/packages/@react-spectrum/s2/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ListView.stories.tsx @@ -10,15 +10,15 @@ * governing permissions and limitations under the License. */ +import {ActionButton, ActionButtonGroup, Image, ListView, ListViewItem, Text} from '../'; import {categorizeArgTypes} from './utils'; -import {ListView, ListViewItem, Text, Image, ActionButton, ActionButtonGroup} from '../'; -import type {Meta, StoryObj} from '@storybook/react'; -import { Key } from 'react-aria'; -import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; -import File from '../s2wf-icons/S2_Icon_File_20_N.svg'; -import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import Delete from '../s2wf-icons/S2_Icon_Delete_20_N.svg'; -import { ReactNode } from 'react'; +import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; +import File from '../s2wf-icons/S2_Icon_File_20_N.svg'; +import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; +import {Key} from 'react-aria'; +import type {Meta, StoryObj} from '@storybook/react'; +import {ReactNode} from 'react'; const meta: Meta = { component: ListView, @@ -98,7 +98,7 @@ const items: Item[] = [ export const Dynamic: Story = { render: (args) => ( - {item => ( + {(item) => ( {item.name} )} @@ -155,7 +155,7 @@ const itemsWithIcons: Array<{id: string, title: string, icons: ReactNode}> = [ {id: '13', title: 'belly rubs', icons: }, {id: '14', title: 'long boi', icons: }, {id: '15', title: 'floof', icons: }, - {id: '16', title: 'german sheparpadom', icons: }, + {id: '16', title: 'german sheparpadom', icons: } ]; export const DynamicWithIcon: Story = { diff --git a/packages/@react-spectrum/s2/stories/assets/check.tsx b/packages/@react-spectrum/s2/stories/assets/check.tsx new file mode 100644 index 00000000000..c461a431e3d --- /dev/null +++ b/packages/@react-spectrum/s2/stories/assets/check.tsx @@ -0,0 +1,2 @@ + +export let checkers = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTAuMTAwMDk4IDBIMy4xMDAxVjNIMC4xMDAwOThWMFoiIGZpbGw9IiNFMUUxRTEiLz4KPHBhdGggZD0iTTAuMTAwMDk4IDE4SDMuMTAwMVYyMUgwLjEwMDA5OFYxOFoiIGZpbGw9IiNFMUUxRTEiLz4KPHBhdGggZD0iTTYuMTAwMSAwSDkuMTAwMVYzSDYuMTAwMVYwWiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNNi4xMDAxIDE4SDkuMTAwMVYyMUg2LjEwMDFWMThaIiBmaWxsPSIjRTFFMUUxIi8+CjxwYXRoIGQ9Ik0xMi4xMDAxIDE4SDkuMTAwMVYxNUgxMi4xMDAxVjE4WiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNMTIuMTAwMSAyNEg5LjEwMDFWMjFIMTIuMTAwMVYyNFoiIGZpbGw9IiNFMUUxRTEiLz4KPHBhdGggZD0iTTE1LjEwMDEgMEgxMi4xMDAxVjNIMTUuMTAwMVYwWiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNMTUuMTAwMSAxOEgxMi4xMDAxVjIxSDE1LjEwMDFWMThaIiBmaWxsPSIjRTFFMUUxIi8+CjxwYXRoIGQ9Ik0xOC4xMDAxIDE4SDE1LjEwMDFWMTVIMTguMTAwMVYxOFoiIGZpbGw9IiNFMUUxRTEiLz4KPHBhdGggZD0iTTE4LjEwMDEgMjRIMTUuMTAwMVYyMUgxOC4xMDAxVjI0WiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNMjEuMTAwMSAwSDE4LjEwMDFWM0gyMS4xMDAxVjBaIiBmaWxsPSIjRTFFMUUxIi8+CjxwYXRoIGQ9Ik0yMS4xMDAxIDE4SDE4LjEwMDFWMjFIMjEuMTAwMVYxOFoiIGZpbGw9IiNFMUUxRTEiLz4KPHBhdGggZD0iTTI0LjEwMDEgMThIMjEuMTAwMVYxNUgyNC4xMDAxVjE4WiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNMjEuMTAwMSAyMUgyNC4xMDAxVjI0SDIxLjEwMDFWMjFaIiBmaWxsPSIjRTFFMUUxIi8+CjxwYXRoIGQ9Ik02LjEwMDEgMThIMy4xMDAxVjE1SDYuMTAwMVYxOFoiIGZpbGw9IiNFMUUxRTEiLz4KPHBhdGggZD0iTTYuMTAwMSAyNEgzLjEwMDFWMjFINi4xMDAxVjI0WiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNMC4xMDAwOTggNkgzLjEwMDFWM0gwLjEwMDA5OFY2WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTYuMTAwMSA2SDkuMTAwMVYzSDYuMTAwMVY2WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTEyLjEwMDEgMTJIOS4xMDAxVjE1SDEyLjEwMDFWMTJaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMTUuMTAwMSA2SDEyLjEwMDFWM0gxNS4xMDAxVjZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMTguMTAwMSAxMkgxNS4xMDAxVjE1SDE4LjEwMDFWMTJaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjEuMTAwMSA2SDE4LjEwMDFWM0gyMS4xMDAxVjZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjQuMTAwMSAxMkgyMS4xMDAxVjE1SDI0LjEwMDFWMTJaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNi4xMDAxIDEySDMuMTAwMVYxNUg2LjEwMDFWMTJaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMC4xMDAwOTggOUgzLjEwMDFWNkgwLjEwMDA5OFY5WiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNNi4xMDAxIDlIOS4xMDAxVjZINi4xMDAxVjlaIiBmaWxsPSIjRTFFMUUxIi8+CjxwYXRoIGQ9Ik0xMi4xMDAxIDlIOS4xMDAxVjEySDEyLjEwMDFWOVoiIGZpbGw9IiNFMUUxRTEiLz4KPHBhdGggZD0iTTE1LjEwMDEgOUgxMi4xMDAxVjZIMTUuMTAwMVY5WiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNMTguMTAwMSA5SDE1LjEwMDFWMTJIMTguMTAwMVY5WiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNMjEuMTAwMSA5SDE4LjEwMDFWNkgyMS4xMDAxVjlaIiBmaWxsPSIjRTFFMUUxIi8+CjxwYXRoIGQ9Ik0yNC4xMDAxIDlIMjEuMTAwMVYxMkgyNC4xMDAxVjlaIiBmaWxsPSIjRTFFMUUxIi8+CjxwYXRoIGQ9Ik02LjEwMDEgOUgzLjEwMDFWMTJINi4xMDAxVjlaIiBmaWxsPSIjRTFFMUUxIi8+CjxwYXRoIGQ9Ik0wLjEwMDA5OCAxMkgzLjEwMDFWOUgwLjEwMDA5OFYxMloiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik02LjEwMDEgMTJIOS4xMDAxVjlINi4xMDAxVjEyWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTEyLjEwMDEgNkg5LjEwMDFWOUgxMi4xMDAxVjZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMTUuMTAwMSAxMkgxMi4xMDAxVjlIMTUuMTAwMVYxMloiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xOC4xMDAxIDZIMTUuMTAwMVY5SDE4LjEwMDFWNloiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0yMS4xMDAxIDEySDE4LjEwMDFWOUgyMS4xMDAxVjEyWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTI0LjEwMDEgNkgyMS4xMDAxVjlIMjQuMTAwMVY2WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTYuMTAwMSA2SDMuMTAwMVY5SDYuMTAwMVY2WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTAuMTAwMDk4IDE1SDMuMTAwMVYxMkgwLjEwMDA5OFYxNVoiIGZpbGw9IiNFMUUxRTEiLz4KPHBhdGggZD0iTTYuMTAwMSAxNUg5LjEwMDFWMTJINi4xMDAxVjE1WiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNMTIuMTAwMSAzSDkuMTAwMVY2SDEyLjEwMDFWM1oiIGZpbGw9IiNFMUUxRTEiLz4KPHBhdGggZD0iTTE1LjEwMDEgMTVIMTIuMTAwMVYxMkgxNS4xMDAxVjE1WiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNMTguMTAwMSAzSDE1LjEwMDFWNkgxOC4xMDAxVjNaIiBmaWxsPSIjRTFFMUUxIi8+CjxwYXRoIGQ9Ik0yMS4xMDAxIDE1SDE4LjEwMDFWMTJIMjEuMTAwMVYxNVoiIGZpbGw9IiNFMUUxRTEiLz4KPHBhdGggZD0iTTI0LjEwMDEgM0gyMS4xMDAxVjZIMjQuMTAwMVYzWiIgZmlsbD0iI0UxRTFFMSIvPgo8cGF0aCBkPSJNNi4xMDAxIDNIMy4xMDAxVjZINi4xMDAxVjNaIiBmaWxsPSIjRTFFMUUxIi8+CjxwYXRoIGQ9Ik0wLjEwMDA5OCAxOEgzLjEwMDFWMTVIMC4xMDAwOThWMThaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMC4xMDAwOTggMjRIMy4xMDAxVjIxSDAuMTAwMDk4VjI0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTYuMTAwMSAxOEg5LjEwMDFWMTVINi4xMDAxVjE4WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTYuMTAwMSAyNEg5LjEwMDFWMjFINi4xMDAxVjI0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTEyLjEwMDEgMEg5LjEwMDFWM0gxMi4xMDAxVjBaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMTIuMTAwMSAxOEg5LjEwMDFWMjFIMTIuMTAwMVYxOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xNS4xMDAxIDE4SDEyLjEwMDFWMTVIMTUuMTAwMVYxOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xNS4xMDAxIDI0SDEyLjEwMDFWMjFIMTUuMTAwMVYyNFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xOC4xMDAxIDBIMTUuMTAwMVYzSDE4LjEwMDFWMFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xOC4xMDAxIDE4SDE1LjEwMDFWMjFIMTguMTAwMVYxOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0yMS4xMDAxIDE4SDE4LjEwMDFWMTVIMjEuMTAwMVYxOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0yMS4xMDAxIDI0SDE4LjEwMDFWMjFIMjEuMTAwMVYyNFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0yMS4xMDAxIDNIMjQuMTAwMVYwSDIxLjEwMDFWM1oiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0yNC4xMDAxIDE4SDIxLjEwMDFWMjFIMjQuMTAwMVYxOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik02LjEwMDEgMEgzLjEwMDFWM0g2LjEwMDFWMFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik02LjEwMDEgMThIMy4xMDAxVjIxSDYuMTAwMVYxOFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo='; diff --git a/packages/@react-spectrum/s2/style/spectrum-theme.ts b/packages/@react-spectrum/s2/style/spectrum-theme.ts index 0e8b732c35a..422d37bd360 100644 --- a/packages/@react-spectrum/s2/style/spectrum-theme.ts +++ b/packages/@react-spectrum/s2/style/spectrum-theme.ts @@ -615,7 +615,8 @@ export const style = createTheme({ borderColor: new SpectrumColorProperty('borderColor', { ...baseColors, negative: colorToken('negative-border-color-default'), - disabled: colorToken('disabled-border-color') + disabled: colorToken('disabled-border-color'), + 'neutral-subdued': colorToken('neutral-subdued-content-color-default') }), outlineColor: new SpectrumColorProperty('outlineColor', { ...baseColors, diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/imports.test.ts.snap b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/imports.test.ts.snap index 4b07144fd42..44706dcc54b 100644 --- a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/imports.test.ts.snap +++ b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/imports.test.ts.snap @@ -40,8 +40,8 @@ import * as RSP1 from "@react-spectrum/s2"; `; exports[`should not import Item from S2 1`] = ` -"import { MenuItem, Menu } from "@react-spectrum/s2"; -import { ListView, Item } from '@adobe/react-spectrum'; +"import { MenuItem, Menu, ListView } from "@react-spectrum/s2"; +import { Item } from '@adobe/react-spectrum';
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/multi-collection.test.ts.snap b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/multi-collection.test.ts.snap index 1e78a4964fa..8efe5edb7e1 100644 --- a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/multi-collection.test.ts.snap +++ b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/multi-collection.test.ts.snap @@ -1,10 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Does not affect unimplemented collections 1`] = ` -"import {Item, ActionBarContainer, ActionBar, ListView, ListBox} from '@adobe/react-spectrum'; +"import { Item, ActionBarContainer, ActionBar, ListBox } from '@adobe/react-spectrum'; import {SearchAutocomplete} from '@react-spectrum/autocomplete'; import {StepList} from '@react-spectrum/steplist'; +import { ListView } from "@react-spectrum/s2"; +
One From 5ee48f93afac64ab8a9f771fe87b0f1fe19071c4 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 17 Sep 2025 15:56:21 +1000 Subject: [PATCH 03/16] fix docs type check --- packages/react-aria-components/src/GridList.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 6787e6309af..e010ca598c9 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -272,7 +272,10 @@ function GridListInner({props, collection, gridListRef: ref}: ); } -export interface GridListItemRenderProps extends ItemRenderProps {} +export interface GridListItemRenderProps extends ItemRenderProps { + isFirstItem: boolean, + isLastItem: boolean +} export interface GridListItemProps extends RenderProps, LinkDOMProps, HoverEvents, PressEvents, Omit, 'onClick'> { /** The unique id of the item. */ From 1dc83e0dcbe4b57c373340c4c371c6610d95d3cf Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 18 Sep 2025 16:39:23 +1000 Subject: [PATCH 04/16] Add highlight selection option b to table --- packages/@react-spectrum/s2/src/TableView.tsx | 106 +++++++++++---- packages/@react-spectrum/s2/src/TreeView.tsx | 55 ++------ .../HighlightSelectionTable.stories.tsx | 126 ++++++++++++++++++ ...tsx => HighlightSelectionTree.stories.tsx} | 5 +- 4 files changed, 225 insertions(+), 67 deletions(-) create mode 100644 packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx rename packages/@react-spectrum/s2/stories/{HighlightSelection.stories.tsx => HighlightSelectionTree.stories.tsx} (99%) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 6057c98d6e6..0295a9f95e4 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -14,6 +14,7 @@ import {baseColor, colorMix, focusRing, fontRelative, lightDark, space, style} f import { Button, CellRenderProps, + CheckboxContext, Collection, ColumnRenderProps, ColumnResizer, @@ -104,7 +105,9 @@ interface S2TableProps { /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ onLoadMore?: () => any, /** Provides the ActionBar to display when rows are selected in the TableView. */ - renderActionBar?: (selectedKeys: 'all' | Set) => ReactElement + renderActionBar?: (selectedKeys: 'all' | Set) => ReactElement, + selectionStyle?: 'highlight' | 'checkbox', + isEmphasized?: boolean } // TODO: Note that loadMore and loadingState are now on the Table instead of on the TableBody @@ -282,6 +285,8 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re onResizeEnd: propsOnResizeEnd, onAction, onLoadMore, + selectionStyle = 'checkbox', + isEmphasized = false, ...otherProps } = props; @@ -306,11 +311,13 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re loadingState, onLoadMore, isInResizeMode, - setIsInResizeMode - }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode]); + setIsInResizeMode, + selectionStyle, + isEmphasized + }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode, selectionStyle, isEmphasized]); let scrollRef = useRef(null); - let isCheckboxSelection = props.selectionMode === 'multiple' || props.selectionMode === 'single'; + let isCheckboxSelection = (props.selectionMode === 'multiple' || props.selectionMode === 'single') && selectionStyle === 'checkbox'; let {selectedKeys, onSelectionChange, actionBar, actionBarHeight} = useActionBarContainer({...props, scrollRef}); @@ -872,7 +879,7 @@ export interface TableHeaderProps extends Omit, 'style export const TableHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(function TableHeader({columns, dependencies, children}: TableHeaderProps, ref: DOMRef) { let scale = useScale(); let {selectionBehavior, selectionMode} = useTableOptions(); - let {isQuiet} = useContext(InternalTableContext); + let {isQuiet, selectionStyle} = useContext(InternalTableContext); let domRef = useDOMRef(ref); return ( @@ -881,7 +888,7 @@ export const TableHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(function ref={domRef} className={tableHeader}> {/* Add extra columns for selection. */} - {selectionBehavior === 'toggle' && ( + {selectionBehavior === 'toggle' && selectionStyle === 'checkbox' && ( // Also isSticky prop is applied just for the layout, will decide what the RAC api should be later // @ts-ignore @@ -1003,6 +1010,16 @@ const cellContent = style({ backgroundColor: { default: 'transparent', isSticky: '--rowBackgroundColor' + }, + fontWeight: { + selectionStyle: { + highlight: { + default: 'normal', + isRowHeader: { + isSelected: 'medium' + } + } + } } }); @@ -1036,15 +1053,33 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef {({isFocusVisible}) => ( - <> - {children} - {isFocusVisible && } - + // @ts-ignore + )} ); }); +let InnerCell = function InnerCell(props: {isFocusVisible: boolean, children: ReactNode, isSticky?: boolean, align?: 'start' | 'center' | 'end', isRowHeader?: boolean}) { + let {isFocusVisible, children, isSticky, align, isRowHeader} = props; + let tableVisualOptions = useContext(InternalTableContext); + let {isSelected} = useSlottedContext(CheckboxContext, 'selection') ?? {isSelected: false}; + + return ( + <> + {children} + {isFocusVisible && } + + ); +}; + // Use color-mix instead of transparency so sticky cells work correctly. const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), colorMix('gray-25', 'informative-700', 10)); const selectedActiveBackground = lightDark(colorMix('gray-25', 'informative-900', 15), colorMix('gray-25', 'informative-700', 15)); @@ -1053,17 +1088,33 @@ const rowBackgroundColor = { default: 'gray-25', isQuiet: '--s2-container-bg' }, - isFocusVisibleWithin: colorMix('gray-25', 'gray-900', 7), // table-row-hover-color - isHovered: colorMix('gray-25', 'gray-900', 7), // table-row-hover-color - isPressed: colorMix('gray-25', 'gray-900', 10), // table-row-hover-color - isSelected: { - default: selectedBackground, // table-selected-row-background-color, opacity /10 - isFocusVisibleWithin: selectedActiveBackground, // table-selected-row-background-color, opacity /15 - isHovered: selectedActiveBackground, // table-selected-row-background-color, opacity /15 - isPressed: selectedActiveBackground // table-selected-row-background-color, opacity /15 - }, - forcedColors: { - default: 'Background' + selectionStyle: { + checkbox: { + isFocusVisibleWithin: colorMix('gray-25', 'gray-900', 7), // table-row-hover-color + isHovered: colorMix('gray-25', 'gray-900', 7), // table-row-hover-color + isPressed: colorMix('gray-25', 'gray-900', 10), // table-row-hover-color + isSelected: { + default: selectedBackground, // table-selected-row-background-color, opacity /10 + isFocusVisibleWithin: selectedActiveBackground, // table-selected-row-background-color, opacity /15 + isHovered: selectedActiveBackground, // table-selected-row-background-color, opacity /15 + isPressed: selectedActiveBackground // table-selected-row-background-color, opacity /15 + }, + forcedColors: { + default: 'Background' + } + }, + highlight: { + isFocusVisibleWithin: 'gray-100', + isHovered: 'gray-100', + isPressed: 'gray-100', + isSelected: { + default: 'gray-100', + isEmphasized: 'blue-200' + }, + forcedColors: { + default: 'Background' + } + } } } as const; @@ -1117,7 +1168,16 @@ const row = style({ default: 'gray-300', forcedColors: 'ButtonBorder' }, - forcedColorAdjust: 'none' + forcedColorAdjust: 'none', + color: { + selectionStyle: { + highlight: { + default: 'gray-700', + isHovered: 'gray-800', + isPressed: 'gray-900' + } + } + } }); export interface RowProps extends Pick, 'id' | 'columns' | 'children' | 'textValue' | 'dependencies' | keyof GlobalDOMAttributes> {} @@ -1141,7 +1201,7 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row - {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( + {selectionMode !== 'none' && selectionBehavior === 'toggle' && tableVisualOptions.selectionStyle === 'checkbox' && ( diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 5b958325443..569d1f0391a 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -300,52 +300,16 @@ const treeRowBackground = style({ inset: 0, backgroundColor: '--rowBackgroundColor', borderTopStartRadius: { - selectionStyle: { - default: 'default', - highlight: { - default: 'default', - isPreviousSelected: 'none' - } - }, - selectionCornerStyle: { - square: 'none' - } + isRoundTop: 'default' }, borderTopEndRadius: { - selectionStyle: { - default: 'default', - highlight: { - default: 'default', - isPreviousSelected: 'none' - } - }, - selectionCornerStyle: { - square: 'none' - } + isRoundTop: 'default' }, borderBottomStartRadius: { - selectionStyle: { - default: 'default', - highlight: { - default: 'default', - isNextSelected: 'none' - } - }, - selectionCornerStyle: { - square: 'none' - } + isRoundBottom: 'default' }, borderBottomEndRadius: { - selectionStyle: { - default: 'default', - highlight: { - default: 'default', - isNextSelected: 'none' - } - }, - selectionCornerStyle: { - square: 'none' - } + isRoundBottom: 'default' }, borderWidth: 0, borderStyle: 'solid' @@ -470,9 +434,16 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode isNextSelected = state.selectionManager.isSelected(keyAfter); } let isFirst = state.collection.getFirstKey() === id; + let isRoundTop = false; + let isRoundBottom = false; + if (selectionStyle === 'highlight' && selectionCornerStyle === 'round') { + isRoundTop = (isHovered && !isSelected) || (isSelected && !isPreviousSelected); + isRoundBottom = (isHovered && !isSelected) || (isSelected && !isNextSelected); + } + return ( -
-
+
+
{selectionMode !== 'none' && selectionBehavior === 'toggle' && ( // TODO: add transition?
diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx new file mode 100644 index 00000000000..fada953506c --- /dev/null +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx @@ -0,0 +1,126 @@ +/** + * 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 {CalendarDate, getLocalTimeZone} from '@internationalized/date'; +import {categorizeArgTypes, getActionArgs} from './utils'; +import { + Cell, + Column, + Row, + TableBody, + TableHeader, + TableView, + TableViewProps, + TreeView +} from '../src'; +import type {Meta} from '@storybook/react'; +import React, {ReactElement} from 'react'; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import UserGroup from '../s2wf-icons/S2_Icon_UserGroup_20_N.svg'; + +const events = ['onSelectionChange']; + +const meta: Meta = { + title: 'Highlight Selection/TableView', + component: TableView, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + args: {...getActionArgs(events)}, + argTypes: { + ...categorizeArgTypes('Events', events), + children: {table: {disable: true}} + } +}; + +export default meta; + +let columns = [ + {name: 'Name', id: 'name', isRowHeader: true, minWidth: 400}, + {name: 'Sharing', id: 'sharing', minWidth: 200}, + {name: 'Date modified', id: 'date', minWidth: 200} +]; + +interface Item { + id: number, + name: { + name: string, + meta: string, + description?: string + }, + sharing: string, + date: CalendarDate +} + +let items: Item[] = [ + {id: 1, name: {name: 'Designer resume', meta: 'PDF', description: 'From Molly Holt'}, sharing: 'public', date: new CalendarDate(2020, 7, 6)}, + // eslint-disable-next-line quotes + {id: 2, name: {name: `Career Management for IC's`, meta: 'PDF'}, sharing: 'public', date: new CalendarDate(2020, 7, 6)}, + {id: 3, name: {name: 'CMP Sessions', meta: 'PDF'}, sharing: 'public', date: new CalendarDate(2020, 7, 6)}, + {id: 4, name: {name: 'Clifton Strength Assessment Info', meta: 'Folder'}, sharing: 'none', date: new CalendarDate(2020, 7, 6)}, + {id: 5, name: {name: 'Personal Brand', meta: 'Zip'}, sharing: 'private', date: new CalendarDate(2020, 7, 6)}, + {id: 6, name: {name: 'Personal Brand', meta: 'Zip'}, sharing: 'private', date: new CalendarDate(2020, 7, 6)}, + {id: 7, name: {name: 'Personal Brand', meta: 'Zip'}, sharing: 'private', date: new CalendarDate(2020, 7, 6)}, + {id: 8, name: {name: 'Personal Brand', meta: 'Zip'}, sharing: 'private', date: new CalendarDate(2020, 7, 6)}, + {id: 9, name: {name: 'Personal Brand', meta: 'Zip'}, sharing: 'private', date: new CalendarDate(2020, 7, 6)}, + {id: 10, name: {name: 'Personal Brand', meta: 'Zip'}, sharing: 'private', date: new CalendarDate(2020, 7, 6)} +]; + +export const DocumentsTable = { + render: (args: TableViewProps): ReactElement => ( + + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.id === 'sharing') { + let content = item[column.id] === 'public' ?
Shared
: 'Only you'; + if (item[column.id] === 'none') { + content = '-'; + } + return {content}; + } + if (column.id === 'name') { + return ( + +
+
{item[column.id].name}
+
+
{item[column.id].meta}
+ {item[column.id].description && <>
·
{item[column.id].description}
} +
+
+
+ ); + } + if (column.id === 'date') { + return {item[column.id].toDate(getLocalTimeZone()).toLocaleDateString('en-US', {year: 'numeric', month: 'long', day: 'numeric'})}; + } + return {item[column.id]}; + }} +
+ )} +
+
+ ), + args: { + overflowMode: 'wrap', + selectionStyle: 'highlight', + selectionMode: 'multiple' + } +}; diff --git a/packages/@react-spectrum/s2/stories/HighlightSelection.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx similarity index 99% rename from packages/@react-spectrum/s2/stories/HighlightSelection.stories.tsx rename to packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx index 2bf94dda138..1f1220e9865 100644 --- a/packages/@react-spectrum/s2/stories/HighlightSelection.stories.tsx +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx @@ -35,12 +35,12 @@ import LockOpen from '../s2wf-icons/S2_Icon_LockOpen_20_N.svg'; import type {Meta, StoryObj} from '@storybook/react'; import React, {ReactElement, useState} from 'react'; import Visibility from '../s2wf-icons/S2_Icon_Visibility_20_N.svg'; - import VisibilityOff from '../s2wf-icons/S2_Icon_VisibilityOff_20_N.svg'; const events = ['onSelectionChange']; const meta: Meta = { + title: 'Highlight Selection/TreeView', component: TreeView, parameters: { layout: 'centered' @@ -147,7 +147,8 @@ export const LayersTree: StoryObj = { args: { defaultExpandedKeys: ['layer-group-2'], selectionMode: 'multiple', - selectionStyle: 'highlight' + selectionStyle: 'highlight', + selectionCornerStyle: 'round' } }; From 1d8e8a1d2105a27b7a2cc50827bf0ba12a3a487b Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 18 Sep 2025 17:32:28 +1000 Subject: [PATCH 05/16] Add highlight selection option b to ListView --- packages/@react-spectrum/s2/src/ListView.tsx | 59 ++++++------ .../HighlightSelectionList.stories.tsx | 89 +++++++++++++++++++ .../HighlightSelectionTable.stories.tsx | 5 +- 3 files changed, 124 insertions(+), 29 deletions(-) create mode 100644 packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index a086359102e..4b2ce2e0c63 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -27,7 +27,7 @@ import { SlotProps, Virtualizer } from 'react-aria-components'; -import {controlFont, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {controlFont, getAllowedOverrides, StyleProps, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {createContext, forwardRef, JSXElementConstructor, ReactElement, ReactNode, useContext, useRef} from 'react'; import {DOMProps, DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; import {IconContext} from './Icon'; @@ -38,7 +38,8 @@ import {useDOMRef} from '@react-spectrum/utils'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface ListViewProps extends Omit, 'className' | 'style' | 'children' | keyof GlobalDOMAttributes>, DOMProps, StyleProps, ListViewStylesProps, SlotProps { +export interface ListViewProps extends Omit, 'className' | 'style' | 'children' | keyof GlobalDOMAttributes>, DOMProps, UnsafeStyles, ListViewStylesProps, SlotProps { + styles?: StylesPropWithHeight, /** * Whether to automatically focus the Inline Alert when it first renders. */ @@ -47,7 +48,9 @@ export interface ListViewProps extends Omit, 'className' | ' } interface ListViewStylesProps { - isQuiet?: boolean + isQuiet?: boolean, + isEmphasized?: boolean, + selectionStyle?: 'highlight' | 'checkbox' } export interface ListViewItemProps extends Omit, StyleProps { @@ -64,7 +67,7 @@ const ListViewRendererContext = createContext({}); export const ListViewContext = createContext>, DOMRefValue>>(null); -let InternalListViewContext = createContext<{isQuiet?: boolean}>({}); +let InternalListViewContext = createContext<{isQuiet?: boolean, isEmphasized?: boolean}>({}); const listView = style({ ...focusRing(), @@ -81,14 +84,14 @@ const listView = style({ borderColor: 'gray-300', borderWidth: 1, borderStyle: 'solid' -}, getAllowedOverrides()); +}, getAllowedOverrides({height: true})); export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function ListView( props: ListViewProps, ref: DOMRef ) { [props, ref] = useSpectrumContextProps(props, ref, ListViewContext); - let {children, isQuiet} = props; + let {children, isQuiet, isEmphasized} = props; let scale = useScale(); let renderer; @@ -105,7 +108,7 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li rowHeight: scale === 'large' ? 50 : 40 }}> - + ({ +const listitem = style({ ...focusRing(), outlineOffset: 0, columnGap: 0, @@ -130,10 +133,16 @@ const listitem = style (props.UNSAFE_className || '') + listitem({ ...renderProps, isLink, - isQuiet + isQuiet, + isEmphasized }, props.styles)}> {(renderProps) => { let {children} = props; @@ -274,7 +281,7 @@ export function ListViewItem(props: ListViewItemProps): ReactNode { } }], [ImageContext, {styles: image}], - [ActionButtonGroupContext, {styles: actionButtonGroup}] + [ActionButtonGroupContext, {styles: actionButtonGroup, size: 'S', isQuiet: true}] ]}> {typeof children === 'string' ? {children} : children} diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx new file mode 100644 index 00000000000..408d37c7987 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx @@ -0,0 +1,89 @@ +/** + * 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. + */ +// TODO: pull all the highlight styles out into a separate macro(s) for the background color, text color, etc. +import ABC from '../s2wf-icons/S2_Icon_ABC_20_N.svg'; +import { + ActionButton, + ActionButtonGroup, + ListView, + ListViewItem, + Text +} from '../src'; +import Add from '../s2wf-icons/S2_Icon_Add_20_N.svg'; +import {categorizeArgTypes, getActionArgs} from './utils'; +import InfoCircle from '../s2wf-icons/S2_Icon_InfoCircle_20_N.svg'; +import type {Meta, StoryObj} from '@storybook/react'; +import React from 'react'; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import TextNumbers from '../s2wf-icons/S2_Icon_TextNumbers_20_N.svg'; + +const events = ['onSelectionChange']; + +const meta: Meta = { + title: 'Highlight Selection/ListView', + component: ListView, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + args: {...getActionArgs(events)}, + argTypes: { + ...categorizeArgTypes('Events', events), + children: {table: {disable: true}} + } +}; + +export default meta; + +interface Item { + id: number, + name: string, + type: 'letter' | 'number' +} + +let items: Item[] = [ + {id: 1, name: 'Count', type: 'number'}, + {id: 2, name: 'City', type: 'letter'}, + {id: 3, name: 'Count of identities', type: 'number'}, + {id: 4, name: 'Current day', type: 'number'}, + {id: 5, name: 'Current month', type: 'letter'}, + {id: 6, name: 'Current week', type: 'number'}, + {id: 7, name: 'Current year', type: 'number'}, + {id: 8, name: 'Current whatever', type: 'number'}, + {id: 9, name: 'Alphabet', type: 'letter'}, + {id: 10, name: 'Numbers', type: 'number'} +]; + +export const DocumentsTable: StoryObj = { + render: (args) => ( + + {item => ( + + {item.type === 'number' ? : } + {item.name} + + + + + + + + + + )} + + ), + args: { + selectionStyle: 'highlight', + selectionMode: 'multiple' + } +}; diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx index fada953506c..3d3703be546 100644 --- a/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx @@ -19,8 +19,7 @@ import { TableBody, TableHeader, TableView, - TableViewProps, - TreeView + TableViewProps } from '../src'; import type {Meta} from '@storybook/react'; import React, {ReactElement} from 'react'; @@ -29,7 +28,7 @@ import UserGroup from '../s2wf-icons/S2_Icon_UserGroup_20_N.svg'; const events = ['onSelectionChange']; -const meta: Meta = { +const meta: Meta = { title: 'Highlight Selection/TableView', component: TableView, parameters: { From e62eb1d706d39ef47ed0e42322d16196ea66a532 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 18 Sep 2025 17:49:27 +1000 Subject: [PATCH 06/16] change selection behaviour of table for highlight mode --- packages/@react-spectrum/s2/src/TableView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 0295a9f95e4..fc88e521ac4 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -359,9 +359,9 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re isCheckboxSelection, isQuiet })} - selectionBehavior="toggle" onRowAction={onAction} {...otherProps} + selectionBehavior={selectionStyle === 'highlight' ? 'replace' : 'toggle'} selectedKeys={selectedKeys} defaultSelectedKeys={undefined} onSelectionChange={onSelectionChange} /> From 020541a6e14b0d65460672f8788fb465de7b0333 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 19 Sep 2025 15:33:46 +1000 Subject: [PATCH 07/16] add option D as highlightMode --- packages/@react-spectrum/s2/src/ListView.tsx | 43 ++++++++++++++----- packages/@react-spectrum/s2/src/TableView.tsx | 29 +++++++++++-- packages/@react-spectrum/s2/src/TreeView.tsx | 37 ++++++++++------ .../HighlightSelectionList.stories.tsx | 7 +-- .../HighlightSelectionTable.stories.tsx | 9 ++-- .../HighlightSelectionTree.stories.tsx | 9 ++-- 6 files changed, 95 insertions(+), 39 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 4b2ce2e0c63..7ca81cb0224 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -50,7 +50,8 @@ export interface ListViewProps extends Omit, 'className' | ' interface ListViewStylesProps { isQuiet?: boolean, isEmphasized?: boolean, - selectionStyle?: 'highlight' | 'checkbox' + selectionStyle?: 'highlight' | 'checkbox', + highlightMode?: 'normal' | 'inverse' } export interface ListViewItemProps extends Omit, StyleProps { @@ -67,7 +68,7 @@ const ListViewRendererContext = createContext({}); export const ListViewContext = createContext>, DOMRefValue>>(null); -let InternalListViewContext = createContext<{isQuiet?: boolean, isEmphasized?: boolean}>({}); +let InternalListViewContext = createContext<{isQuiet?: boolean, isEmphasized?: boolean, highlightMode?: 'normal' | 'inverse'}>({}); const listView = style({ ...focusRing(), @@ -91,7 +92,7 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li ref: DOMRef ) { [props, ref] = useSpectrumContextProps(props, ref, ListViewContext); - let {children, isQuiet, isEmphasized} = props; + let {children, isQuiet, isEmphasized, highlightMode} = props; let scale = useScale(); let renderer; @@ -108,7 +109,7 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li rowHeight: scale === 'large' ? 50 : 40 }}> - + ({ +const listitem = style({ ...focusRing(), outlineOffset: 0, columnGap: 0, @@ -135,14 +136,28 @@ const listitem = style {(renderProps) => { let {children} = props; @@ -281,7 +297,12 @@ export function ListViewItem(props: ListViewItemProps): ReactNode { } }], [ImageContext, {styles: image}], - [ActionButtonGroupContext, {styles: actionButtonGroup, size: 'S', isQuiet: true}] + [ActionButtonGroupContext, { + styles: actionButtonGroup, + size: 'S', + isQuiet: true, + staticColor: highlightMode === 'inverse' && renderProps.isSelected ? 'white' : undefined // how to invert this and react to color scheme? also, too bright/bold in dark mode unselected + }] ]}> {typeof children === 'string' ? {children} : children} diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index fc88e521ac4..be2dce3de83 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -107,7 +107,8 @@ interface S2TableProps { /** Provides the ActionBar to display when rows are selected in the TableView. */ renderActionBar?: (selectedKeys: 'all' | Set) => ReactElement, selectionStyle?: 'highlight' | 'checkbox', - isEmphasized?: boolean + isEmphasized?: boolean, + highlightMode?: 'normal' | 'inverse' } // TODO: Note that loadMore and loadingState are now on the Table instead of on the TableBody @@ -287,6 +288,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re onLoadMore, selectionStyle = 'checkbox', isEmphasized = false, + highlightMode = 'normal', ...otherProps } = props; @@ -313,8 +315,9 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re isInResizeMode, setIsInResizeMode, selectionStyle, - isEmphasized - }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode, selectionStyle, isEmphasized]); + isEmphasized, + highlightMode + }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode, selectionStyle, isEmphasized, highlightMode]); let scrollRef = useRef(null); let isCheckboxSelection = (props.selectionMode === 'multiple' || props.selectionMode === 'single') && selectionStyle === 'checkbox'; @@ -984,6 +987,10 @@ const checkboxCellStyle = style({ const cellContent = style({ truncate: true, + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + }, whiteSpace: { default: 'nowrap', overflowMode: { @@ -1007,6 +1014,13 @@ const cellContent = style({ default: -4, isSticky: 0 }, + color: { + highlightMode: { + inverse: { + isSelected: 'gray-25' + } + } + }, backgroundColor: { default: 'transparent', isSticky: '--rowBackgroundColor' @@ -1109,7 +1123,14 @@ const rowBackgroundColor = { isPressed: 'gray-100', isSelected: { default: 'gray-100', - isEmphasized: 'blue-200' + highlightMode: { + normal: { + isEmphasized: 'blue-200' + }, + inverse: { + isEmphasized: 'blue-800' + } + } }, forcedColors: { default: 'Background' diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 569d1f0391a..7c144c3f6c8 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -58,7 +58,8 @@ interface S2TreeProps { /** Whether the tree should be displayed with a [emphasized style](https://spectrum.adobe.com/page/tree-view/#Emphasis). */ isEmphasized?: boolean, selectionStyle?: 'highlight' | 'checkbox', - selectionCornerStyle?: 'square' | 'round' + selectionCornerStyle?: 'square' | 'round', + highlightMode?: 'normal' | 'inverse' } export interface TreeViewProps extends Omit, 'style' | 'className' | 'onRowAction' | 'selectionBehavior' | 'onScroll' | 'onCellAction' | 'dragAndDropHooks' | keyof GlobalDOMAttributes>, UnsafeStyles, S2TreeProps { @@ -84,7 +85,7 @@ const TreeRendererContext = createContext({}); export const TreeViewContext = createContext>, DOMRefValue>>(null); -let InternalTreeContext = createContext<{isDetached?: boolean, isEmphasized?: boolean, selectionStyle: 'highlight' | 'checkbox', selectionCornerStyle: 'square' | 'round'}>({selectionStyle: 'checkbox', selectionCornerStyle: 'round'}); +let InternalTreeContext = createContext<{isDetached?: boolean, isEmphasized?: boolean, selectionStyle: 'highlight' | 'checkbox', selectionCornerStyle: 'square' | 'round', highlightMode?: 'normal' | 'inverse'}>({selectionStyle: 'checkbox', selectionCornerStyle: 'round'}); // 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 @@ -116,7 +117,7 @@ const tree = style({ */ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function TreeView(props: TreeViewProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, TreeViewContext); - let {children, isDetached, isEmphasized, selectionStyle = 'checkbox', selectionCornerStyle = 'round', UNSAFE_className, UNSAFE_style} = props; + let {children, isDetached, isEmphasized, selectionStyle = 'checkbox', selectionCornerStyle = 'round', UNSAFE_className, UNSAFE_style, highlightMode = 'normal'} = props; let scale = useScale(); let renderer; @@ -134,7 +135,7 @@ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tr gap: isDetached ? 2 : 0 }}> - + { let { href } = props; - let {isDetached, isEmphasized, selectionStyle} = useContext(InternalTreeContext); + let {isDetached, isEmphasized, selectionStyle, highlightMode} = useContext(InternalTreeContext); return ( treeRow({ ...renderProps, - isLink: !!href, isEmphasized, - selectionStyle + isLink: !!href, + isEmphasized, + selectionStyle, + highlightMode }) + (renderProps.isFocusVisible && !isDetached && selectionStyle !== 'highlight' ? ' ' + treeRowFocusIndicator : '')} /> ); }; @@ -416,7 +429,7 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode let { children } = props; - let {isDetached, isEmphasized, selectionStyle, selectionCornerStyle} = useContext(InternalTreeContext); + let {isDetached, isEmphasized, selectionStyle, selectionCornerStyle, highlightMode} = useContext(InternalTreeContext); let scale = useScale(); return ( @@ -442,7 +455,7 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode } return ( -
+
{selectionMode !== 'none' && selectionBehavior === 'toggle' && ( // TODO: add transition? @@ -466,7 +479,7 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode render: centerBaseline({slot: 'icon', styles: treeIcon}), styles: style({size: fontRelative(20), flexShrink: 0}) }], - [ActionButtonGroupContext, {styles: treeActions, size: 'S'}], + [ActionButtonGroupContext, {styles: treeActions, size: 'S', staticColor: highlightMode === 'inverse' && isSelected ? 'white' : undefined}], [ActionMenuContext, {styles: treeActionMenu, isQuiet: true, size: 'S'}] ]}> {typeof children === 'string' ? {children} : children} diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx index 408d37c7987..aa6cae100e3 100644 --- a/packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx @@ -34,7 +34,6 @@ const meta: Meta = { parameters: { layout: 'centered' }, - tags: ['autodocs'], args: {...getActionArgs(events)}, argTypes: { ...categorizeArgTypes('Events', events), @@ -63,7 +62,7 @@ let items: Item[] = [ {id: 10, name: 'Numbers', type: 'number'} ]; -export const DocumentsTable: StoryObj = { +export const AttributesList: StoryObj = { render: (args) => ( {item => ( @@ -84,6 +83,8 @@ export const DocumentsTable: StoryObj = { ), args: { selectionStyle: 'highlight', - selectionMode: 'multiple' + selectionMode: 'multiple', + highlightMode: 'inverse', + isEmphasized: true } }; diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx index 3d3703be546..09e76f893a2 100644 --- a/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx @@ -34,7 +34,6 @@ const meta: Meta = { parameters: { layout: 'centered' }, - tags: ['autodocs'], args: {...getActionArgs(events)}, argTypes: { ...categorizeArgTypes('Events', events), @@ -99,10 +98,6 @@ export const DocumentsTable = {
{item[column.id].name}
-
-
{item[column.id].meta}
- {item[column.id].description && <>
·
{item[column.id].description}
} -
); @@ -120,6 +115,8 @@ export const DocumentsTable = { args: { overflowMode: 'wrap', selectionStyle: 'highlight', - selectionMode: 'multiple' + selectionMode: 'multiple', + highlightMode: 'inverse', + isEmphasized: true } }; diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx index 1f1220e9865..057290d74ff 100644 --- a/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx @@ -45,7 +45,6 @@ const meta: Meta = { parameters: { layout: 'centered' }, - tags: ['autodocs'], args: {...getActionArgs(events)}, argTypes: { ...categorizeArgTypes('Events', events), @@ -148,7 +147,9 @@ export const LayersTree: StoryObj = { defaultExpandedKeys: ['layer-group-2'], selectionMode: 'multiple', selectionStyle: 'highlight', - selectionCornerStyle: 'round' + selectionCornerStyle: 'round', + highlightMode: 'inverse', + isEmphasized: true } }; @@ -265,6 +266,8 @@ export const FileTree: StoryObj = { render: TreeExampleFiles, args: { selectionMode: 'multiple', - selectionStyle: 'highlight' + selectionStyle: 'highlight', + highlightMode: 'inverse', + isEmphasized: true } }; From 22545f2e3632af8c17c1483caa68c16b8a935b4a Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 2 Oct 2025 10:05:19 +1000 Subject: [PATCH 08/16] change font weight to bold for highlight selection --- packages/@react-spectrum/s2/src/ListView.tsx | 2 +- packages/@react-spectrum/s2/src/TableView.tsx | 2 +- packages/@react-spectrum/s2/src/TreeView.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 7ca81cb0224..56bdde25d48 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -201,7 +201,7 @@ export let label = style({ color: 'inherit', fontWeight: { default: 'normal', - isSelected: 'medium' + isSelected: 'bold' }, // TODO: token values for padding not defined yet, revisit marginTop: '--labelPadding', diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index be2dce3de83..b097a99901a 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -1030,7 +1030,7 @@ const cellContent = style({ highlight: { default: 'normal', isRowHeader: { - isSelected: 'medium' + isSelected: 'bold' } } } diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 7c144c3f6c8..27f7ed072d6 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -271,7 +271,7 @@ const treeCellGrid = style({ }, fontWeight: { default: 'normal', - isSelected: 'medium' + isSelected: 'bold' }, '--rowSelectedBorderColor': { type: 'outlineColor', From 49beedfc09fc1456d1b638085e15c427bcf2d665 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 3 Oct 2025 08:55:45 +1000 Subject: [PATCH 09/16] update background color --- packages/@react-spectrum/s2/src/ListView.tsx | 2 +- packages/@react-spectrum/s2/src/TableView.tsx | 2 +- packages/@react-spectrum/s2/src/TreeView.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 56bdde25d48..ed6871eaac3 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -139,7 +139,7 @@ const listitem = style Date: Tue, 21 Oct 2025 17:07:15 +1100 Subject: [PATCH 10/16] Start new highlight selection prototypes, ListView and part of TreeView --- packages/@react-spectrum/s2/src/ListView.tsx | 106 +++++++---- packages/@react-spectrum/s2/src/TreeView.tsx | 174 +++++++++++------- .../HighlightSelectionTree.stories.tsx | 2 - .../s2/style/spectrum-theme.ts | 21 ++- .../react-aria-components/src/GridList.tsx | 2 + 5 files changed, 193 insertions(+), 112 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index ed6871eaac3..62a14080154 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -68,7 +68,7 @@ const ListViewRendererContext = createContext({}); export const ListViewContext = createContext>, DOMRefValue>>(null); -let InternalListViewContext = createContext<{isQuiet?: boolean, isEmphasized?: boolean, highlightMode?: 'normal' | 'inverse'}>({}); +let InternalListViewContext = createContext<{isQuiet?: boolean, isEmphasized?: boolean}>({}); const listView = style({ ...focusRing(), @@ -92,7 +92,7 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li ref: DOMRef ) { [props, ref] = useSpectrumContextProps(props, ref, ListViewContext); - let {children, isQuiet, isEmphasized, highlightMode} = props; + let {children, isQuiet, isEmphasized} = props; let scale = useScale(); let renderer; @@ -109,7 +109,7 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li rowHeight: scale === 'large' ? 50 : 40 }}> - + ({ +const listitem = style({ ...focusRing(), - outlineOffset: 0, + boxSizing: 'border-box', + outlineOffset: -2, columnGap: 0, paddingX: 0, paddingBottom: '--labelPadding', backgroundColor: { default: 'transparent', isHovered: 'gray-100', - isSelected: 'gray-100', - highlightMode: { - normal: { - isEmphasized: { - isSelected: 'blue-900/10' - } - }, - inverse: { - isEmphasized: { - isSelected: 'blue-800' - } - } + isSelected: { + default: 'blue-900/10', + isHovered: 'blue-900/15' } }, color: { default: baseColor('neutral-subdued'), isHovered: 'gray-800', - isSelected: { - highlightMode: { - normal: 'gray-900', - inverse: 'gray-25' - } - }, + isSelected: 'gray-900', isDisabled: { default: 'disabled', forcedColors: 'GrayText' @@ -184,14 +182,48 @@ const listitem = style {(renderProps) => { let {children} = props; @@ -300,8 +327,7 @@ export function ListViewItem(props: ListViewItemProps): ReactNode { [ActionButtonGroupContext, { styles: actionButtonGroup, size: 'S', - isQuiet: true, - staticColor: highlightMode === 'inverse' && renderProps.isSelected ? 'white' : undefined // how to invert this and react to color scheme? also, too bright/bold in dark mode unselected + isQuiet: true }] ]}> {typeof children === 'string' ? {children} : children} diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 487da480938..c2bc05d4ec5 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -58,8 +58,7 @@ interface S2TreeProps { /** Whether the tree should be displayed with a [emphasized style](https://spectrum.adobe.com/page/tree-view/#Emphasis). */ isEmphasized?: boolean, selectionStyle?: 'highlight' | 'checkbox', - selectionCornerStyle?: 'square' | 'round', - highlightMode?: 'normal' | 'inverse' + selectionCornerStyle?: 'square' | 'round' } export interface TreeViewProps extends Omit, 'style' | 'className' | 'onRowAction' | 'selectionBehavior' | 'onScroll' | 'onCellAction' | 'dragAndDropHooks' | keyof GlobalDOMAttributes>, UnsafeStyles, S2TreeProps { @@ -85,7 +84,7 @@ const TreeRendererContext = createContext({}); export const TreeViewContext = createContext>, DOMRefValue>>(null); -let InternalTreeContext = createContext<{isDetached?: boolean, isEmphasized?: boolean, selectionStyle: 'highlight' | 'checkbox', selectionCornerStyle: 'square' | 'round', highlightMode?: 'normal' | 'inverse'}>({selectionStyle: 'checkbox', selectionCornerStyle: 'round'}); +let InternalTreeContext = createContext<{isDetached?: boolean, isEmphasized?: boolean, selectionStyle: 'highlight' | 'checkbox', selectionCornerStyle: 'square' | 'round'}>({selectionStyle: 'checkbox', selectionCornerStyle: 'round'}); // 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 @@ -117,7 +116,7 @@ const tree = style({ */ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function TreeView(props: TreeViewProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, TreeViewContext); - let {children, isDetached, isEmphasized, selectionStyle = 'checkbox', selectionCornerStyle = 'round', UNSAFE_className, UNSAFE_style, highlightMode = 'normal'} = props; + let {children, isDetached, isEmphasized, selectionStyle = 'checkbox', selectionCornerStyle = 'round', UNSAFE_className, UNSAFE_style} = props; let scale = useScale(); let renderer; @@ -135,7 +134,7 @@ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tr gap: isDetached ? 2 : 0 }}> - + { let { href } = props; - let {isDetached, isEmphasized, selectionStyle, highlightMode} = useContext(InternalTreeContext); + let {isDetached, isEmphasized, selectionStyle, selectionCornerStyle} = useContext(InternalTreeContext); return ( { isLink: !!href, isEmphasized, selectionStyle, - highlightMode - }) + (renderProps.isFocusVisible && !isDetached && selectionStyle !== 'highlight' ? ' ' + treeRowFocusIndicator : '')} /> + isRound: selectionCornerStyle === 'round' + }) + (renderProps.isFocusVisible && !isDetached ? ' ' + treeRowFocusIndicator : '')} /> ); }; @@ -429,7 +471,7 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode let { children } = props; - let {isDetached, isEmphasized, selectionStyle, selectionCornerStyle, highlightMode} = useContext(InternalTreeContext); + let {isDetached, isEmphasized, selectionCornerStyle} = useContext(InternalTreeContext); let scale = useScale(); return ( @@ -447,16 +489,10 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode isNextSelected = state.selectionManager.isSelected(keyAfter); } let isFirst = state.collection.getFirstKey() === id; - let isRoundTop = false; - let isRoundBottom = false; - if (selectionStyle === 'highlight' && selectionCornerStyle === 'round') { - isRoundTop = (isHovered && !isSelected) || (isSelected && !isPreviousSelected); - isRoundBottom = (isHovered && !isSelected) || (isSelected && !isNextSelected); - } + let isRound = selectionCornerStyle === 'round'; return ( -
-
+
{selectionMode !== 'none' && selectionBehavior === 'toggle' && ( // TODO: add transition?
@@ -479,7 +515,7 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode render: centerBaseline({slot: 'icon', styles: treeIcon}), styles: style({size: fontRelative(20), flexShrink: 0}) }], - [ActionButtonGroupContext, {styles: treeActions, size: 'S', staticColor: highlightMode === 'inverse' && isSelected ? 'white' : undefined}], + [ActionButtonGroupContext, {styles: treeActions, size: 'S'}], [ActionMenuContext, {styles: treeActionMenu, isQuiet: true, size: 'S'}] ]}> {typeof children === 'string' ? {children} : children} diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx index 057290d74ff..df4d1e7d695 100644 --- a/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx @@ -148,7 +148,6 @@ export const LayersTree: StoryObj = { selectionMode: 'multiple', selectionStyle: 'highlight', selectionCornerStyle: 'round', - highlightMode: 'inverse', isEmphasized: true } }; @@ -267,7 +266,6 @@ export const FileTree: StoryObj = { args: { selectionMode: 'multiple', selectionStyle: 'highlight', - highlightMode: 'inverse', isEmphasized: true } }; diff --git a/packages/@react-spectrum/s2/style/spectrum-theme.ts b/packages/@react-spectrum/s2/style/spectrum-theme.ts index 422d37bd360..a6fd9414134 100644 --- a/packages/@react-spectrum/s2/style/spectrum-theme.ts +++ b/packages/@react-spectrum/s2/style/spectrum-theme.ts @@ -612,7 +612,25 @@ export const style = createTheme({ pasteboard: weirdColorToken('background-pasteboard-color'), elevated: weirdColorToken('background-elevated-color') }), - borderColor: new SpectrumColorProperty('borderColor', { + borderStartColor: new SpectrumColorProperty('borderInlineStartColor', { + ...baseColors, + negative: colorToken('negative-border-color-default'), + disabled: colorToken('disabled-border-color'), + 'neutral-subdued': colorToken('neutral-subdued-content-color-default') + }), + borderTopColor: new SpectrumColorProperty('borderTopColor', { + ...baseColors, + negative: colorToken('negative-border-color-default'), + disabled: colorToken('disabled-border-color'), + 'neutral-subdued': colorToken('neutral-subdued-content-color-default') + }), + borderEndColor: new SpectrumColorProperty('borderInlineEndColor', { + ...baseColors, + negative: colorToken('negative-border-color-default'), + disabled: colorToken('disabled-border-color'), + 'neutral-subdued': colorToken('neutral-subdued-content-color-default') + }), + borderBottomColor: new SpectrumColorProperty('borderBottomColor', { ...baseColors, negative: colorToken('negative-border-color-default'), disabled: colorToken('disabled-border-color'), @@ -984,6 +1002,7 @@ export const style = createTheme({ borderBottomRadius: ['borderBottomStartRadius', 'borderBottomEndRadius'] as const, borderStartRadius: ['borderTopStartRadius', 'borderBottomStartRadius'] as const, borderEndRadius: ['borderTopEndRadius', 'borderBottomEndRadius'] as const, + borderColor: ['borderTopColor', 'borderBottomColor', 'borderStartColor', 'borderEndColor'] as const, translate: ['translateX', 'translateY'] as const, scale: ['scaleX', 'scaleY'] as const, inset: ['top', 'bottom', 'insetStart', 'insetEnd'] as const, diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index e010ca598c9..2adbb4950ae 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -350,6 +350,8 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function ...states, isFirstItem: item.key === state.collection.getFirstKey(), isLastItem: item.key === state.collection.getLastKey(), + isNextSelected: state.collection.getKeyAfter(item.key) !== null && state.selectionManager.isSelected(state.collection.getKeyAfter(item.key)!) || undefined, + isPrevSelected: state.collection.getKeyBefore(item.key) !== null && state.selectionManager.isSelected(state.collection.getKeyBefore(item.key)!) || undefined, isHovered, isFocusVisible, selectionMode: state.selectionManager.selectionMode, From e5fc83d6eebb2f3e1231fd0d2b28aebaa6f40b72 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 21 Oct 2025 17:12:24 +1100 Subject: [PATCH 11/16] fix lint --- packages/react-aria-components/src/GridList.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 2adbb4950ae..672c0e7bb97 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -274,7 +274,9 @@ function GridListInner({props, collection, gridListRef: ref}: export interface GridListItemRenderProps extends ItemRenderProps { isFirstItem: boolean, - isLastItem: boolean + isLastItem: boolean, + isNextSelected: boolean, + isPrevSelected: boolean } export interface GridListItemProps extends RenderProps, LinkDOMProps, HoverEvents, PressEvents, Omit, 'onClick'> { @@ -350,8 +352,8 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function ...states, isFirstItem: item.key === state.collection.getFirstKey(), isLastItem: item.key === state.collection.getLastKey(), - isNextSelected: state.collection.getKeyAfter(item.key) !== null && state.selectionManager.isSelected(state.collection.getKeyAfter(item.key)!) || undefined, - isPrevSelected: state.collection.getKeyBefore(item.key) !== null && state.selectionManager.isSelected(state.collection.getKeyBefore(item.key)!) || undefined, + isNextSelected: state.collection.getKeyAfter(item.key) !== null && state.selectionManager.isSelected(state.collection.getKeyAfter(item.key)!) || false, + isPrevSelected: state.collection.getKeyBefore(item.key) !== null && state.selectionManager.isSelected(state.collection.getKeyBefore(item.key)!) || false, isHovered, isFocusVisible, selectionMode: state.selectionManager.selectionMode, From f75c75a39c78f683ea49181a220b852458ede0d6 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 22 Oct 2025 13:05:51 +1100 Subject: [PATCH 12/16] update styles for listview and treeview --- packages/@react-spectrum/s2/src/ListView.tsx | 74 +++++++--- packages/@react-spectrum/s2/src/TreeView.tsx | 135 +++++++++--------- .../HighlightSelectionTree.stories.tsx | 1 + 3 files changed, 129 insertions(+), 81 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 62a14080154..19bd11644c3 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -190,41 +190,80 @@ const listitem = style +
{typeof children === 'string' ? {children} : children} ); diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index c2bc05d4ec5..fa0199e2801 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -40,7 +40,6 @@ import {ImageContext} from './Image'; // @ts-ignore 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, useContext, useRef} from 'react'; import {Text, TextContext} from './Content'; import {useDOMRef} from '@react-spectrum/utils'; @@ -99,6 +98,7 @@ const tree = style({ height: 'full', overflow: 'auto', boxSizing: 'border-box', + padding: 4, justifyContent: { isEmpty: 'center' }, @@ -180,25 +180,18 @@ const rowBackgroundColor = { } }, highlight: { - default: '--s2-container-bg', - isHovered: 'gray-100', - isPressed: 'gray-100', - isSelected: { - default: 'gray-100', - isEmphasized: 'blue-900/10' - }, - forcedColors: { - default: 'Background' - } + default: 'transparent' } } } as const; const treeRow = style({ + ...focusRing(), + outlineOffset: 2, position: 'relative', display: 'flex', height: 40, - width: 'full', + width: 'calc(100% - 24px)', boxSizing: 'border-box', font: 'ui', color: 'body', @@ -250,9 +243,9 @@ const treeCellGrid = style({ display: 'grid', width: 'full', height: 'full', - boxSizing: 'border-box', alignContent: 'center', alignItems: 'center', + boxSizing: 'border-box', gridTemplateColumns: ['auto', 'auto', 'auto', 'auto', 'auto', '1fr', 'minmax(0, auto)', 'auto'], gridTemplateRows: '1fr', gridTemplateAreas: [ @@ -298,7 +291,6 @@ const treeCellGrid = style({ forcedColors: 'Highlight' } }, - backgroundColor: '--rowBackgroundColor', '--borderColor': { type: 'borderTopColor', value: { @@ -306,56 +298,70 @@ const treeCellGrid = style({ isSelected: 'blue-900', forcedColors: 'ButtonBorder' } + } +}); + +const treeRowBackground = style({ + position: 'absolute', + zIndex: -1, + inset: 0, + backgroundColor: { + default: '--rowBackgroundColor', + isHovered: 'gray-900/5', + isPressed: 'gray-900/10', + isSelected: { + default: 'blue-900/10', + isHovered: 'blue-900/15', + isPressed: 'blue-900/15' + }, + forcedColors: { + default: 'Background' + } }, - borderWidth: 1, - borderStyle: 'solid', borderTopStartRadius: { default: '--borderRadiusTreeItem', - isPreviousSelected: 'none', + isPreviousSelected: { + default: '--borderRadiusTreeItem', + isSelected: 'none' + }, isDetached: 'default' }, borderTopEndRadius: { default: '--borderRadiusTreeItem', - isPreviousSelected: 'none', + isPreviousSelected: { + default: '--borderRadiusTreeItem', + isSelected: 'none' + }, isDetached: 'default' }, borderBottomStartRadius: { default: '--borderRadiusTreeItem', - isNextSelected: 'none', + isNextSelected: { + default: '--borderRadiusTreeItem', + isSelected: 'none' + }, isDetached: 'default' }, borderBottomEndRadius: { default: '--borderRadiusTreeItem', - isNextSelected: 'none', + isNextSelected: { + default: '--borderRadiusTreeItem', + isSelected: 'none' + }, isDetached: 'default' }, - borderTopColor: { - default: 'transparent', - isSelected: '--borderColor', - isPreviousSelected: 'transparent', - isDetached: { - default: 'transparent', - isSelected: '--rowSelectedBorderColor' - } - }, - borderBottomColor: { - default: 'transparent', - isSelected: '--borderColor', - isNextSelected: 'transparent', - isDetached: { - default: 'transparent', - isSelected: '--rowSelectedBorderColor' - } + borderTopWidth: { + default: 1, + isPreviousSelected: 0 }, - borderStartColor: { - default: 'transparent', - isSelected: '--borderColor', - isDetached: { - default: 'transparent', - isSelected: '--rowSelectedBorderColor' - } + borderBottomWidth: { + default: 1, + isNextSelected: 0 }, - borderEndColor: { + borderStartWidth: 1, + borderEndWidth: 1, + borderStyle: 'solid', + borderColor: { default: 'transparent', isSelected: '--borderColor', isDetached: { @@ -425,29 +431,29 @@ const cellFocus = { borderRadius: '[6px]' } as const; -const treeRowFocusIndicator = raw(` - &:before { - content: ""; - display: block; - position: absolute; - inset-inline-start: -4px; - inset-block-start: -4px; - inset-block-end: -4px; - inset-inline-end: -4px; - border-radius: var(--borderRadiusTreeItem); - border-width: 2px; - border-style: solid; - border-color: var(--rowFocusIndicatorColor); - z-index: 3; - pointer-events: none; - }` -); +// const treeRowFocusIndicator = raw(` +// &:before { +// content: ""; +// display: block; +// position: absolute; +// inset-inline-start: -4px; +// inset-block-start: -4px; +// inset-block-end: -4px; +// inset-inline-end: -4px; +// border-radius: var(--borderRadiusTreeItem); +// border-width: 2px; +// border-style: solid; +// border-color: var(--rowFocusIndicatorColor); +// z-index: 3; +// pointer-events: none; +// }` +// ); export const TreeViewItem = (props: TreeViewItemProps): ReactNode => { let { href } = props; - let {isDetached, isEmphasized, selectionStyle, selectionCornerStyle} = useContext(InternalTreeContext); + let {isEmphasized, selectionStyle, selectionCornerStyle} = useContext(InternalTreeContext); return ( { isEmphasized, selectionStyle, isRound: selectionCornerStyle === 'round' - }) + (renderProps.isFocusVisible && !isDetached ? ' ' + treeRowFocusIndicator : '')} /> + })} /> ); }; @@ -493,6 +499,7 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode return (
+
{selectionMode !== 'none' && selectionBehavior === 'toggle' && ( // TODO: add transition?
diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx index df4d1e7d695..39ee3532a88 100644 --- a/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx @@ -266,6 +266,7 @@ export const FileTree: StoryObj = { args: { selectionMode: 'multiple', selectionStyle: 'highlight', + selectionCornerStyle: 'square', isEmphasized: true } }; From 3fb6b67047474ad2fab742034dbed468681db223 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 22 Oct 2025 14:55:54 +1100 Subject: [PATCH 13/16] tableview --- packages/@react-spectrum/s2/src/TableView.tsx | 108 ++++++++++++------ .../HighlightSelectionTable.stories.tsx | 1 - packages/react-aria-components/src/Table.tsx | 16 +++ 3 files changed, 89 insertions(+), 36 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index a7ceceb4ef0..9bd91a6f5b8 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -107,8 +107,7 @@ interface S2TableProps { /** Provides the ActionBar to display when rows are selected in the TableView. */ renderActionBar?: (selectedKeys: 'all' | Set) => ReactElement, selectionStyle?: 'highlight' | 'checkbox', - isEmphasized?: boolean, - highlightMode?: 'normal' | 'inverse' + isEmphasized?: boolean } // TODO: Note that loadMore and loadingState are now on the Table instead of on the TableBody @@ -288,7 +287,6 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re onLoadMore, selectionStyle = 'checkbox', isEmphasized = false, - highlightMode = 'normal', ...otherProps } = props; @@ -315,9 +313,8 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re isInResizeMode, setIsInResizeMode, selectionStyle, - isEmphasized, - highlightMode - }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode, selectionStyle, isEmphasized, highlightMode]); + isEmphasized + }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode, selectionStyle, isEmphasized]); let scrollRef = useRef(null); let isCheckboxSelection = (props.selectionMode === 'multiple' || props.selectionMode === 'single') && selectionStyle === 'checkbox'; @@ -1014,26 +1011,9 @@ const cellContent = style({ default: -4, isSticky: 0 }, - color: { - highlightMode: { - inverse: { - isSelected: 'gray-25' - } - } - }, backgroundColor: { default: 'transparent', isSticky: '--rowBackgroundColor' - }, - fontWeight: { - selectionStyle: { - highlight: { - default: 'normal', - isRowHeader: { - isSelected: 'bold' - } - } - } } }); @@ -1122,15 +1102,8 @@ const rowBackgroundColor = { isHovered: 'gray-100', isPressed: 'gray-100', isSelected: { - default: 'gray-100', - highlightMode: { - normal: { - isEmphasized: 'blue-900/10' - }, - inverse: { - isEmphasized: 'blue-800' - } - } + default: 'blue-900/10', + isHovered: 'blue-900/15' }, forcedColors: { default: 'Background' @@ -1180,8 +1153,17 @@ const row = style({ // } // }, outlineStyle: 'none', - borderTopWidth: 0, - borderBottomWidth: 1, + borderTopWidth: { + default: 0, + isSelected: 0 + }, + borderBottomWidth: { + default: 1, + isSelected: { + default: 0, + isNextSelected: 1 + } + }, borderStartWidth: 0, borderEndWidth: 0, borderStyle: 'solid', @@ -1189,6 +1171,44 @@ const row = style({ default: 'gray-300', forcedColors: 'ButtonBorder' }, + '--rowSelectionIndicatorColor': { + type: 'borderTopColor', + value: { + default: 'gray-300', + isSelected: 'blue-900', + forcedColors: 'ButtonBorder' + } + }, + '--rowSelectionIndicatorBorderTopWidth': { + type: 'borderTopWidth', + value: { + default: 0, + isSelected: 1, + isPreviousSelected: 0 + } + }, + '--rowSelectionIndicatorBorderBottomWidth': { + type: 'borderBottomWidth', + value: { + default: 0, + isSelected: 1, + isNextSelected: 0 + } + }, + '--rowSelectionIndicatorBorderStartWidth': { + type: 'borderStartWidth', + value: { + default: 0, + isSelected: 1 + } + }, + '--rowSelectionIndicatorBorderEndWidth': { + type: 'borderEndWidth', + value: { + default: 0, + isSelected: 1 + } + }, forcedColorAdjust: 'none', color: { selectionStyle: { @@ -1201,6 +1221,22 @@ const row = style({ } }); +let rowSelectionIndicator = raw(` + &:before { + content: ""; + display: inline-block; + position: absolute; + inset: 0; + border-color: var(--rowSelectionIndicatorColor); + border-top-width: var(--rowSelectionIndicatorBorderTopWidth); + border-bottom-width: var(--rowSelectionIndicatorBorderBottomWidth); + border-inline-start-width: var(--rowSelectionIndicatorBorderStartWidth); + border-inline-end-width: var(--rowSelectionIndicatorBorderEndWidth); + border-style: solid; + z-index: 3; + }`); +let rowFocusIndicator = raw('&:after { content: ""; display: inline-block; position: sticky; inset-inline-start: 0; width: 3px; height: 100%; margin-inline-end: -3px; margin-block-end: 1px; z-index: 3; background-color: var(--rowFocusIndicatorColor)'); + export interface RowProps extends Pick, 'id' | 'columns' | 'children' | 'textValue' | 'dependencies' | keyof GlobalDOMAttributes> {} /** @@ -1220,7 +1256,9 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row row({ ...renderProps, ...tableVisualOptions - }) + (renderProps.isFocusVisible && ' ' + raw('&:before { content: ""; display: inline-block; position: sticky; inset-inline-start: 0; width: 3px; height: 100%; margin-inline-end: -3px; margin-block-end: 1px; z-index: 3; background-color: var(--rowFocusIndicatorColor)'))} + }) + + (renderProps.isSelected ? (' ' + rowSelectionIndicator) : '') + + (renderProps.isFocusVisible ? (' ' + rowFocusIndicator) : '')} {...otherProps}> {selectionMode !== 'none' && selectionBehavior === 'toggle' && tableVisualOptions.selectionStyle === 'checkbox' && ( diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx index 09e76f893a2..0f854194b8c 100644 --- a/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx @@ -116,7 +116,6 @@ export const DocumentsTable = { overflowMode: 'wrap', selectionStyle: 'highlight', selectionMode: 'multiple', - highlightMode: 'inverse', isEmphasized: true } }; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 497346964ef..eb6b9e4fd13 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1011,6 +1011,10 @@ export const TableBody = /*#__PURE__*/ createBranchComponent(TableBodyNode, Date: Thu, 23 Oct 2025 16:01:18 +1100 Subject: [PATCH 14/16] design updates --- packages/@react-spectrum/s2/src/ListView.tsx | 30 +++++++++++--------- packages/@react-spectrum/s2/src/TreeView.tsx | 5 ++-- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 19bd11644c3..9d27ccd838e 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -136,7 +136,9 @@ const listitem = style({ ...focusRing(), boxSizing: 'border-box', @@ -204,6 +206,18 @@ const listitem = style {(renderProps) => { diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index fa0199e2801..7cfc2fed256 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -98,7 +98,6 @@ const tree = style({ height: 'full', overflow: 'auto', boxSizing: 'border-box', - padding: 4, justifyContent: { isEmpty: 'center' }, @@ -187,11 +186,11 @@ const rowBackgroundColor = { const treeRow = style({ ...focusRing(), - outlineOffset: 2, + outlineOffset: -2, position: 'relative', display: 'flex', height: 40, - width: 'calc(100% - 24px)', + width: 'full', boxSizing: 'border-box', font: 'ui', color: 'body', From ccc682ec1ed765402c282dcbf2aefa7b4c84313c Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 24 Oct 2025 06:54:51 +1100 Subject: [PATCH 15/16] fix colors and add disabled items --- packages/@react-spectrum/s2/src/TreeView.tsx | 24 ++++++++++++++----- .../HighlightSelectionList.stories.tsx | 2 +- .../HighlightSelectionTable.stories.tsx | 2 +- .../HighlightSelectionTree.stories.tsx | 4 ++-- packages/react-aria-components/src/Tree.tsx | 8 +++++-- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 7cfc2fed256..af144ebe6aa 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -258,6 +258,14 @@ const treeCellGrid = style({ isDisabled: { default: 'gray-400', forcedColors: 'GrayText' + }, + selectionStyle: { + highlight: { + isSelectionDisabled: { + default: 'gray-400', + forcedColors: 'GrayText' + } + } } }, '--thumbnailBorderColor': { @@ -306,13 +314,16 @@ const treeRowBackground = style({ inset: 0, backgroundColor: { default: '--rowBackgroundColor', - isHovered: 'gray-900/5', - isPressed: 'gray-900/10', + isHovered: 'gray-100', + isPressed: 'gray-100', isSelected: { default: 'blue-900/10', isHovered: 'blue-900/15', isPressed: 'blue-900/15' }, + isDisabled: { + default: 'gray-100' + }, forcedColors: { default: 'Background' } @@ -476,12 +487,13 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode let { children } = props; - let {isDetached, isEmphasized, selectionCornerStyle} = useContext(InternalTreeContext); + let {isDetached, isEmphasized, selectionCornerStyle, selectionStyle} = useContext(InternalTreeContext); let scale = useScale(); return ( - {({isExpanded, hasChildItems, selectionMode, selectionBehavior, isDisabled, isFocusVisible, isSelected, id, state, isHovered}) => { + {({isExpanded, hasChildItems, selectionMode, selectionBehavior, isDisabled, isFocusVisible, isSelected, id, state, isHovered, isSelectionDisabled}) => { + console.log('isSelectionDisabled', isSelectionDisabled); let isNextSelected = false; let isNextFocused = false; let isPreviousSelected = false; @@ -497,8 +509,8 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode let isRound = selectionCornerStyle === 'round'; return ( -
-
+
+
{selectionMode !== 'none' && selectionBehavior === 'toggle' && ( // TODO: add transition?
diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx index aa6cae100e3..02a848016f0 100644 --- a/packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionList.stories.tsx @@ -64,7 +64,7 @@ let items: Item[] = [ export const AttributesList: StoryObj = { render: (args) => ( - + {item => ( {item.type === 'number' ? : } diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx index 0f854194b8c..4476cb3b5d4 100644 --- a/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionTable.stories.tsx @@ -76,7 +76,7 @@ let items: Item[] = [ export const DocumentsTable = { render: (args: TableViewProps): ReactElement => ( - + {(column) => ( {column.name} diff --git a/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx b/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx index 39ee3532a88..d7504847773 100644 --- a/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx +++ b/packages/@react-spectrum/s2/stories/HighlightSelectionTree.stories.tsx @@ -133,7 +133,7 @@ const TreeExampleLayersItem = (props: Omit & Tree const TreeExampleLayers = (args: TreeViewProps): ReactElement => (
- + {(item) => ( )} @@ -246,7 +246,7 @@ const TreeExampleFiles = (args: TreeViewProps): ReactEleme }; return (
- + {(item) => ( , /** The unique id of the tree row. */ - id: Key + id: Key, + /** Whether the tree item has its selection disabled. */ + isSelectionDisabled: boolean } export interface TreeItemContentRenderProps extends TreeItemRenderProps {} @@ -557,13 +559,14 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, Date: Fri, 24 Oct 2025 07:04:02 +1100 Subject: [PATCH 16/16] fix lint --- packages/react-aria-components/src/Tree.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index a29f494c891..5d4735b52fd 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -559,7 +559,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode,