From 957f3218bdd209e38746889bcbd04608f89073ea Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 21 Aug 2025 17:57:02 +1000 Subject: [PATCH 01/25] table inline editing --- .../s2/stories/TableView.stories.tsx | 301 +++++++++++++++++- 1 file changed, 297 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index c08479b0263..e6244f4a9ed 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -13,29 +13,41 @@ import {action} from '@storybook/addon-actions'; import { ActionButton, + Avatar, Cell, + CellProps, Column, + ColumnProps, Content, Heading, IllustratedMessage, Link, MenuItem, MenuSection, + NumberField, Row, + StatusLight, TableBody, TableHeader, TableView, TableViewProps, - Text + Text, + TextField } from '../src'; import {categorizeArgTypes} from './utils'; +import {colorScheme, getAllowedOverrides} from '../src/style-utils' with {type: 'macro'}; +import {DialogTrigger, Popover, SortDescriptor} from 'react-aria-components'; +import {DOMRef, Key} from '@react-types/shared'; +import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; +import {forwardRef, KeyboardEvent, ReactElement, useCallback, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; -import {ReactElement, useState} from 'react'; -import {SortDescriptor} from 'react-aria-components'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useAsyncList} from '@react-stately/data'; +import {useDOMRef} from '@react-spectrum/utils'; +import {useHover} from 'react-aria'; +import {useLayoutEffect} from '@react-aria/utils'; let onActionFunc = action('onAction'); let noOnAction = null; @@ -78,7 +90,7 @@ export default meta; const StaticTable = (args: any) => ( - Name + Name Type Date Modified Size @@ -1388,3 +1400,284 @@ const ResizableTable = () => { } } }; + +const editableCell = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: 'full', + height: 'full', + flexDirection: { + default: 'row', + isReversed: 'row-reverse' + } +}); + + +let popover = style({ + ...colorScheme(), + '--s2-container-bg': { + type: 'backgroundColor', + value: 'layer-2' + }, + backgroundColor: '--s2-container-bg', + borderRadius: 'default', + // Use box-shadow instead of filter when an arrow is not shown. + // This fixes the shadow stacking problem with submenus. + boxShadow: 'elevated', + borderStyle: 'solid', + borderWidth: 1, + borderColor: { + default: 'gray-200', + forcedColors: 'ButtonBorder' + }, + boxSizing: 'content-box', + isolation: 'isolate', + pointerEvents: { + isExiting: 'none' + }, + outlineStyle: 'none', + minWidth: '--trigger-width', + width: '--trigger-width', + padding: 8, + display: 'flex', + alignItems: 'center' +}, getAllowedOverrides()); + +let editButton = style({ + flexShrink: 0, + opacity: { + default: 0.001, + isShown: 1 + } +}); + +export const EditableCell = forwardRef(function EditableCell(props: Omit & {value: string, onChange: (value: string) => void}, ref: DOMRef) { + let {value, onChange, ...otherProps} = props; + let domRef = useDOMRef(ref); + let [isOpen, setIsOpen] = useState(false); + let [triggerWidth, setTriggerWidth] = useState(0); + let [verticalOffset, setVerticalOffset] = useState(0); + let [internalValue, setInternalValue] = useState(value); + let [editButtonFocused, setEditButtonFocused] = useState(false); + useLayoutEffect(() => { + let width = domRef.current?.clientWidth || 0; + let boundingRect = domRef.current?.getBoundingClientRect(); + let verticalOffset = (boundingRect?.top ?? 0) - (boundingRect?.bottom ?? 0); + setTriggerWidth(width); + setVerticalOffset(verticalOffset - 4); + }, [domRef]); + + let {isHovered, hoverProps} = useHover({}); + + let onKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + setIsOpen(false); + onChange(internalValue); + break; + case 'Escape': + setIsOpen(false); + setInternalValue(value); + break; + } + }; + + return ( +
+
+ {value} +
+ +
+ + + +
+ setEditButtonFocused(false)}> +
+ +
+
+
+
+ ); +}); + +export const EditableNumberCell = forwardRef(function EditableCell(props: Omit & {value: number, onChange: (value: number) => void}, ref: DOMRef) { + let {value, onChange, ...otherProps} = props; + let domRef = useDOMRef(ref); + let [isOpen, setIsOpen] = useState(false); + let [triggerWidth, setTriggerWidth] = useState(0); + let [verticalOffset, setVerticalOffset] = useState(0); + let [internalValue, setInternalValue] = useState(value); + let [editButtonFocused, setEditButtonFocused] = useState(false); + useLayoutEffect(() => { + let width = domRef.current?.clientWidth || 0; + let boundingRect = domRef.current?.getBoundingClientRect(); + let verticalOffset = (boundingRect?.top ?? 0) - (boundingRect?.bottom ?? 0); + setTriggerWidth(width); + setVerticalOffset(verticalOffset - 4); + }, [domRef]); + + let {isHovered, hoverProps} = useHover({}); + + let onKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + setIsOpen(false); + onChange(internalValue); + break; + case 'Escape': + setIsOpen(false); + setInternalValue(value); + break; + } + }; + + return ( +
+
+ {value} +
+ +
+ + + +
+ setEditButtonFocused(false)}> +
+ +
+
+
+
+ ); +}); + +let defaultItems = [ + {id: 1, fruits: 'Apples', task: 'Collect', status: 'Pending', farmer: 'Eva', count: 2}, + {id: 2, fruits: 'Oranges', task: 'Collect', status: 'Pending', farmer: 'Steven', count: 5}, + {id: 3, fruits: 'Pears', task: 'Collect', status: 'Pending', farmer: 'Michael', count: 10}, + {id: 4, fruits: 'Cherries', task: 'Collect', status: 'Pending', farmer: 'Sara', count: 12}, + {id: 5, fruits: 'Dates', task: 'Collect', status: 'Pending', farmer: 'Karina', count: 25}, + {id: 6, fruits: 'Bananas', task: 'Collect', status: 'Pending', farmer: 'Otto', count: 33}, + {id: 7, fruits: 'Melons', task: 'Collect', status: 'Pending', farmer: 'Matt', count: 42}, + {id: 8, fruits: 'Figs', task: 'Collect', status: 'Pending', farmer: 'Emily', count: 53}, + {id: 9, fruits: 'Blueberries', task: 'Collect', status: 'Pending', farmer: 'Amelia', count: 64}, + {id: 10, fruits: 'Blackberries', task: 'Collect', status: 'Pending', farmer: 'Isla', count: 78} +]; + +let editableColumns: Array & {name: string}> = [ + {name: 'Fruits', id: 'fruits', isRowHeader: true, width: '6fr'}, + {name: 'Task', id: 'task', width: '2fr'}, + {name: 'Status', id: 'status', width: '2fr', showDivider: true}, + {name: 'Farmer', id: 'farmer', width: '2fr'}, + {name: 'Count', id: 'count', allowsSorting: true, width: '1fr', align: 'end', minWidth: 95} +]; + +export const EditableTable = (args: TableViewProps): ReactElement => { + let [editableItems, setEditableItems] = useState(defaultItems); + let onChange = useCallback((value: any, id: Key, columnId: Key) => { + setEditableItems(prev => prev.map(i => i.id === id ? {...i, [columnId]: value} : i)); + }, []); + let [sortDescriptor, setSortDescriptor] = useState({column: 'count', direction: 'ascending'}); + let onSortChange = (sortDescriptor: SortDescriptor) => { + let {direction = 'ascending', column = 'count'} = sortDescriptor; + + setEditableItems(prev => { + return prev.slice().sort((a, b) => { + let cmp = Number(a[column]) < Number(b[column]) ? -1 : 1; + if (direction === 'descending') { + cmp *= -1; + } + return cmp; + }); + }); + setSortDescriptor(sortDescriptor); + }; + return ( + + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.id === 'count') { + return ( + + onChange(value, item.id, column.id!)} /> + + ); + } + if (column.id === 'fruits') { + return ( + + onChange(value, item.id, column.id!)} /> + + ); + } + if (column.id === 'farmer') { + return ( + +
{item[column.id]}
+
+ ); + } + if (column.id === 'status') { + return ( + + {item[column.id]} + + ); + } + return {item[column.id!]}; + }} +
+ )} +
+
+ ); +}; From 097ba50dec103a2ad91887c6d053ff2272f4a966 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 21 Aug 2025 18:01:36 +1000 Subject: [PATCH 02/25] remove extra exports --- packages/@react-spectrum/s2/stories/TableView.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index e6244f4a9ed..92632bd62b1 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -1452,7 +1452,7 @@ let editButton = style({ } }); -export const EditableCell = forwardRef(function EditableCell(props: Omit & {value: string, onChange: (value: string) => void}, ref: DOMRef) { +const EditableCell = forwardRef(function EditableCell(props: Omit & {value: string, onChange: (value: string) => void}, ref: DOMRef) { let {value, onChange, ...otherProps} = props; let domRef = useDOMRef(ref); let [isOpen, setIsOpen] = useState(false); @@ -1522,7 +1522,7 @@ export const EditableCell = forwardRef(function EditableCell(props: Omit & {value: number, onChange: (value: number) => void}, ref: DOMRef) { +const EditableNumberCell = forwardRef(function EditableCell(props: Omit & {value: number, onChange: (value: number) => void}, ref: DOMRef) { let {value, onChange, ...otherProps} = props; let domRef = useDOMRef(ref); let [isOpen, setIsOpen] = useState(false); From 98e8d84e6d6adae51cbf2b443f4f7ac6bb2a106b Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 25 Aug 2025 16:43:59 +1000 Subject: [PATCH 03/25] Add extra controls for different interactions, mobile, inline save, invalid state, click off --- .../s2/stories/TableView.stories.tsx | 346 +++++++++++++----- .../react-aria-components/src/Popover.tsx | 21 +- 2 files changed, 261 insertions(+), 106 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 92632bd62b1..dd0ca30bf7a 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -14,11 +14,13 @@ import {action} from '@storybook/addon-actions'; import { ActionButton, Avatar, + Button, Cell, CellProps, Column, ColumnProps, Content, + Dialog, Heading, IllustratedMessage, Link, @@ -35,18 +37,20 @@ import { TextField } from '../src'; import {categorizeArgTypes} from './utils'; +import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg'; +import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; import {colorScheme, getAllowedOverrides} from '../src/style-utils' with {type: 'macro'}; import {DialogTrigger, Popover, SortDescriptor} from 'react-aria-components'; import {DOMRef, Key} from '@react-types/shared'; import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; -import {forwardRef, KeyboardEvent, ReactElement, useCallback, useState} from 'react'; +import {forwardRef, KeyboardEvent, ReactElement, useCallback, useEffect, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useAsyncList} from '@react-stately/data'; import {useDOMRef} from '@react-spectrum/utils'; -import {useHover} from 'react-aria'; +import {useIsMobileDevice} from '../src/utils'; import {useLayoutEffect} from '@react-aria/utils'; let onActionFunc = action('onAction'); @@ -1438,7 +1442,6 @@ let popover = style({ }, outlineStyle: 'none', minWidth: '--trigger-width', - width: '--trigger-width', padding: 8, display: 'flex', alignItems: 'center' @@ -1452,14 +1455,37 @@ let editButton = style({ } }); -const EditableCell = forwardRef(function EditableCell(props: Omit & {value: string, onChange: (value: string) => void}, ref: DOMRef) { - let {value, onChange, ...otherProps} = props; +const EditableCell = forwardRef(function EditableCell(props: Omit & {value: string, onChange: (value: string) => void, showButtons?: boolean}, ref: DOMRef) { + let {value, onChange, showButtons = true, ...otherProps} = props; let domRef = useDOMRef(ref); let [isOpen, setIsOpen] = useState(false); let [triggerWidth, setTriggerWidth] = useState(0); let [verticalOffset, setVerticalOffset] = useState(0); let [internalValue, setInternalValue] = useState(value); let [editButtonFocused, setEditButtonFocused] = useState(false); + let isMobile = useIsMobileDevice(); + let [isHovered, setIsHovered] = useState(false); + + useEffect(() => { + if (domRef.current) { + let row = domRef.current.closest('[role="row"]'); + let onHover = () => { + setIsHovered(true); + }; + let onLeave = () => { + setIsHovered(false); + }; + if (row) { + row.addEventListener('pointerenter', onHover); + row.addEventListener('pointerleave', onLeave); + } + return () => { + row?.removeEventListener('pointerenter', onHover); + row?.removeEventListener('pointerleave', onLeave); + }; + } + }, [domRef]); + useLayoutEffect(() => { let width = domRef.current?.clientWidth || 0; let boundingRect = domRef.current?.getBoundingClientRect(); @@ -1468,17 +1494,31 @@ const EditableCell = forwardRef(function EditableCell(props: Omit 0); + + let validateAndCommit = () => { + if (internalValue.length > 0) { + setValid(true); + onChange(internalValue); + setIsOpen(false); + return true; + } + setValid(false); + return false; + }; + + let cancel = () => { + setIsOpen(false); + setInternalValue(value); + }; let onKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'Enter': - setIsOpen(false); - onChange(internalValue); + validateAndCommit(); break; case 'Escape': - setIsOpen(false); - setInternalValue(value); + cancel(); break; } }; @@ -1488,7 +1528,6 @@ const EditableCell = forwardRef(function EditableCell(props: Omit
-
+
- setEditButtonFocused(false)}> -
- -
-
+ {!isMobile ? ( + { + return validateAndCommit(); + }} + triggerRef={domRef} + aria-label="Edit cell" + offset={verticalOffset} + style={{minWidth: `${triggerWidth}px`}} + className={popover}> +
+ + {showButtons && ( +
+ + +
+ )} +
+
+ ) : ( + + {({close}) => ( + <> + Edit cell + +
+ +
+ + +
+
+
+ + )} +
+ )}
); }); -const EditableNumberCell = forwardRef(function EditableCell(props: Omit & {value: number, onChange: (value: number) => void}, ref: DOMRef) { - let {value, onChange, ...otherProps} = props; +const EditableNumberCell = forwardRef(function EditableCell(props: Omit & {value: number, onChange: (value: number) => void, showButtons?: boolean}, ref: DOMRef) { + let {value, onChange, showButtons = true, ...otherProps} = props; let domRef = useDOMRef(ref); let [isOpen, setIsOpen] = useState(false); let [triggerWidth, setTriggerWidth] = useState(0); let [verticalOffset, setVerticalOffset] = useState(0); let [internalValue, setInternalValue] = useState(value); let [editButtonFocused, setEditButtonFocused] = useState(false); + let [isHovered, setIsHovered] = useState(false); + + useEffect(() => { + if (domRef.current) { + let row = domRef.current.closest('[role="row"]'); + let onHover = () => { + setIsHovered(true); + }; + let onLeave = () => { + setIsHovered(false); + }; + if (row) { + row.addEventListener('pointerenter', onHover); + row.addEventListener('pointerleave', onLeave); + } + return () => { + row?.removeEventListener('pointerenter', onHover); + row?.removeEventListener('pointerleave', onLeave); + }; + } + }, [domRef]); + useLayoutEffect(() => { let width = domRef.current?.clientWidth || 0; let boundingRect = domRef.current?.getBoundingClientRect(); @@ -1538,7 +1645,22 @@ const EditableNumberCell = forwardRef(function EditableCell(props: Omit 0 && !Number.isNaN(internalValue)); + + let validateAndCommit = () => { + if (internalValue > 0 && !Number.isNaN(internalValue)) { + setValid(true); + onChange(internalValue); + setIsOpen(false); + return; + } + setValid(false); + }; + + let cancel = () => { + setIsOpen(false); + setInternalValue(value); + }; let onKeyDown = (e: KeyboardEvent) => { switch (e.key) { @@ -1558,7 +1680,6 @@ const EditableNumberCell = forwardRef(function EditableCell(props: Omit
setEditButtonFocused(false)}> -
- + style={{minWidth: `${triggerWidth}px`}} + className={popover}> +
+ + {showButtons && ( +
+ + +
+ )}
@@ -1614,70 +1747,87 @@ let editableColumns: Array & {name: string}> = [ {name: 'Count', id: 'count', allowsSorting: true, width: '1fr', align: 'end', minWidth: 95} ]; -export const EditableTable = (args: TableViewProps): ReactElement => { - let [editableItems, setEditableItems] = useState(defaultItems); - let onChange = useCallback((value: any, id: Key, columnId: Key) => { - setEditableItems(prev => prev.map(i => i.id === id ? {...i, [columnId]: value} : i)); - }, []); - let [sortDescriptor, setSortDescriptor] = useState({column: 'count', direction: 'ascending'}); - let onSortChange = (sortDescriptor: SortDescriptor) => { - let {direction = 'ascending', column = 'count'} = sortDescriptor; +let mobileColumns: Array & {name: string}> = [ + {name: 'Fruits', id: 'fruits', isRowHeader: true, width: '2fr', showDivider: true}, + {name: 'Farmer', id: 'farmer', width: '1fr'}, + {name: 'Count', id: 'count', allowsSorting: true, width: '1fr', align: 'end'} +]; - setEditableItems(prev => { - return prev.slice().sort((a, b) => { - let cmp = Number(a[column]) < Number(b[column]) ? -1 : 1; - if (direction === 'descending') { - cmp *= -1; - } - return cmp; +interface EditableTableProps extends TableViewProps { + showButtons?: boolean +} + +export const EditableTable: StoryObj = { + args: { + showButtons: true + }, + render: function EditableTable(args) { + let {showButtons, ...props} = args; + let isMobile = useIsMobileDevice(); + let [editableItems, setEditableItems] = useState(defaultItems); + let onChange = useCallback((value: any, id: Key, columnId: Key) => { + setEditableItems(prev => prev.map(i => i.id === id ? {...i, [columnId]: value} : i)); + }, []); + let [sortDescriptor, setSortDescriptor] = useState({column: 'count', direction: 'ascending'}); + let onSortChange = (sortDescriptor: SortDescriptor) => { + let {direction = 'ascending', column = 'count'} = sortDescriptor; + + setEditableItems(prev => { + return prev.slice().sort((a, b) => { + let cmp = Number(a[column]) < Number(b[column]) ? -1 : 1; + if (direction === 'descending') { + cmp *= -1; + } + return cmp; + }); }); - }); - setSortDescriptor(sortDescriptor); - }; - return ( - - - {(column) => ( - {column.name} - )} - - - {item => ( - - {(column) => { - if (column.id === 'count') { - return ( - - onChange(value, item.id, column.id!)} /> - - ); - } - if (column.id === 'fruits') { - return ( - - onChange(value, item.id, column.id!)} /> - - ); - } - if (column.id === 'farmer') { - return ( - -
{item[column.id]}
-
- ); - } - if (column.id === 'status') { - return ( - - {item[column.id]} - - ); - } - return {item[column.id!]}; - }} -
- )} -
-
- ); + setSortDescriptor(sortDescriptor); + }; + return ( + + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.id === 'count' && !isMobile) { + return ( + + onChange(value, item.id, column.id!)} /> + + ); + } + if (column.id === 'fruits') { + return ( + + onChange(value, item.id, column.id!)} /> + + ); + } + if (column.id === 'farmer') { + return ( + +
{item[column.id]}
+
+ ); + } + if (column.id === 'status') { + return ( + + {item[column.id]} + + ); + } + return {item[column.id!]}; + }} +
+ )} +
+
+ ); + } }; diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index 13ae55ab81b..79d91f36889 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -12,7 +12,7 @@ import {AriaLabelingProps, forwardRefType, GlobalDOMAttributes, RefObject} from '@react-types/shared'; import {AriaPopoverProps, DismissButton, Overlay, PlacementAxis, PositionProps, useLocale, usePopover} from 'react-aria'; -import {ContextValue, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils'; +import {ContextValue, Provider, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils'; import {filterDOMProps, mergeProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {OverlayArrowContext} from './OverlayArrow'; @@ -123,13 +123,18 @@ export const Popover = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pop } return ( - + + + ); }); From 7ea9be0d887eafa23c615ab4e3f64947f2104112 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 25 Aug 2025 16:52:48 +1000 Subject: [PATCH 04/25] fix lint --- .../s2/stories/TableView.stories.tsx | 85 +++++++++++-------- .../react-aria-components/src/Popover.tsx | 21 ++--- 2 files changed, 57 insertions(+), 49 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index dd0ca30bf7a..a88a68177b6 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -40,7 +40,7 @@ import {categorizeArgTypes} from './utils'; import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg'; import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; import {colorScheme, getAllowedOverrides} from '../src/style-utils' with {type: 'macro'}; -import {DialogTrigger, Popover, SortDescriptor} from 'react-aria-components'; +import {DialogTrigger, OverlayTriggerStateContext, Popover, Provider, SortDescriptor} from 'react-aria-components'; import {DOMRef, Key} from '@react-types/shared'; import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; @@ -1554,27 +1554,35 @@ const EditableCell = forwardRef(function EditableCell(props: Omit -
- - {showButtons && ( -
- - -
- )} -
+ +
+ + {showButtons && ( +
+ + +
+ )} +
+
) : ( {({close}) => ( - <> + Edit cell
@@ -1598,7 +1606,7 @@ const EditableCell = forwardRef(function EditableCell(props: Omit
- +
)}
)} @@ -1703,23 +1711,28 @@ const EditableNumberCell = forwardRef(function EditableCell(props: Omit -
- - {showButtons && ( -
- - -
- )} -
+ +
+ + {showButtons && ( +
+ + +
+ )} +
+
diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index 79d91f36889..13ae55ab81b 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -12,7 +12,7 @@ import {AriaLabelingProps, forwardRefType, GlobalDOMAttributes, RefObject} from '@react-types/shared'; import {AriaPopoverProps, DismissButton, Overlay, PlacementAxis, PositionProps, useLocale, usePopover} from 'react-aria'; -import {ContextValue, Provider, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils'; +import {ContextValue, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils'; import {filterDOMProps, mergeProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {OverlayArrowContext} from './OverlayArrow'; @@ -123,18 +123,13 @@ export const Popover = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pop } return ( - - - + ); }); From f447491254128327c709f253be468f2c9ca2bad9 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 25 Aug 2025 17:30:45 +1000 Subject: [PATCH 05/25] Add fake saving logic --- .../s2/stories/TableView.stories.tsx | 109 ++++++++++++++---- 1 file changed, 87 insertions(+), 22 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index a88a68177b6..690a4fb96f4 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -27,6 +27,7 @@ import { MenuItem, MenuSection, NumberField, + ProgressCircle, Row, StatusLight, TableBody, @@ -45,7 +46,7 @@ import {DOMRef, Key} from '@react-types/shared'; import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; -import {forwardRef, KeyboardEvent, ReactElement, useCallback, useEffect, useState} from 'react'; +import {forwardRef, KeyboardEvent, ReactElement, useCallback, useEffect, useRef, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useAsyncList} from '@react-stately/data'; @@ -1455,8 +1456,8 @@ let editButton = style({ } }); -const EditableCell = forwardRef(function EditableCell(props: Omit & {value: string, onChange: (value: string) => void, showButtons?: boolean}, ref: DOMRef) { - let {value, onChange, showButtons = true, ...otherProps} = props; +const EditableCell = forwardRef(function EditableCell(props: Omit & {value: string, onChange: (value: string) => void, showButtons?: boolean, isSaving?: boolean}, ref: DOMRef) { + let {value, onChange, showButtons = true, isSaving, ...otherProps} = props; let domRef = useDOMRef(ref); let [isOpen, setIsOpen] = useState(false); let [triggerWidth, setTriggerWidth] = useState(0); @@ -1466,6 +1467,11 @@ const EditableCell = forwardRef(function EditableCell(props: Omit { + // sync controlled value in case it's updated outside of this workflow + setInternalValue(value); + }, [value]); + useEffect(() => { if (domRef.current) { let row = domRef.current.closest('[role="row"]'); @@ -1523,6 +1529,27 @@ const EditableCell = forwardRef(function EditableCell(props: Omit | null>(null); + let wasSpinning = useRef(false); + useEffect(() => { + if (isSaving && !wasSpinning.current) { + wasSpinning.current = true; + timeout.current = setTimeout(() => { + setShowSpinner(true); + }, 5000); + } else if (!isSaving) { + wasSpinning.current = false; + setShowSpinner(false); + timeout.current && clearTimeout(timeout.current); + } + }, [isSaving]); + useEffect(() => { + return () => { + timeout.current && clearTimeout(timeout.current); + }; + }, []); + return (
- {value} + height: 'full', + alignItems: 'center', + color: { + isSaving: 'gray-500' + } + })({isSaving})}> +
+ {value} +
+ {isSaving && showSpinner && ( + + )}
- +
@@ -1560,6 +1596,7 @@ const EditableCell = forwardRef(function EditableCell(props: Omit
- +
From 9c6e6f5b89a47718d9a34b0bb1c5aa9b0248ecc8 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 25 Aug 2025 18:02:10 +1000 Subject: [PATCH 08/25] simplify fake save logic --- .../s2/stories/TableView.stories.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 66287dc14c3..5eae9d1b5dc 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -1472,6 +1472,7 @@ const EditableCell = forwardRef(function EditableCell(props: Omit { if (domRef.current) { let row = domRef.current.closest('[role="row"]'); @@ -1492,6 +1493,7 @@ const EditableCell = forwardRef(function EditableCell(props: Omit { let width = domRef.current?.clientWidth || 0; let boundingRect = domRef.current?.getBoundingClientRect(); @@ -1500,8 +1502,8 @@ const EditableCell = forwardRef(function EditableCell(props: Omit 0); - let validateAndCommit = () => { if (internalValue.length > 0) { setValid(true); @@ -1513,11 +1515,13 @@ const EditableCell = forwardRef(function EditableCell(props: Omit { setIsOpen(false); setInternalValue(value); }; + // Special keyboard shortcut handling let onKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'Enter': @@ -1826,25 +1830,24 @@ export const EditableTable: StoryObj = { } else { setEditableItems(prev => prev.map(i => i.id === id ? {...i, [columnId]: prevValue, isSaving: false} : i)); } - currentRequests.current = currentRequests.current.filter(r => r.id !== id); + currentRequests.current.delete(id); }, []); - let currentRequests = useRef, id: Key}>>([]); + let currentRequests = useRef, prevValue: any}>>(new Map()); let onChange = useCallback((value: any, id: Key, columnId: Key) => { - let alreadySaving = currentRequests.current.find(r => r.id === id); + let alreadySaving = currentRequests.current.get(id); if (alreadySaving) { - // remove and cancel the timeout - currentRequests.current = currentRequests.current.filter(r => r.id !== id); + // remove and cancel the previous request + currentRequests.current.delete(id); clearTimeout(alreadySaving.request); - return; } setEditableItems(prev => { let prevValue = prev.find(i => i.id === id)?.[columnId]; - let newItems = prev.map(i => i.id === id ? {...i, [columnId]: value, isSaving: true} : i); + let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value, isSaving: true} : i); // set a timeout between 0 and 10s let timeout = setTimeout(() => { - saveItem(id, columnId, prevValue); + saveItem(id, columnId, alreadySaving?.prevValue ?? prevValue); }, Math.random() * 10000); - currentRequests.current.push({request: timeout, id}); + currentRequests.current.set(id, {request: timeout, prevValue}); return newItems; }); }, []); From 6be039426b17abe1d952afe4100e4e5a49c5ad54 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 27 Aug 2025 12:33:21 +1000 Subject: [PATCH 09/25] set boundary element of the table, design updates --- .../overlays/src/calculatePosition.ts | 2 +- .../s2/stories/TableView.stories.tsx | 166 +++++++++++------- 2 files changed, 101 insertions(+), 67 deletions(-) diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index 5d78be36f62..d0a8ac7ab55 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -181,7 +181,7 @@ function getDelta( // Note that these values are with respect to the visual viewport (aka 0,0 is the top left of the viewport) let boundaryStartEdge = boundaryDimensions.scroll[AXIS[axis]] + padding; let boundaryEndEdge = boundarySize + boundaryDimensions.scroll[AXIS[axis]] - padding; - let startEdgeOffset = offset - containerScroll + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; + let startEdgeOffset = offset - containerScroll + containerOffsetWithBoundary[axis]; let endEdgeOffset = offset - containerScroll + size + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; // If any of the overlay edges falls outside of the boundary, shift the overlay the required amount to align one of the overlay's diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 5eae9d1b5dc..ee13757c0a4 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -41,13 +41,14 @@ import {categorizeArgTypes} from './utils'; import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg'; import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; import {colorScheme, getAllowedOverrides} from '../src/style-utils' with {type: 'macro'}; +import {CSSProperties, forwardRef, KeyboardEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {DialogTrigger, OverlayTriggerStateContext, Popover, Provider, SortDescriptor} from 'react-aria-components'; -import {DOMRef, Key} from '@react-types/shared'; +import {DOMRef, DOMRefValue, Key} from '@react-types/shared'; import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; -import {forwardRef, KeyboardEvent, ReactElement, useCallback, useEffect, useRef, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; +import {Placement, useFocusVisible} from 'react-aria'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useAsyncList} from '@react-stately/data'; import {useDOMRef} from '@react-spectrum/utils'; @@ -1412,6 +1413,8 @@ const editableCell = style({ justifyContent: 'space-between', width: 'full', height: 'full', + paddingX: 16, + marginX: -16, flexDirection: { default: 'row', isReversed: 'row-reverse' @@ -1426,7 +1429,7 @@ let popover = style({ value: 'layer-2' }, backgroundColor: '--s2-container-bg', - borderRadius: 'default', + borderBottomRadius: 'default', // Use box-shadow instead of filter when an arrow is not shown. // This fixes the shadow stacking problem with submenus. boxShadow: 'elevated', @@ -1456,16 +1459,18 @@ let editButton = style({ } }); -const EditableCell = forwardRef(function EditableCell(props: Omit & {value: string, onChange: (value: string) => void, showButtons?: boolean, isSaving?: boolean}, ref: DOMRef) { - let {value, onChange, showButtons = true, isSaving, ...otherProps} = props; +const EditableCell = forwardRef(function EditableCell(props: Omit & {value: string, onChange: (value: string) => void, showButtons?: boolean, isSaving?: boolean, placement?: Placement, tableRef?: DOMRefValue}, ref: DOMRef) { + let {value, onChange, showButtons = true, isSaving, placement = 'bottom', tableRef, ...otherProps} = props; let domRef = useDOMRef(ref); let [isOpen, setIsOpen] = useState(false); let [triggerWidth, setTriggerWidth] = useState(0); + let [tableWidth, setTableWidth] = useState(0); let [verticalOffset, setVerticalOffset] = useState(0); let [internalValue, setInternalValue] = useState(value); let [editButtonFocused, setEditButtonFocused] = useState(false); let isMobile = useIsMobileDevice(); let [isHovered, setIsHovered] = useState(false); + let {isFocusVisible} = useFocusVisible(); useEffect(() => { // sync controlled value in case it's updated outside of this workflow @@ -1498,9 +1503,12 @@ const EditableCell = forwardRef(function EditableCell(props: Omit 0); @@ -1581,7 +1589,7 @@ const EditableCell = forwardRef(function EditableCell(props: Omit -
+
@@ -1594,13 +1602,17 @@ const EditableCell = forwardRef(function EditableCell(props: Omit -
+
+ styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0, width: '--input-width'})} /> {showButtons && ( -
+
@@ -1636,9 +1648,8 @@ const EditableCell = forwardRef(function EditableCell(props: Omit -
+ onKeyDown={onKeyDown} /> +
+ + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + let placement: Placement = column.align === 'end' ? 'bottom right' : 'bottom left'; + if (column.id === 'count' && !isMobile) { + return ( + + {/* @ts-expect-error */} + onChange(value, item.id, column.id!)} /> + + ); + } + if (column.id === 'fruits') { + return ( + + {/* @ts-expect-error */} + onChange(value, item.id, column.id!)} /> + + ); + } + if (column.id === 'farmer') { + return ( + +
{item[column.id]}
+
+ ); + } + if (column.id === 'status') { + return ( + + {item[column.id]} + + ); + } + return {item[column.id!]}; + }} +
+ )} +
+
+
); } }; From a887e364ad92705ba39b181dcfe7b0beabd362da Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 28 Aug 2025 14:36:42 +1000 Subject: [PATCH 10/25] Add picker, restore focus to cell when trigger is hidden, converge implementation to make generic, fix density, individual cell saving state, use touch detection for showing all the time --- .../@react-aria/table/src/useTableCell.ts | 3 +- packages/@react-spectrum/s2/src/Picker.tsx | 7 + .../s2/stories/TableView.stories.tsx | 427 +++++++++--------- packages/react-aria-components/src/Table.tsx | 6 +- 4 files changed, 239 insertions(+), 204 deletions(-) diff --git a/packages/@react-aria/table/src/useTableCell.ts b/packages/@react-aria/table/src/useTableCell.ts index 1016cc13297..26acf77fb60 100644 --- a/packages/@react-aria/table/src/useTableCell.ts +++ b/packages/@react-aria/table/src/useTableCell.ts @@ -28,7 +28,8 @@ export interface AriaTableCellProps { * Please use onCellAction at the collection level instead. * @deprecated **/ - onAction?: () => void + onAction?: () => void, + focusMode?: 'cell' | 'child' } export interface TableCellAria { diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index cc450d41578..0eaf343a346 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -20,6 +20,7 @@ import { ButtonRenderProps, Collection, ContextValue, + DEFAULT_SLOT, ListBox, ListBoxItem, ListBoxItemProps, @@ -522,6 +523,11 @@ const PickerButton = createHideableComponent(function PickerButton & {value: string, onChange: (value: string) => void, showButtons?: boolean, isSaving?: boolean, placement?: Placement, tableRef?: DOMRefValue}, ref: DOMRef) { - let {value, onChange, showButtons = true, isSaving, placement = 'bottom', tableRef, ...otherProps} = props; +function EditableTrigger(props: {cellRef: DOMRefValue}) { + let {cellRef} = props; + let isFocused = useRef(false); + + useEffect(() => { + return () => { + if (isFocused.current) { + // @ts-expect-error + cellRef?.current?.parentElement?.parentElement?.focus(); + } + }; + }, []); + + return ( +
+ isFocused.current = e} isQuiet aria-label="Edit cell" styles={style({flexShrink: 0})}> + + +
+ ); +} + +interface EditableCellProps extends Omit { + value: T, + onChange: (value: T) => void, + showButtons?: boolean, + isSaving?: boolean, + tableRef: DOMRefValue, + isValid: (value: T) => boolean, + displayValue: (value: T) => ReactNode, + children: (props: any) => ReactElement, + align?: 'start' | 'center' | 'end', + density?: 'compact' | 'spacious' | 'regular', + valueKey?: string, + setValueKey?: string +} + +const EditableCell = (forwardRef as forwardRefType)(function EditableCell(props: EditableCellProps, ref: DOMRef) { + let {value, valueKey = 'value', setValueKey = 'onChange', onChange, showButtons = true, isSaving, tableRef, isValid, displayValue, children, align = 'start', density, ...otherProps} = props; let domRef = useDOMRef(ref); + let popoverRef = useRef(null); let [isOpen, setIsOpen] = useState(false); let [triggerWidth, setTriggerWidth] = useState(0); let [tableWidth, setTableWidth] = useState(0); let [verticalOffset, setVerticalOffset] = useState(0); - let [internalValue, setInternalValue] = useState(value); - let [editButtonFocused, setEditButtonFocused] = useState(false); - let isMobile = useIsMobileDevice(); + let [internalValue, _setInternalValue] = useState(value); + let [isRowFocused, setIsRowFocused] = useState(false); + let isMobile = !useMediaQuery('(any-pointer: fine)'); let [isHovered, setIsHovered] = useState(false); let {isFocusVisible} = useFocusVisible(); + let setInternalValue = (value: T) => { + _setInternalValue(value); + }; + useEffect(() => { // sync controlled value in case it's updated outside of this workflow setInternalValue(value); @@ -1487,13 +1528,23 @@ const EditableCell = forwardRef(function EditableCell(props: Omit { setIsHovered(false); }; + let onFocusIn = () => { + setIsRowFocused(true); + }; + let onFocusOut = () => { + setIsRowFocused(false); + }; if (row) { row.addEventListener('pointerenter', onHover); row.addEventListener('pointerleave', onLeave); + row.addEventListener('focusin', onFocusIn, {capture: true}); + row.addEventListener('focusout', onFocusOut, {capture: true}); } return () => { row?.removeEventListener('pointerenter', onHover); row?.removeEventListener('pointerleave', onLeave); + row?.removeEventListener('focusin', onFocusIn, {capture: true}); + row?.removeEventListener('focusout', onFocusOut, {capture: true}); }; } }, [domRef]); @@ -1501,19 +1552,28 @@ const EditableCell = forwardRef(function EditableCell(props: Omit { let width = domRef.current?.clientWidth || 0; - let boundingRect = domRef.current?.getBoundingClientRect(); + let boundingRect = domRef.current?.parentElement?.parentElement?.getBoundingClientRect(); let verticalOffset = (boundingRect?.top ?? 0) - (boundingRect?.bottom ?? 0); + if (density === 'compact') { + verticalOffset += 0; + } else if (density === 'spacious') { + verticalOffset += 8; + } else { + verticalOffset += 4; + } // @ts-expect-error let tableWidth = tableRef?.current?.UNSAFE_getDOMNode()?.clientWidth || 0; setTriggerWidth(width); - setVerticalOffset(verticalOffset - 4); + setVerticalOffset(verticalOffset); setTableWidth(tableWidth); - }, [domRef, tableRef]); + }, [domRef, tableRef, density]); // Validation, save if valid, otherwise error message is shown and popover remains open - let [valid, setValid] = useState(value.length > 0); + let [valid, setValid] = useState(isValid(value)); + let validateAndCommit = () => { - if (internalValue.length > 0) { + console.log('validateAndCommit', internalValue); + if (isValid(internalValue)) { setValid(true); onChange(internalValue); setIsOpen(false); @@ -1562,11 +1622,15 @@ const EditableCell = forwardRef(function EditableCell(props: Omit { + return isHovered || isOpen || (isRowFocused && isFocusVisible) || isMobile; + }, [isHovered, isOpen, isRowFocused, isFocusVisible, isMobile]); + return (
-
- {value} + })({isSaving, align})}> +
+ {displayValue(value)}
{isSaving && showSpinner && ( )}
-
- - - -
+ {/** Ignore the extra pressable, this is not real code, just want the warnings to stop. */} + {/* @ts-expect-error */} + {isShown ? :
} {!isMobile ? ( { + ref={popoverRef} + shouldCloseOnInteractOutside={(e) => { + if (!popoverRef.current?.contains(document.activeElement)) { + return false; + } return validateAndCommit(); }} triggerRef={domRef} @@ -1606,22 +1692,23 @@ const EditableCell = forwardRef(function EditableCell(props: Omit
- + {children({ + 'aria-label': 'Edit cell', + autoFocus: true, + isInvalid: !valid, + errorMessage: 'Please enter a valid non empty value', + [valueKey]: internalValue, + [setValueKey]: setInternalValue, + onKeyDown: onKeyDown, + styles: style({flexGrow: 1, flexShrink: 1, minWidth: 0, width: '--input-width'}) + })} {showButtons && (
@@ -1641,14 +1728,16 @@ const EditableCell = forwardRef(function EditableCell(props: OmitEdit cell
- + {children({ + 'aria-label': 'Edit cell', + autoFocus: true, + isInvalid: !valid, + errorMessage: 'Please enter a valid non empty value', + value: internalValue, + onChange: setInternalValue, + onKeyDown: onKeyDown, + isMobile: true + })}
+ + + {({close}) => ( + <> + Alert + + + )} + + + + ); + + let button = getByRole('button'); + await user.click(button); + + let dialog = queryByRole('alertdialog'); + expect(dialog).toBeNull(); + }); }); From 9679410a4f6b64d5a4a2613896528092d35edfad Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 2 Sep 2025 15:41:53 +1000 Subject: [PATCH 17/25] Add bulk edit bar --- packages/@react-spectrum/s2/intl/en-US.json | 3 + packages/@react-spectrum/s2/src/ActionBar.tsx | 22 +- packages/@react-spectrum/s2/src/TableView.tsx | 5 +- .../s2/stories/TableView.stories.tsx | 234 ++++++++++++++++-- 4 files changed, 240 insertions(+), 24 deletions(-) diff --git a/packages/@react-spectrum/s2/intl/en-US.json b/packages/@react-spectrum/s2/intl/en-US.json index e6379d040e7..e212140b92f 100644 --- a/packages/@react-spectrum/s2/intl/en-US.json +++ b/packages/@react-spectrum/s2/intl/en-US.json @@ -1,7 +1,10 @@ { "actionbar.clearSelection": "Clear selection", "actionbar.selected": "{count, plural, =0 {None selected} other {# selected}}", + "actionbar.selectedEdited": "{count, plural, =0 {None selected} other {# selected}}{editedCount, plural, =0 {} other { and {editedCount} items edited}}", + "actionbar.edited": "{editedCount, plural, =0 {{editedCount} item edited} other { {editedCount} items edited}}", "actionbar.selectedAll": "All selected", + "actionbar.selectedAllEdited": "All selected and {editedCount} items edited", "actionbar.actions": "Actions", "actionbar.actionsAvailable": "Actions available.", "button.pending": "pending", diff --git a/packages/@react-spectrum/s2/src/ActionBar.tsx b/packages/@react-spectrum/s2/src/ActionBar.tsx index da8ff4ac558..cefa0d5b241 100644 --- a/packages/@react-spectrum/s2/src/ActionBar.tsx +++ b/packages/@react-spectrum/s2/src/ActionBar.tsx @@ -85,7 +85,8 @@ export interface ActionBarProps extends SlotProps { /** Handler that is called when the ActionBar clear button is pressed. */ onClearSelection?: () => void, /** A ref to the scrollable element the ActionBar appears above. */ - scrollRef?: RefObject + scrollRef?: RefObject, + editedItemCount?: number } export const ActionBarContext = createContext, DOMRefValue>>(null); @@ -94,7 +95,7 @@ export const ActionBar = forwardRef(function ActionBar(props: ActionBarProps, re [props, ref] = useSpectrumContextProps(props, ref, ActionBarContext); let domRef = useDOMRef(ref); - let isOpen = props.selectedItemCount !== 0; + let isOpen = props.selectedItemCount !== 0 || props.editedItemCount !== 0; let isExiting = useExitAnimation(domRef, isOpen && props.scrollRef != null); if (!isOpen && !isExiting) { return null; @@ -104,7 +105,7 @@ export const ActionBar = forwardRef(function ActionBar(props: ActionBarProps, re }); const ActionBarInner = forwardRef(function ActionBarInner(props: ActionBarProps & {isExiting: boolean}, ref: ForwardedRef) { - let {isEmphasized, selectedItemCount = 0, children, onClearSelection, isExiting} = props; + let {isEmphasized, selectedItemCount = 0, children, onClearSelection, isExiting, editedItemCount = 0} = props; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); // Store the last count greater than zero so that we can retain it while rendering the fade-out animation. @@ -152,6 +153,17 @@ const ActionBarInner = forwardRef(function ActionBarInner(props: ActionBarProps let objectRef = useObjectRef(ref); let isEntering = useEnterAnimation(objectRef, !!scrollRef); + let message = stringFormatter.format('actionbar.selected', {count: lastCount}); + if (editedItemCount > 0 && lastCount !== 'all' && lastCount > 0) { + message = stringFormatter.format('actionbar.selectedEdited', {count: lastCount, editedCount: editedItemCount}); + } else if (editedItemCount > 0 && lastCount !== 'all') { + message = stringFormatter.format('actionbar.edited', {editedCount: editedItemCount}); + } else if (editedItemCount > 0 && lastCount === 'all') { + message = stringFormatter.format('actionbar.selectedAllEdited', {editedCount: editedItemCount}); + } else if (lastCount === 'all') { + message = stringFormatter.format('actionbar.selectedAll'); + } + return (
onClearSelection?.()} /> - {lastCount === 'all' - ? stringFormatter.format('actionbar.selectedAll') - : stringFormatter.format('actionbar.selected', {count: lastCount})} + {message}
diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 27c8cb292ac..4fa6fb9a6ad 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -457,7 +457,8 @@ const cellFocus = { outlineOffset: -2, outlineWidth: 2, outlineColor: 'focus-ring', - borderRadius: '[6px]' + borderRadius: '[6px]', + pointerEvents: 'none' } as const; function CellFocusRing() { @@ -1035,8 +1036,8 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef {({isFocusVisible}) => ( <> - {isFocusVisible && } {children} + {isFocusVisible && } )} diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index bb9458617be..1727d234933 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -12,6 +12,7 @@ import {action} from '@storybook/addon-actions'; import { + ActionBar, ActionButton, Avatar, Button, @@ -41,7 +42,7 @@ import { import {categorizeArgTypes} from './utils'; import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg'; import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; -import {colorMix, style} from '../style/spectrum-theme' with {type: 'macro'}; +import {colorMix, lightDark, style} from '../style/spectrum-theme' with {type: 'macro'}; import {colorScheme, getAllowedOverrides} from '../src/style-utils' with {type: 'macro'}; import {CSSProperties, forwardRef, KeyboardEvent, ReactElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {DialogTrigger, OverlayTriggerStateContext, Popover, Provider, SortDescriptor} from 'react-aria-components'; @@ -1414,20 +1415,14 @@ const editableCell = style({ alignItems: 'center', justifyContent: 'space-between', width: 'full', - height: 'full', - paddingTop: 4, - '--paddingBottomVar': { - type: 'paddingBottom', - value: 4 - }, - '--marginBottomVar': { - type: 'marginBottom', - value: -4 + height: { + density: { + compact: '[30px]', + regular: '[38px]', + spacious: '[46px]' + } }, - paddingBottom: 'calc(var(--paddingBottomVar) - 1px)', paddingX: 36, - marginTop: -4, - marginBottom: 'calc(var(--marginBottomVar) - 1px)', marginX: -36, flexDirection: { default: 'row', @@ -1448,7 +1443,8 @@ const editableCell = style({ single: '--s2-container-bg', multiple: '--s2-container-bg' } - } + }, + isEdited: lightDark('yellow-300/15', 'yellow-300/50') } }); @@ -1520,6 +1516,7 @@ interface EditableCellProps extends Omit { onChange: (value: T) => void, showButtons?: boolean, isSaving?: boolean, + isEdited?: boolean, tableRef: DOMRefValue, isValid: (value: T) => boolean, displayValue: (value: T) => ReactNode, @@ -1532,7 +1529,7 @@ interface EditableCellProps extends Omit { } const EditableCell = (forwardRef as forwardRefType)(function EditableCell(props: EditableCellProps, ref: DOMRef) { - let {value, valueKey = 'value', setValueKey = 'onChange', onChange, showButtons = true, isSaving, tableRef, isValid, displayValue, children, align = 'start', density, selectionMode, ...otherProps} = props; + let {value, valueKey = 'value', setValueKey = 'onChange', onChange, showButtons = true, isSaving, tableRef, isValid, displayValue, children, align = 'start', density = 'regular', selectionMode, isEdited, ...otherProps} = props; let domRef = useDOMRef(ref); let popoverRef = useRef(null); let [isOpen, setIsOpen] = useState(false); @@ -1652,7 +1649,7 @@ const EditableCell = (forwardRef as forwardRefType)(function EditableCell
= { ); } }; + + +export const EditableTableWithBulk: StoryObj = { + args: { + showButtons: true + }, + render: function EditableTableWithBulk(args) { + let selectionMode = args.selectionMode ?? 'none' as SelectionMode; + let {showButtons, ...props} = args; + let isMobile = useIsMobileDevice(); + let tableRef = useRef>(null); + let [editedItems, setEditedItems] = useState>(new Map()); + let [editableItems, setEditableItems] = useState(defaultItems); + let onChange = useCallback((value: any, id: Key, columnId: Key) => { + setEditedItems(prev => { + let newItems = new Map(prev); + let existingEdit = newItems.get(id); + if (existingEdit) { + newItems.set(id, { + ...existingEdit, + [columnId]: value + }); + } else { + newItems.set(id, { + [columnId]: value + }); + } + return newItems; + }); + }, []); + let [sortDescriptor, setSortDescriptor] = useState({column: 'count', direction: 'ascending'}); + let onSortChange = (sortDescriptor: SortDescriptor) => { + let {direction = 'ascending', column = 'count'} = sortDescriptor; + + setEditableItems(prev => { + return prev.slice().sort((a, b) => { + let cmp = Number(a[column]) < Number(b[column]) ? -1 : 1; + if (direction === 'descending') { + cmp *= -1; + } + return cmp; + }); + }); + setSortDescriptor(sortDescriptor); + }; + + let [fruitWidth, setFruitWidth] = useState('6fr'); + let columns = useMemo(() => { + return isMobile ? mobileColumns : editableColumns.map(column => { + if (column.id === 'fruits') { + column.width = fruitWidth; + } + return {...column}; + }); + }, [isMobile, fruitWidth]); + + let saveItems = useCallback(() => { + setEditableItems(prev => { + return prev.map(item => { + if (editedItems.has(item.id)) { + return {...item, ...editedItems.get(item.id)}; + } + return item; + }); + }); + setEditedItems(new Map()); + }, [editedItems]); + + return ( +
+ + ( + + setEditedItems(new Map())}>Reset + Save + + )} + key={fruitWidth} + ref={tableRef} + aria-label="Dynamic table" + {...props} + sortDescriptor={sortDescriptor} + onSortChange={onSortChange} + styles={style({width: {default: 800, isMobile: 'calc(100vw - 32px)'}, height: 208})({isMobile})}> + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.id === 'count' && !isMobile) { + return ( + + value.toString()} + isValid={value => value > 0 && !Number.isNaN(value)} + // @ts-expect-error + tableRef={tableRef} + isEdited={!!editedItems.get(item.id)?.[column.id]} + showButtons={showButtons} + value={editedItems.get(item.id)?.[column.id] ?? item[column.id]} + density={props.density} + onChange={value => onChange(value, item.id, column.id!)}> + {(props) => ( + + )} + + + ); + } + if (column.id === 'fruits') { + return ( + + value.toString()} + isValid={value => value.length > 0} + // @ts-expect-error + tableRef={tableRef} + isEdited={!!editedItems.get(item.id)?.[column.id]} + showButtons={showButtons} + value={editedItems.get(item.id)?.[column.id] ?? item[column.id]} + density={props.density} + onChange={value => onChange(value, item.id, column.id!)}> + {(props) => ( + + )} + + + ); + } + if (column.id === 'farmer') { + return ( + + { + return ( +
+ + {value} +
+ ); + }} + isValid={() => true} + // @ts-expect-error + tableRef={tableRef} + valueKey="selectedKey" + setValueKey="onSelectionChange" + isEdited={!!editedItems.get(item.id)?.[column.id]} + showButtons={showButtons} + value={editedItems.get(item.id)?.[column.id] ?? item[column.id]} + density={props.density} + onChange={value => onChange(value, item.id, column.id!)}> + {(props) => { + return ( + + Eva + Steven + Michael + Sara + Karina + Otto + Matt + Emily + Amelia + Isla + + ); + }} +
+
+ ); + } + if (column.id === 'status') { + return ( + + {item[column.id]} + + ); + } + return {item[column.id!]}; + }} +
+ )} +
+
+
+ ); + } +}; + From 2a80b8bd9db0a9bfb2a48d389ac9c67ec54f8e50 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 2 Sep 2025 15:49:13 +1000 Subject: [PATCH 18/25] fix lint --- packages/react-aria-components/test/Button.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/test/Button.test.js b/packages/react-aria-components/test/Button.test.js index af5c83a5257..48c76674573 100644 --- a/packages/react-aria-components/test/Button.test.js +++ b/packages/react-aria-components/test/Button.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {Button, ButtonContext, Dialog, DialogTrigger, Heading, Modal, ProgressBar, Text} from '../'; import React, {useState} from 'react'; import userEvent from '@testing-library/user-event'; @@ -366,7 +366,7 @@ describe('Button', () => { }); it('disables press when in pending state for context', async function () { - let {getByRole,queryByRole} = render( + let {getByRole, queryByRole} = render( From b02b164779605bfe652869db1ef68c5bf517c2f8 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 4 Sep 2025 15:28:06 +1000 Subject: [PATCH 19/25] add "More" actions and remove actionbar bulk actions --- packages/@react-spectrum/s2/intl/en-US.json | 3 - packages/@react-spectrum/s2/src/ActionBar.tsx | 22 +- .../s2/stories/TableView.stories.tsx | 221 +----------------- 3 files changed, 16 insertions(+), 230 deletions(-) diff --git a/packages/@react-spectrum/s2/intl/en-US.json b/packages/@react-spectrum/s2/intl/en-US.json index e212140b92f..e6379d040e7 100644 --- a/packages/@react-spectrum/s2/intl/en-US.json +++ b/packages/@react-spectrum/s2/intl/en-US.json @@ -1,10 +1,7 @@ { "actionbar.clearSelection": "Clear selection", "actionbar.selected": "{count, plural, =0 {None selected} other {# selected}}", - "actionbar.selectedEdited": "{count, plural, =0 {None selected} other {# selected}}{editedCount, plural, =0 {} other { and {editedCount} items edited}}", - "actionbar.edited": "{editedCount, plural, =0 {{editedCount} item edited} other { {editedCount} items edited}}", "actionbar.selectedAll": "All selected", - "actionbar.selectedAllEdited": "All selected and {editedCount} items edited", "actionbar.actions": "Actions", "actionbar.actionsAvailable": "Actions available.", "button.pending": "pending", diff --git a/packages/@react-spectrum/s2/src/ActionBar.tsx b/packages/@react-spectrum/s2/src/ActionBar.tsx index cefa0d5b241..da8ff4ac558 100644 --- a/packages/@react-spectrum/s2/src/ActionBar.tsx +++ b/packages/@react-spectrum/s2/src/ActionBar.tsx @@ -85,8 +85,7 @@ export interface ActionBarProps extends SlotProps { /** Handler that is called when the ActionBar clear button is pressed. */ onClearSelection?: () => void, /** A ref to the scrollable element the ActionBar appears above. */ - scrollRef?: RefObject, - editedItemCount?: number + scrollRef?: RefObject } export const ActionBarContext = createContext, DOMRefValue>>(null); @@ -95,7 +94,7 @@ export const ActionBar = forwardRef(function ActionBar(props: ActionBarProps, re [props, ref] = useSpectrumContextProps(props, ref, ActionBarContext); let domRef = useDOMRef(ref); - let isOpen = props.selectedItemCount !== 0 || props.editedItemCount !== 0; + let isOpen = props.selectedItemCount !== 0; let isExiting = useExitAnimation(domRef, isOpen && props.scrollRef != null); if (!isOpen && !isExiting) { return null; @@ -105,7 +104,7 @@ export const ActionBar = forwardRef(function ActionBar(props: ActionBarProps, re }); const ActionBarInner = forwardRef(function ActionBarInner(props: ActionBarProps & {isExiting: boolean}, ref: ForwardedRef) { - let {isEmphasized, selectedItemCount = 0, children, onClearSelection, isExiting, editedItemCount = 0} = props; + let {isEmphasized, selectedItemCount = 0, children, onClearSelection, isExiting} = props; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); // Store the last count greater than zero so that we can retain it while rendering the fade-out animation. @@ -153,17 +152,6 @@ const ActionBarInner = forwardRef(function ActionBarInner(props: ActionBarProps let objectRef = useObjectRef(ref); let isEntering = useEnterAnimation(objectRef, !!scrollRef); - let message = stringFormatter.format('actionbar.selected', {count: lastCount}); - if (editedItemCount > 0 && lastCount !== 'all' && lastCount > 0) { - message = stringFormatter.format('actionbar.selectedEdited', {count: lastCount, editedCount: editedItemCount}); - } else if (editedItemCount > 0 && lastCount !== 'all') { - message = stringFormatter.format('actionbar.edited', {editedCount: editedItemCount}); - } else if (editedItemCount > 0 && lastCount === 'all') { - message = stringFormatter.format('actionbar.selectedAllEdited', {editedCount: editedItemCount}); - } else if (lastCount === 'all') { - message = stringFormatter.format('actionbar.selectedAll'); - } - return (
onClearSelection?.()} /> - {message} + {lastCount === 'all' + ? stringFormatter.format('actionbar.selectedAll') + : stringFormatter.format('actionbar.selected', {count: lastCount})}
diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 1727d234933..19b6e3ff90d 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -12,8 +12,8 @@ import {action} from '@storybook/addon-actions'; import { - ActionBar, ActionButton, + ActionMenu, Avatar, Button, Cell, @@ -42,7 +42,7 @@ import { import {categorizeArgTypes} from './utils'; import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg'; import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; -import {colorMix, lightDark, style} from '../style/spectrum-theme' with {type: 'macro'}; +import {colorMix, style} from '../style/spectrum-theme' with {type: 'macro'}; import {colorScheme, getAllowedOverrides} from '../src/style-utils' with {type: 'macro'}; import {CSSProperties, forwardRef, KeyboardEvent, ReactElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {DialogTrigger, OverlayTriggerStateContext, Popover, Provider, SortDescriptor} from 'react-aria-components'; @@ -1443,8 +1443,7 @@ const editableCell = style({ single: '--s2-container-bg', multiple: '--s2-container-bg' } - }, - isEdited: lightDark('yellow-300/15', 'yellow-300/50') + } } }); @@ -1529,7 +1528,7 @@ interface EditableCellProps extends Omit { } const EditableCell = (forwardRef as forwardRefType)(function EditableCell(props: EditableCellProps, ref: DOMRef) { - let {value, valueKey = 'value', setValueKey = 'onChange', onChange, showButtons = true, isSaving, tableRef, isValid, displayValue, children, align = 'start', density = 'regular', selectionMode, isEdited, ...otherProps} = props; + let {value, valueKey = 'value', setValueKey = 'onChange', onChange, showButtons = true, isSaving, tableRef, isValid, displayValue, children, align = 'start', density = 'regular', selectionMode, ...otherProps} = props; let domRef = useDOMRef(ref); let popoverRef = useRef(null); let [isOpen, setIsOpen] = useState(false); @@ -1649,7 +1648,7 @@ const EditableCell = (forwardRef as forwardRefType)(function EditableCell
= { {...props} /> )} + + Cut + Copy + Paste + ); } @@ -1993,208 +1997,3 @@ export const EditableTable: StoryObj = { ); } }; - - -export const EditableTableWithBulk: StoryObj = { - args: { - showButtons: true - }, - render: function EditableTableWithBulk(args) { - let selectionMode = args.selectionMode ?? 'none' as SelectionMode; - let {showButtons, ...props} = args; - let isMobile = useIsMobileDevice(); - let tableRef = useRef>(null); - let [editedItems, setEditedItems] = useState>(new Map()); - let [editableItems, setEditableItems] = useState(defaultItems); - let onChange = useCallback((value: any, id: Key, columnId: Key) => { - setEditedItems(prev => { - let newItems = new Map(prev); - let existingEdit = newItems.get(id); - if (existingEdit) { - newItems.set(id, { - ...existingEdit, - [columnId]: value - }); - } else { - newItems.set(id, { - [columnId]: value - }); - } - return newItems; - }); - }, []); - let [sortDescriptor, setSortDescriptor] = useState({column: 'count', direction: 'ascending'}); - let onSortChange = (sortDescriptor: SortDescriptor) => { - let {direction = 'ascending', column = 'count'} = sortDescriptor; - - setEditableItems(prev => { - return prev.slice().sort((a, b) => { - let cmp = Number(a[column]) < Number(b[column]) ? -1 : 1; - if (direction === 'descending') { - cmp *= -1; - } - return cmp; - }); - }); - setSortDescriptor(sortDescriptor); - }; - - let [fruitWidth, setFruitWidth] = useState('6fr'); - let columns = useMemo(() => { - return isMobile ? mobileColumns : editableColumns.map(column => { - if (column.id === 'fruits') { - column.width = fruitWidth; - } - return {...column}; - }); - }, [isMobile, fruitWidth]); - - let saveItems = useCallback(() => { - setEditableItems(prev => { - return prev.map(item => { - if (editedItems.has(item.id)) { - return {...item, ...editedItems.get(item.id)}; - } - return item; - }); - }); - setEditedItems(new Map()); - }, [editedItems]); - - return ( -
- - ( - - setEditedItems(new Map())}>Reset - Save - - )} - key={fruitWidth} - ref={tableRef} - aria-label="Dynamic table" - {...props} - sortDescriptor={sortDescriptor} - onSortChange={onSortChange} - styles={style({width: {default: 800, isMobile: 'calc(100vw - 32px)'}, height: 208})({isMobile})}> - - {(column) => ( - {column.name} - )} - - - {item => ( - - {(column) => { - if (column.id === 'count' && !isMobile) { - return ( - - value.toString()} - isValid={value => value > 0 && !Number.isNaN(value)} - // @ts-expect-error - tableRef={tableRef} - isEdited={!!editedItems.get(item.id)?.[column.id]} - showButtons={showButtons} - value={editedItems.get(item.id)?.[column.id] ?? item[column.id]} - density={props.density} - onChange={value => onChange(value, item.id, column.id!)}> - {(props) => ( - - )} - - - ); - } - if (column.id === 'fruits') { - return ( - - value.toString()} - isValid={value => value.length > 0} - // @ts-expect-error - tableRef={tableRef} - isEdited={!!editedItems.get(item.id)?.[column.id]} - showButtons={showButtons} - value={editedItems.get(item.id)?.[column.id] ?? item[column.id]} - density={props.density} - onChange={value => onChange(value, item.id, column.id!)}> - {(props) => ( - - )} - - - ); - } - if (column.id === 'farmer') { - return ( - - { - return ( -
- - {value} -
- ); - }} - isValid={() => true} - // @ts-expect-error - tableRef={tableRef} - valueKey="selectedKey" - setValueKey="onSelectionChange" - isEdited={!!editedItems.get(item.id)?.[column.id]} - showButtons={showButtons} - value={editedItems.get(item.id)?.[column.id] ?? item[column.id]} - density={props.density} - onChange={value => onChange(value, item.id, column.id!)}> - {(props) => { - return ( - - Eva - Steven - Michael - Sara - Karina - Otto - Matt - Emily - Amelia - Isla - - ); - }} -
-
- ); - } - if (column.id === 'status') { - return ( - - {item[column.id]} - - ); - } - return {item[column.id!]}; - }} -
- )} -
-
-
- ); - } -}; - From b2e8d7e789df1838bea8eb0f438e52df431e79d4 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 4 Sep 2025 16:59:29 +1000 Subject: [PATCH 20/25] fix rendering --- .../s2/stories/TableView.stories.tsx | 54 ++++++++++--------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 19b6e3ff90d..6e2ae80130d 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -1422,8 +1422,6 @@ const editableCell = style({ spacious: '[46px]' } }, - paddingX: 36, - marginX: -36, flexDirection: { default: 'row', isReversed: 'row-reverse' @@ -1597,7 +1595,9 @@ const EditableCell = (forwardRef as forwardRefType)(function EditableCell { let width = domRef.current?.clientWidth || 0; - let boundingRect = domRef.current?.parentElement?.parentElement?.getBoundingClientRect(); + // @ts-expect-error + let cell = domRef.current.closest('[role="rowheader"],[role="gridcell"]'); + let boundingRect = cell?.parentElement?.getBoundingClientRect(); let verticalOffset = (boundingRect?.top ?? 0) - (boundingRect?.bottom ?? 0); // @ts-expect-error @@ -1910,29 +1910,31 @@ export const EditableTable: StoryObj = { if (column.id === 'fruits') { return ( - value.toString()} - isValid={value => value.length > 0} - // @ts-expect-error - tableRef={tableRef} - isSaving={item.isSaving[column.id!]} - showButtons={showButtons} - value={item[column.id]} - density={props.density} - onChange={value => onChange(value, item.id, column.id!)}> - {(props) => ( - - )} - - - Cut - Copy - Paste - +
+ value.toString()} + isValid={value => value.length > 0} + // @ts-expect-error + tableRef={tableRef} + isSaving={item.isSaving[column.id!]} + showButtons={showButtons} + value={item[column.id]} + density={props.density} + onChange={value => onChange(value, item.id, column.id!)}> + {(props) => ( + + )} + + + Cut + Copy + Paste + +
); } From b56a69a6d2efebfe9ceb3c4276ff5c2d32610861 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 18 Sep 2025 10:30:54 +1000 Subject: [PATCH 21/25] Add other components so we know if there's anything else --- .../s2/stories/TableView.stories.tsx | 276 ++++++++++++++++-- 1 file changed, 254 insertions(+), 22 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 6e2ae80130d..27888a9dd69 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -18,9 +18,13 @@ import { Button, Cell, CellProps, + ColorSwatch, + ColorWheel, Column, ColumnProps, Content, + DatePicker, + DateRangePicker, Dialog, Heading, IllustratedMessage, @@ -30,6 +34,9 @@ import { NumberField, Picker, PickerItem, + Radio, + RadioGroup, + RangeSlider, Row, StatusLight, TableBody, @@ -37,8 +44,10 @@ import { TableView, TableViewProps, Text, + TextArea, TextField } from '../src'; +import {CalendarDate, getLocalTimeZone} from '@internationalized/date'; import {categorizeArgTypes} from './utils'; import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg'; import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; @@ -53,8 +62,8 @@ import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; import type {Meta, StoryObj} from '@storybook/react'; import {Pressable, useHover} from '@react-aria/interactions'; import {useAsyncList} from '@react-stately/data'; +import {useDateFormatter, useFocusVisible, useNumberFormatter} from 'react-aria'; import {useDOMRef, useMediaQuery} from '@react-spectrum/utils'; -import {useFocusVisible} from 'react-aria'; import {useIsMobileDevice} from '../src/utils'; import {useLayoutEffect} from '@react-aria/utils'; @@ -1416,10 +1425,15 @@ const editableCell = style({ justifyContent: 'space-between', width: 'full', height: { - density: { - compact: '[30px]', - regular: '[38px]', - spacious: '[46px]' + overflowMode: { + wrap: 'auto', + truncate: { + density: { + compact: '[30px]', + regular: '[38px]', + spacious: '[46px]' + } + } } }, flexDirection: { @@ -1722,7 +1736,7 @@ const EditableCell = (forwardRef as forwardRefType)(function EditableCell @@ -1775,24 +1789,90 @@ const EditableCell = (forwardRef as forwardRefType)(function EditableCell & {name: string}> = [ - {name: 'Fruits', id: 'fruits', isRowHeader: true, width: '6fr'}, - {name: 'Task', id: 'task', width: '2fr'}, - {name: 'Status', id: 'status', width: '2fr', showDivider: true}, - {name: 'Farmer', id: 'farmer', width: '2fr'}, - {name: 'Count', id: 'count', allowsSorting: true, width: '1fr', align: 'end', minWidth: 95} + {name: 'Fruits', id: 'fruits', isRowHeader: true, width: '6fr', minWidth: 300}, + {name: 'Task', id: 'task', width: '2fr', minWidth: 100}, + {name: 'Status', id: 'status', width: '2fr', showDivider: true, minWidth: 100}, + {name: 'Farmer', id: 'farmer', width: '2fr', minWidth: 150}, + {name: 'Count', id: 'count', allowsSorting: true, width: '1fr', align: 'end', minWidth: 95}, + {name: 'Deadline', id: 'deadline', width: '2fr', minWidth: 250}, + {name: 'Picking Season', id: 'pickingSeason', width: '2fr', minWidth: 400}, + {name: 'Color', id: 'color', width: '2fr', minWidth: 100}, + {name: 'Temperature Range', id: 'temperatureRange', width: '2fr', minWidth: 200}, + {name: 'Growing Season', id: 'growingSeason', width: '2fr', minWidth: 150}, + {name: 'Description', id: 'description', width: '2fr', minWidth: 150} ]; let mobileColumns: Array & {name: string}> = [ @@ -1807,7 +1887,7 @@ interface EditableTableProps extends TableViewProps { export const EditableTable: StoryObj = { args: { - showButtons: true + showButtons: false }, render: function EditableTable(args) { let selectionMode = args.selectionMode ?? 'none' as SelectionMode; @@ -1826,6 +1906,7 @@ export const EditableTable: StoryObj = { }, []); let currentRequests = useRef, prevValue: any}>>(new Map()); let onChange = useCallback((value: any, id: Key, columnId: Key) => { + console.log('onChange', value, id, columnId); let alreadySaving = currentRequests.current.get(id); if (alreadySaving) { // remove and cancel the previous request @@ -1868,6 +1949,8 @@ export const EditableTable: StoryObj = { return {...column}; }); }, [isMobile, fruitWidth]); + let dateFormatter = useDateFormatter({dateStyle: 'full'}); + let numberFormatter = useNumberFormatter({unit: 'celsius', style: 'unit'}); return (
@@ -1982,6 +2065,155 @@ export const EditableTable: StoryObj = { ); } + if (column.id === 'deadline') { + return ( + + dateFormatter.format(value.toDate(getLocalTimeZone()))} + isValid={() => true} + // @ts-expect-error + tableRef={tableRef} + isSaving={item.isSaving[column.id!]} + showButtons={showButtons} + value={item[column.id]} + density={props.density} + onChange={value => onChange(value, item.id, column.id!)}> + {(props) => { + return ( + + ); + }} + + + ); + } + if (column.id === 'pickingSeason') { + return ( + + dateFormatter.formatRange(value.start.toDate(getLocalTimeZone()), value.end.toDate(getLocalTimeZone()))} + isValid={() => true} + // @ts-expect-error + tableRef={tableRef} + isSaving={item.isSaving[column.id!]} + showButtons={showButtons} + value={item[column.id]} + density={props.density} + onChange={value => onChange(value, item.id, column.id!)}> + {(props) => { + return ( + + ); + }} + + + ); + } + if (column.id === 'color') { + return ( + + } + isValid={() => true} + // @ts-expect-error + tableRef={tableRef} + isSaving={item.isSaving[column.id!]} + showButtons={showButtons} + value={item[column.id]} + density={props.density} + onChange={value => onChange(value, item.id, column.id!)}> + {(props) => { + return ( + + ); + }} + + + ); + } + if (column.id === 'temperatureRange') { + return ( + + numberFormatter.formatRange(value.start, value.end)} + isValid={() => true} + // @ts-expect-error + tableRef={tableRef} + isSaving={item.isSaving[column.id!]} + showButtons={showButtons} + value={item[column.id]} + density={props.density} + onChange={value => onChange(value, item.id, column.id!)}> + {(props) => { + return ( + + ); + }} + + + ); + } + if (column.id === 'growingSeason') { + return ( + + value} + isValid={() => true} + // @ts-expect-error + tableRef={tableRef} + isSaving={item.isSaving[column.id!]} + showButtons={showButtons} + value={item[column.id]} + density={props.density} + onChange={value => onChange(value, item.id, column.id!)}> + {(props) => { + return ( + + Spring + Summer + Fall + Winter + + ); + }} + + + ); + } + if (column.id === 'description') { + return ( + +
{value}
} + isValid={() => true} + // @ts-expect-error + tableRef={tableRef} + isSaving={item.isSaving[column.id!]} + showButtons={showButtons} + value={item[column.id]} + density={props.density} + onChange={value => onChange(value, item.id, column.id!)}> + {(props) => { + return ( +