From 00e212aca6c505c26668225deba5b91e66ac7368 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 29 Oct 2025 10:55:03 +1100 Subject: [PATCH 1/7] fix EditableTable Cells from testing feedback --- packages/@react-spectrum/s2/src/TableView.tsx | 134 ++++++++++-------- .../s2/stories/TableView.stories.tsx | 43 +++--- .../s2/test/EditableTableView.test.tsx | 18 +-- packages/dev/s2-docs/pages/s2/TableView.mdx | 21 ++- 4 files changed, 109 insertions(+), 107 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 5bbaecf343b..2812e388d63 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -50,12 +50,14 @@ import { useTableOptions, Virtualizer } from 'react-aria-components'; +import {ButtonGroup} from './ButtonGroup'; import {centerPadding, colorScheme, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {Checkbox} from './Checkbox'; import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg'; import Chevron from '../ui-icons/Chevron'; import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; import {ColumnSize} from '@react-types/table'; +import {CustomDialog, DialogContainer} from '..'; import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LoadingState, Node} from '@react-types/shared'; import {getActiveElement, getOwnerDocument, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; @@ -70,8 +72,9 @@ import {raw} from '../style/style-macro' with {type: 'macro'}; import React, {createContext, CSSProperties, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg'; import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg'; +import {Button as SpectrumButton} from './Button'; import {useActionBarContainer} from './ActionBar'; -import {useDOMRef} from '@react-spectrum/utils'; +import {useDOMRef, useMediaQuery} from '@react-spectrum/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -1081,17 +1084,6 @@ const editableCell = style { /** Whether the cell is currently being saved. */ isSaving?: boolean, /** Handler that is called when the value has been changed and is ready to be saved. */ - onSubmit: () => void + onSubmit: (values: Record) => void } /** @@ -1229,6 +1221,8 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, setIsOpen(false); }; + let isMobile = !useMediaQuery('(any-pointer: fine)'); + return ( - { - if (!popoverRef.current?.contains(document.activeElement)) { + {!isMobile && ( + { + if (!popoverRef.current?.contains(document.activeElement)) { + return false; + } + formRef.current?.requestSubmit(); return false; - } - formRef.current?.requestSubmit(); - return false; - }} - triggerRef={cellRef} - aria-label={stringFormatter.format('table.editCell')} - offset={verticalOffset} - placement="bottom start" - style={{ - minWidth: `min(${triggerWidth}px, ${tableWidth}px)`, - maxWidth: `${tableWidth}px`, - // Override default z-index from useOverlayPosition. We use isolation: isolate instead. - zIndex: undefined - }} - className={editPopover}> - -
{ - e.preventDefault(); - onSubmit(); - setIsOpen(false); - }} - className={style({width: 'full', display: 'flex', alignItems: 'start', gap: 16})} - style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}> - {renderEditing()} -
- - -
-
-
-
+ }} + triggerRef={cellRef} + aria-label={props['aria-label'] ?? stringFormatter.format('table.editCell')} + offset={verticalOffset} + placement="bottom start" + style={{ + minWidth: `min(${triggerWidth}px, ${tableWidth}px)`, + maxWidth: `${tableWidth}px`, + // Override default z-index from useOverlayPosition. We use isolation: isolate instead. + zIndex: undefined + }} + className={editPopover}> + +
{ + e.preventDefault(); + let formData = new FormData(formRef.current as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onSubmit(values); + setIsOpen(false); + }} + className={style({width: 'full', display: 'flex', alignItems: 'start', gap: 16})} + style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}> + {renderEditing()} +
+ + +
+
+
+
+ )} + {isMobile && ( + setIsOpen(false)}> + {isOpen && ( + +
{ + e.preventDefault(); + let formData = new FormData(formRef.current as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onSubmit(values); + setIsOpen(false); + }} + className={style({width: 'full', display: 'flex', flexDirection: 'column', alignItems: 'start', gap: 16})}> + {renderEditing()} + + Cancel + Save + +
+
+ )} +
+ )}
); -} +}; // 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)); diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 2fd5b40815b..5cbdbbb69c9 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -1461,24 +1461,19 @@ export const EditableTable: StoryObj = { render: function EditableTable(props) { let columns = editableColumns; let [editableItems, setEditableItems] = useState(defaultItems); - let intermediateValue = useRef(null); - let onChange = useCallback((id: Key, columnId: Key) => { - let value = intermediateValue.current; + let onChange = useCallback((id: Key, columnId: Key, values: any) => { + // console.log(values); + let value = values[columnId]; if (value === null) { return; } - intermediateValue.current = null; setEditableItems(prev => { let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value} : i); return newItems; }); }, []); - let onIntermediateChange = useCallback((value: any) => { - intermediateValue.current = value; - }, []); - return (
@@ -1494,18 +1489,19 @@ export const EditableTable: StoryObj = { if (column.id === 'fruits') { return ( onChange(item.id, column.id!)} + onSubmit={(values) => onChange(item.id, column.id!, values)} isSaving={item.isSaving[column.id!]} renderEditing={() => ( value.length > 0 ? null : 'Fruit name is required'} styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})} - defaultValue={item[column.id!]} - onChange={value => onIntermediateChange(value)} /> + defaultValue={item[column.id!]} /> )}>
{item[column.id]} @@ -1520,7 +1516,7 @@ export const EditableTable: StoryObj = { onChange(item.id, column.id!)} + onSubmit={(values) => onChange(item.id, column.id!, values)} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { autoFocus styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})} defaultValue={item[column.id!]} - onChange={value => onIntermediateChange(value)}> + name={column.id! as string}> Eva Steven Michael @@ -1567,7 +1563,6 @@ export const EditableTableWithAsyncSaving: StoryObj = { render: function EditableTable(props) { let columns = editableColumns; let [editableItems, setEditableItems] = useState(defaultItems); - let intermediateValue = useRef(null); // Replace all of this with real API calls, this is purely demonstrative. let saveItem = useCallback((id: Key, columnId: Key, prevValue: any) => { @@ -1580,12 +1575,11 @@ export const EditableTableWithAsyncSaving: StoryObj = { currentRequests.current.delete(id); }, []); let currentRequests = useRef, prevValue: any}>>(new Map()); - let onChange = useCallback((id: Key, columnId: Key) => { - let value = intermediateValue.current; + let onChange = useCallback((id: Key, columnId: Key, values: any) => { + let value = values[columnId]; if (value === null) { return; } - intermediateValue.current = null; let alreadySaving = currentRequests.current.get(id); if (alreadySaving) { // remove and cancel the previous request @@ -1604,10 +1598,6 @@ export const EditableTableWithAsyncSaving: StoryObj = { }); }, [saveItem]); - let onIntermediateChange = useCallback((value: any) => { - intermediateValue.current = value; - }, []); - return (
@@ -1623,9 +1613,10 @@ export const EditableTableWithAsyncSaving: StoryObj = { if (column.id === 'fruits') { return ( onChange(item.id, column.id!)} + onSubmit={(values) => onChange(item.id, column.id!, values)} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { validate={value => value.length > 0 ? null : 'Fruit name is required'} styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})} defaultValue={item[column.id!]} - onChange={value => onIntermediateChange(value)} /> + name={column.id! as string} /> )}>
{item[column.id]}
@@ -1645,7 +1636,7 @@ export const EditableTableWithAsyncSaving: StoryObj = { onChange(item.id, column.id!)} + onSubmit={(values) => onChange(item.id, column.id!, values)} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { autoFocus styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})} defaultValue={item[column.id!]} - onChange={value => onIntermediateChange(value)}> + name={column.id! as string}> Eva Steven Michael diff --git a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx index e102ac19515..14ec836219a 100644 --- a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx +++ b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx @@ -128,19 +128,17 @@ describe('TableView', () => { let {delay = 0} = props; let columns = editableColumns; let [editableItems, setEditableItems] = useState(defaultItems); - let intermediateValue = useRef(null); let saveItem = useCallback((id: Key, columnId: Key) => { setEditableItems(prev => prev.map(i => i.id === id ? {...i, isSaving: {...i.isSaving, [columnId]: false}} : i)); currentRequests.current.delete(id); }, []); let currentRequests = useRef}>>(new Map()); - let onChange = useCallback((id: Key, columnId: Key) => { - let value = intermediateValue.current; + let onChange = useCallback((id: Key, columnId: Key, values: any) => { + let value = values[columnId]; if (value === null) { return; } - intermediateValue.current = null; let alreadySaving = currentRequests.current.get(id); if (alreadySaving) { // remove and cancel the previous request @@ -157,10 +155,6 @@ describe('TableView', () => { }); }, [saveItem, delay]); - let onIntermediateChange = useCallback((value: any) => { - intermediateValue.current = value; - }, []); - return (
@@ -178,7 +172,7 @@ describe('TableView', () => { onChange(item.id, column.id!)} + onSubmit={(values) => onChange(item.id, column.id!, values)} isSaving={item.isSaving[column.id!]} renderEditing={() => ( { autoFocus validate={value => value.length > 0 ? null : 'Fruit name is required'} defaultValue={item[column.id!]} - onChange={value => onIntermediateChange(value)} /> + name={column.id! as string} /> )}>
{item[column.id]}
@@ -197,14 +191,14 @@ describe('TableView', () => { onChange(item.id, column.id!)} + onSubmit={(values) => onChange(item.id, column.id!, values)} isSaving={item.isSaving[column.id!]} renderEditing={() => ( onIntermediateChange(value)}> + name={column.id! as string}> Eva Steven Michael diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index 1d884e3c76f..53f05fb6797 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -726,24 +726,18 @@ let editableColumns: Array & {name: string}> = [ export default function EditableTable(props) { let columns = editableColumns; let [editableItems, setEditableItems] = useState(defaultItems); - let intermediateValue = useRef(null); - let onChange = useCallback((id: Key, columnId: Key) => { - let value = intermediateValue.current; + let onChange = useCallback((id: Key, columnId: Key, values: any) => { + let value = values[columnId]; if (value === null) { return; } - intermediateValue.current = null; setEditableItems(prev => { let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value} : i); return newItems; }); }, []); - let onIntermediateChange = useCallback((value: any) => { - intermediateValue.current = value; - }, []); - return ( @@ -759,17 +753,18 @@ export default function EditableTable(props) { ///- begin highlight -/// return ( onChange(item.id, column.id!)} + onSubmit={(values) => onChange(item.id, column.id!, values)} renderEditing={() => ( value.length > 0 ? null : 'Fruit name is required'} styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})} defaultValue={item[column.id!]} - onChange={value => onIntermediateChange(value)} /> + name={column.id! as string} /> )}>
{item[column.id]} @@ -786,14 +781,14 @@ export default function EditableTable(props) { onChange(item.id, column.id!)} + onSubmit={(values) => onChange(item.id, column.id!, values)} renderEditing={() => ( onIntermediateChange(value)}> + name={column.id! as string}> Eva From 187a9ff18423eb926838808efab19b190c8aa119 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 29 Oct 2025 11:16:00 +1100 Subject: [PATCH 2/7] fix Esc handling --- packages/@react-spectrum/s2/src/TableView.tsx | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 2812e388d63..94a261200f6 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -1174,6 +1174,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, let [verticalOffset, setVerticalOffset] = useState(0); let tableVisualOptions = useContext(InternalTableContext); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + let dialogRef = useRef>(null); let {density} = useContext(InternalTableContext); let size: 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = 'M'; @@ -1221,7 +1222,24 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, setIsOpen(false); }; + // Can't differentiate between Dialog click outside dismissal and Escape key dismissal let isMobile = !useMediaQuery('(any-pointer: fine)'); + useEffect(() => { + let dialog = dialogRef.current?.UNSAFE_getDOMNode(); + if (isOpen && dialog) { + let handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsOpen(false); + e.stopPropagation(); + e.preventDefault(); + } + }; + dialog.addEventListener('keydown', handler); + return () => { + dialog.removeEventListener('keydown', handler); + }; + } + }, [isOpen]); return ( )} {isMobile && ( - setIsOpen(false)}> + formRef.current?.requestSubmit()}> {isOpen && ( - +
{ From 699f6fb1b49cb3573141e5783b556cd77b062c8e Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 29 Oct 2025 11:17:08 +1100 Subject: [PATCH 3/7] remove console.log --- packages/@react-spectrum/s2/stories/TableView.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 5cbdbbb69c9..194ff011160 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -1463,7 +1463,6 @@ export const EditableTable: StoryObj = { let [editableItems, setEditableItems] = useState(defaultItems); let onChange = useCallback((id: Key, columnId: Key, values: any) => { - // console.log(values); let value = values[columnId]; if (value === null) { return; From a11d75005c8ae254a16aedd6590a3de4fe57804d Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 31 Oct 2025 10:08:10 +1100 Subject: [PATCH 4/7] add onCancel, use list data, move submit responsibilities --- packages/@react-spectrum/s2/src/TableView.tsx | 36 ++++--- .../s2/stories/TableView.stories.tsx | 95 +++++++++++------- .../s2/test/EditableTableView.test.tsx | 99 +++++++++++-------- packages/dev/s2-docs/pages/s2/TableView.mdx | 34 ++++--- 4 files changed, 158 insertions(+), 106 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 94a261200f6..b34cbca87c6 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -69,7 +69,7 @@ import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu'; import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg'; import {ProgressCircle} from './ProgressCircle'; import {raw} from '../style/style-macro' with {type: 'macro'}; -import React, {createContext, CSSProperties, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, CSSProperties, FormEvent, FormHTMLAttributes, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg'; import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg'; import {Button as SpectrumButton} from './Button'; @@ -1122,7 +1122,11 @@ interface EditableCellProps extends Omit { /** Whether the cell is currently being saved. */ isSaving?: boolean, /** Handler that is called when the value has been changed and is ready to be saved. */ - onSubmit: (values: Record) => void + onSubmit?: (e: FormEvent) => void, + /** Handler that is called when the user cancels the edit. */ + onCancel?: () => void, + /** The action to submit the form to. Only available in React 19+. */ + action?: string | FormHTMLAttributes['action'] } /** @@ -1165,7 +1169,7 @@ const nonTextInputTypes = new Set([ ]); function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, cellRef: RefObject}) { - let {children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef} = props; + let {children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef, action, onCancel} = props; let [isOpen, setIsOpen] = useState(false); let popoverRef = useRef(null); let formRef = useRef(null); @@ -1218,28 +1222,32 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, } }, [isOpen]); - let cancel = () => { + let cancel = useCallback(() => { setIsOpen(false); - }; + onCancel?.(); + }, [onCancel]); // Can't differentiate between Dialog click outside dismissal and Escape key dismissal let isMobile = !useMediaQuery('(any-pointer: fine)'); + let prevIsOpen = useRef(isOpen); useEffect(() => { let dialog = dialogRef.current?.UNSAFE_getDOMNode(); - if (isOpen && dialog) { + if (isOpen && dialog && !prevIsOpen.current) { let handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { - setIsOpen(false); + cancel(); e.stopPropagation(); e.preventDefault(); } }; dialog.addEventListener('keydown', handler); + prevIsOpen.current = isOpen; return () => { dialog.removeEventListener('keydown', handler); }; } - }, [isOpen]); + prevIsOpen.current = isOpen; + }, [isOpen, cancel]); return ( { - e.preventDefault(); - let formData = new FormData(formRef.current as HTMLFormElement); - let values = Object.fromEntries(formData.entries()); - onSubmit(values); + onSubmit?.(e); setIsOpen(false); }} className={style({width: 'full', display: 'flex', alignItems: 'start', gap: 16})} @@ -1334,11 +1340,9 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, aria-label={props['aria-label'] ?? stringFormatter.format('table.editCell')}> { - e.preventDefault(); - let formData = new FormData(formRef.current as HTMLFormElement); - let values = Object.fromEntries(formData.entries()); - onSubmit(values); + onSubmit?.(e); setIsOpen(false); }} className={style({width: 'full', display: 'flex', flexDirection: 'column', alignItems: 'start', gap: 16})}> diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 194ff011160..ef1f648089e 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -40,10 +40,11 @@ import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; import {Key} from '@react-types/shared'; import type {Meta, StoryObj} from '@storybook/react'; -import {ReactElement, useCallback, useRef, useState} from 'react'; +import {ReactElement, useCallback, useEffect, useRef, 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 {useAsyncList, useListData} from '@react-stately/data'; +import {useEffectEvent} from '@react-aria/utils'; import User from '../s2wf-icons/S2_Icon_User_20_N.svg'; let onActionFunc = action('onAction'); @@ -1460,18 +1461,16 @@ interface EditableTableProps extends TableViewProps {} export const EditableTable: StoryObj = { render: function EditableTable(props) { let columns = editableColumns; - let [editableItems, setEditableItems] = useState(defaultItems); + let data = useListData({initialItems: defaultItems}); let onChange = useCallback((id: Key, columnId: Key, values: any) => { let value = values[columnId]; if (value === null) { return; } - setEditableItems(prev => { - let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value} : i); - return newItems; - }); - }, []); + let prevItem = data.getItem(id)!; + data.update(id, {...prevItem, [columnId]: value}); + }, [data]); return (
@@ -1481,7 +1480,7 @@ export const EditableTable: StoryObj = { {column.name} )} - + {item => ( {(column) => { @@ -1491,7 +1490,12 @@ export const EditableTable: StoryObj = { aria-label={`Edit ${item[column.id]} in ${column.name}`} align={column.align} showDivider={column.showDivider} - onSubmit={(values) => onChange(item.id, column.id!, values)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { onChange(item.id, column.id!, values)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { export const EditableTableWithAsyncSaving: StoryObj = { render: function EditableTable(props) { + let delay = 5000; let columns = editableColumns; - let [editableItems, setEditableItems] = useState(defaultItems); - - // Replace all of this with real API calls, this is purely demonstrative. - let saveItem = useCallback((id: Key, columnId: Key, prevValue: any) => { - let succeeds = Math.random() > 0.5; - if (succeeds) { - setEditableItems(prev => prev.map(i => i.id === id ? {...i, isSaving: {...i.isSaving, [columnId]: false}} : i)); - } else { - setEditableItems(prev => prev.map(i => i.id === id ? {...i, [columnId]: prevValue, isSaving: {...i.isSaving, [columnId]: false}} : i)); - } + let data = useListData({initialItems: defaultItems}); + + let saveItem = useEffectEvent((id: Key, columnId: Key) => { + let prevItem = data.getItem(id)!; + data.update(id, {...prevItem, isSaving: {...prevItem.isSaving, [columnId]: false}}); currentRequests.current.delete(id); - }, []); - let currentRequests = useRef, prevValue: any}>>(new Map()); + }); + let currentRequests = useRef}>>(new Map()); let onChange = useCallback((id: Key, columnId: Key, values: any) => { let value = values[columnId]; if (value === null) { @@ -1585,17 +1590,23 @@ export const EditableTableWithAsyncSaving: StoryObj = { currentRequests.current.delete(id); clearTimeout(alreadySaving.request); } - setEditableItems(prev => { - let prevValue = prev.find(i => i.id === id)?.[columnId]; - let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value, isSaving: {...i.isSaving, [columnId]: true}} : i); - // set a timeout between 0 and 10s - let timeout = setTimeout(() => { - saveItem(id, columnId, alreadySaving?.prevValue ?? prevValue); - }, Math.random() * 10000); - currentRequests.current.set(id, {request: timeout, prevValue}); - return newItems; - }); - }, [saveItem]); + let prevItem = data.getItem(id)!; + data.update(id, {...prevItem, [columnId]: value, isSaving: {...prevItem.isSaving, [columnId]: true}}); + }, [data]); + + useEffect(() => { + // if any item is saving and we don't have a request for it, start a timer to commit it + for (const item of data.items) { + for (const columnId in item.isSaving) { + if (item.isSaving[columnId] && !currentRequests.current.has(item.id)) { + let timeout = setTimeout(() => { + saveItem(item.id, columnId); + }, delay); + currentRequests.current.set(item.id, {request: timeout}); + } + } + } + }, [data, delay]); return (
@@ -1605,7 +1616,7 @@ export const EditableTableWithAsyncSaving: StoryObj = { {column.name} )} - + {item => ( {(column) => { @@ -1615,7 +1626,12 @@ export const EditableTableWithAsyncSaving: StoryObj = { aria-label={`Edit ${item[column.id]} in ${column.name}`} align={column.align} showDivider={column.showDivider} - onSubmit={(values) => onChange(item.id, column.id!, values)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { onChange(item.id, column.id!, values)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} isSaving={item.isSaving[column.id!]} renderEditing={() => ( { let defaultItems = [ {id: 1, fruits: 'Apples', task: 'Collect', status: 'Pending', farmer: 'Eva', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 2, fruits: 'Oranges', task: 'Collect', status: 'Pending', farmer: 'Steven', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 3, fruits: 'Pears', task: 'Collect', status: 'Pending', farmer: 'Michael', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 4, fruits: 'Cherries', task: 'Collect', status: 'Pending', farmer: 'Sara', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 5, fruits: 'Dates', task: 'Collect', status: 'Pending', farmer: 'Karina', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 6, fruits: 'Bananas', task: 'Collect', status: 'Pending', farmer: 'Otto', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 7, fruits: 'Melons', task: 'Collect', status: 'Pending', farmer: 'Matt', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 8, fruits: 'Figs', task: 'Collect', status: 'Pending', farmer: 'Emily', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 9, fruits: 'Blueberries', task: 'Collect', status: 'Pending', farmer: 'Amelia', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 10, fruits: 'Blackberries', task: 'Collect', status: 'Pending', farmer: 'Isla', - isSaving: {}, - intermediateValue: {} + isSaving: {} } ]; @@ -124,15 +116,16 @@ describe('TableView', () => { interface EditableTableProps extends TableViewProps {} - function EditableTable(props: EditableTableProps & {delay?: number}) { - let {delay = 0} = props; + function EditableTable(props: EditableTableProps & {delay?: number, onCancel?: () => void}) { + let {delay = 0, onCancel} = props; let columns = editableColumns; - let [editableItems, setEditableItems] = useState(defaultItems); + let data = useListData({initialItems: defaultItems}); - let saveItem = useCallback((id: Key, columnId: Key) => { - setEditableItems(prev => prev.map(i => i.id === id ? {...i, isSaving: {...i.isSaving, [columnId]: false}} : i)); + let saveItem = useEffectEvent((id: Key, columnId: Key) => { + let prevItem = data.getItem(id)!; + data.update(id, {...prevItem, isSaving: {...prevItem.isSaving, [columnId]: false}}); currentRequests.current.delete(id); - }, []); + }); let currentRequests = useRef}>>(new Map()); let onChange = useCallback((id: Key, columnId: Key, values: any) => { let value = values[columnId]; @@ -145,15 +138,23 @@ describe('TableView', () => { currentRequests.current.delete(id); clearTimeout(alreadySaving.request); } - setEditableItems(prev => { - let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value, isSaving: {...i.isSaving, [columnId]: true}} : i); - let timeout = setTimeout(() => { - saveItem(id, columnId); - }, delay); - currentRequests.current.set(id, {request: timeout}); - return newItems; - }); - }, [saveItem, delay]); + let prevItem = data.getItem(id)!; + data.update(id, {...prevItem, [columnId]: value, isSaving: {...prevItem.isSaving, [columnId]: true}}); + }, [data]); + + useEffect(() => { + // if any item is saving and we don't have a request for it, start a timer to commit it + for (const item of data.items) { + for (const columnId in item.isSaving) { + if (item.isSaving[columnId] && !currentRequests.current.has(item.id)) { + let timeout = setTimeout(() => { + saveItem(item.id, columnId); + }, delay); + currentRequests.current.set(item.id, {request: timeout}); + } + } + } + }, [data, delay]); return (
@@ -163,7 +164,7 @@ describe('TableView', () => { {column.name} )} - + {item => ( {(column) => { @@ -172,7 +173,13 @@ describe('TableView', () => { onChange(item.id, column.id!, values)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} + onCancel={onCancel} isSaving={item.isSaving[column.id!]} renderEditing={() => ( { onChange(item.id, column.id!, values)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} + onCancel={onCancel} isSaving={item.isSaving[column.id!]} renderEditing={() => ( { }); it('should be cancellable through the buttons in the dialog', async () => { + let onCancel = jest.fn(); let {getByRole} = render( - + ); let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); @@ -354,11 +368,13 @@ describe('TableView', () => { expect(dialog).not.toBeInTheDocument(); expect(tableTester.findRow({rowIndexOrText: 'Apples'})).toBeInTheDocument(); + expect(onCancel).toHaveBeenCalled(); }); it('should be cancellable through Escape key', async () => { + let onCancel = jest.fn(); let {getByRole} = render( - + ); let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); @@ -379,6 +395,7 @@ describe('TableView', () => { expect(dialog).not.toBeInTheDocument(); expect(tableTester.findRow({rowIndexOrText: 'Apples'})).toBeInTheDocument(); + expect(onCancel).toHaveBeenCalled(); }); }); diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index 53f05fb6797..73caae6d5c3 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -698,7 +698,8 @@ import {TableView, TableHeader, Column, TableBody, Row, Cell, EditableCell, Text import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import User from '@react-spectrum/s2/icons/User'; import Edit from '@react-spectrum/s2/icons/Edit'; -import {useCallback,useRef, useState} from 'react'; +import {useCallback} from 'react'; +import {useListData} from '@react-stately/data'; ///- begin collapse -/// let defaultItems = [ @@ -725,18 +726,16 @@ let editableColumns: Array & {name: string}> = [ export default function EditableTable(props) { let columns = editableColumns; - let [editableItems, setEditableItems] = useState(defaultItems); + let data = useListData({initialItems: defaultItems}); let onChange = useCallback((id: Key, columnId: Key, values: any) => { let value = values[columnId]; - if (value === null) { + if (value == null) { return; } - setEditableItems(prev => { - let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value} : i); - return newItems; - }); - }, []); + let prevItem = data.getItem(id); + data.update(id, {...prevItem, [columnId]: value}); + }, [data]); return ( @@ -745,7 +744,7 @@ export default function EditableTable(props) { {column.name} )} - + {item => ( {(column) => { @@ -756,7 +755,12 @@ export default function EditableTable(props) { aria-label={`Edit ${item[column.id]} in ${column.name}`} align={column.align} showDivider={column.showDivider} - onSubmit={(values) => onChange(item.id, column.id!, values)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} renderEditing={() => ( -
+ +
); ///- end highlight -/// @@ -781,7 +786,12 @@ export default function EditableTable(props) { onChange(item.id, column.id!, values)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} renderEditing={() => ( Date: Fri, 31 Oct 2025 14:26:54 +1100 Subject: [PATCH 5/7] add tests for action --- .../s2/stories/TableView.stories.tsx | 105 ++++- .../s2/test/EditableTableView.test.tsx | 380 +++++++++++++++++- .../@react-stately/data/src/useListData.ts | 8 +- packages/dev/s2-docs/pages/s2/TableView.mdx | 3 +- 4 files changed, 482 insertions(+), 14 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index ef1f648089e..c22ecff2220 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -39,9 +39,9 @@ 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 {Key} from '@react-types/shared'; +import {ListState, SortDescriptor} from 'react-aria-components'; import type {Meta, StoryObj} from '@storybook/react'; -import {ReactElement, useCallback, useEffect, useRef, useState} from 'react'; -import {SortDescriptor} from 'react-aria-components'; +import React, {ReactElement, useCallback, useEffect, useRef, useState} from 'react'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useAsyncList, useListData} from '@react-stately/data'; import {useEffectEvent} from '@react-aria/utils'; @@ -1468,8 +1468,7 @@ export const EditableTable: StoryObj = { if (value === null) { return; } - let prevItem = data.getItem(id)!; - data.update(id, {...prevItem, [columnId]: value}); + data.update(id, (prevItem) => ({...prevItem, [columnId]: value})); }, [data]); return ( @@ -1698,3 +1697,101 @@ export const EditableTableWithAsyncSaving: StoryObj = { ); } }; + +export const ActionEditableTable = { + render: function ActionEditableTable(props: EditableTableProps & {delay?: number, onCancel?: () => void}) { + let {onCancel} = props; + let columns = editableColumns; + let data = useListData({initialItems: defaultItems}); + let [formState, formAction] = React.useActionState<{list: ListState}>((prev, formData) => { + let updateKeys = ['fruits', 'farmer']; + for (const key of updateKeys) { + let value = formData.get(key); + console.log('key', key, value); + if (value != null) { + prev.list.update(value, (prevItem) => ({...prevItem, [key]: value})); + } + } + return {list: prev.list}; + }, {list: data}); + + return ( +
+ + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.id === 'fruits') { + return ( + ( + value.length > 0 ? null : 'Fruit name is required'} + defaultValue={item[column.id!]} + name={column.id! as string} /> + )}> +
{item[column.id]}
+
+ ); + } + if (column.id === 'farmer') { + return ( + ( + + Eva + Steven + Michael + Sara + Karina + Otto + Matt + Emily + Amelia + Isla + + )}> +
{item[column.id]}
+
+ ); + } + if (column.id === 'status') { + return ( + + {item[column.id]} + + ); + } + return {item[column.id!]}; + }} +
+ )} +
+
+ +
+ ); + } +}; diff --git a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx index 9126550e2bf..560fdd4a338 100644 --- a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx +++ b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx @@ -122,8 +122,7 @@ describe('TableView', () => { let data = useListData({initialItems: defaultItems}); let saveItem = useEffectEvent((id: Key, columnId: Key) => { - let prevItem = data.getItem(id)!; - data.update(id, {...prevItem, isSaving: {...prevItem.isSaving, [columnId]: false}}); + data.update(id, (prevItem) => ({...prevItem, isSaving: {...prevItem.isSaving, [columnId]: false}})); currentRequests.current.delete(id); }); let currentRequests = useRef}>>(new Map()); @@ -138,8 +137,7 @@ describe('TableView', () => { currentRequests.current.delete(id); clearTimeout(alreadySaving.request); } - let prevItem = data.getItem(id)!; - data.update(id, {...prevItem, [columnId]: value, isSaving: {...prevItem.isSaving, [columnId]: true}}); + data.update(id, (prevItem) => ({...prevItem, [columnId]: value, isSaving: {...prevItem.isSaving, [columnId]: true}})); }, [data]); useEffect(() => { @@ -493,4 +491,378 @@ describe('TableView', () => { expect(button).not.toHaveAttribute('aria-disabled'); }); }); + + if (parseInt(React.version, 10) >= 19) { + describe('using action instead of onSubmit', () => { + function ActionEditableTable(props: EditableTableProps & {delay?: number, onCancel?: () => void}) { + let {delay = 0, onCancel} = props; + let columns = editableColumns; + let data = useListData({initialItems: defaultItems}); + + let saveItem = useEffectEvent((id: Key, columnId: Key) => { + data.update(id, (prevItem) => ({...prevItem, isSaving: {...prevItem.isSaving, [columnId]: false}})); + currentRequests.current.delete(id); + }); + let currentRequests = useRef}>>(new Map()); + let onChange = useCallback((id: Key, columnId: Key, values: any) => { + let value = values.get(columnId); + if (value === null) { + return; + } + let alreadySaving = currentRequests.current.get(id); + if (alreadySaving) { + // remove and cancel the previous request + currentRequests.current.delete(id); + clearTimeout(alreadySaving.request); + } + data.update(id, (prevItem) => ({...prevItem, [columnId]: value, isSaving: {...prevItem.isSaving, [columnId]: true}})); + }, [data]); + + useEffect(() => { + // if any item is saving and we don't have a request for it, start a timer to commit it + for (const item of data.items) { + for (const columnId in item.isSaving) { + if (item.isSaving[columnId] && !currentRequests.current.has(item.id)) { + let timeout = setTimeout(() => { + saveItem(item.id, columnId); + }, delay); + currentRequests.current.set(item.id, {request: timeout}); + } + } + } + }, [data, delay]); + + return ( +
+ + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.id === 'fruits') { + return ( + { + onChange(item.id, column.id!, e); + }} + onCancel={onCancel} + isSaving={item.isSaving[column.id!]} + renderEditing={() => ( + value.length > 0 ? null : 'Fruit name is required'} + defaultValue={item[column.id!]} + name={column.id! as string} /> + )}> +
{item[column.id]}
+
+ ); + } + if (column.id === 'farmer') { + return ( + { + onChange(item.id, column.id!, e); + }} + onCancel={onCancel} + isSaving={item.isSaving[column.id!]} + renderEditing={() => ( + + Eva + Steven + Michael + Sara + Karina + Otto + Matt + Emily + Amelia + Isla + + )}> +
{item[column.id]}
+
+ ); + } + if (column.id === 'status') { + return ( + + {item[column.id]} + + ); + } + return {item[column.id!]}; + }} +
+ )} +
+
+ +
+ ); + } + + describe('keyboard', () => { + it('should edit text in a cell either through a TextField or a Picker', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + let dialogTrigger = document.activeElement! as HTMLElement; + let dialogTester = testUtilUser.createTester('Dialog', {root: dialogTrigger, interactionType: 'keyboard', overlayType: 'modal'}); + await dialogTester.open(); + let dialog = dialogTester.dialog; + expect(dialog).toBeVisible(); + + let input = within(dialog!).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard('Apples Crisp'); + await user.keyboard('{Enter}'); // implicitly submit through form + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + + expect(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).toBeInTheDocument(); + + // navigate to Farmer column + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + dialogTrigger = document.activeElement! as HTMLElement; + dialogTester = testUtilUser.createTester('Dialog', {root: dialogTrigger, interactionType: 'keyboard', overlayType: 'modal'}); + await dialogTester.open(); + dialog = dialogTester.dialog; + // TODO: also weird that it is dialog.dialog? + expect(dialog).toBeVisible(); + + let selectTester = testUtilUser.createTester('Select', {root: dialog!}); + expect(selectTester.trigger).toHaveFocus(); + await selectTester.selectOption({option: 'Steven'}); + act(() => {jest.runAllTimers();}); + await user.tab(); + await user.tab(); + expect(within(dialog!).getByRole('button', {name: 'Save'})).toHaveFocus(); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + expect(within(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).getByText('Steven')).toBeInTheDocument(); + + await user.tab(); + expect(getByRole('button', {name: 'After'})).toHaveFocus(); + + await user.tab({shift: true}); + expect(within(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).getByRole('button', {name: 'Edit farmer'})).toHaveFocus(); + }); + + it('should perform validation when editing text in a cell', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.clear(input); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).toBeInTheDocument(); + expect(input).toHaveFocus(); + expect(document.getElementById(input.getAttribute('aria-describedby')!)).toHaveTextContent('Fruit name is required'); + + await user.keyboard('Peaches'); + await user.tab(); + await user.tab(); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + + expect(tableTester.findRow({rowIndexOrText: 'Peaches'})).toBeInTheDocument(); + }); + + it('should be cancellable through the buttons in the dialog', async () => { + let onCancel = jest.fn(); + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard(' Crisp'); + await user.tab(); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + + expect(tableTester.findRow({rowIndexOrText: 'Apples'})).toBeInTheDocument(); + expect(onCancel).toHaveBeenCalled(); + }); + + it('should be cancellable through Escape key', async () => { + let onCancel = jest.fn(); + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard(' Crisp'); + await user.keyboard('{Escape}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + expect(tableTester.findRow({rowIndexOrText: 'Apples'})).toBeInTheDocument(); + expect(onCancel).toHaveBeenCalled(); + }); + }); + + describe('pointer', () => { + installPointerEvent(); + + it('should edit text in a cell', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.click(within(tableTester.findCell({text: 'Apples'})).getByRole('button')); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + await user.click(within(dialog).getByRole('textbox')); + await user.keyboard(' Crisp'); + await user.click(document.body); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + expect(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).toBeInTheDocument(); + }); + }); + + describe('pending', () => { + it('should display a pending state when editing a cell', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard('Apples Crisp'); + await user.keyboard('{Enter}'); // implicitly submit through form + + act(() => {jest.advanceTimersByTime(5000);}); + + expect(dialog).not.toBeInTheDocument(); + expect(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).toBeInTheDocument(); + let button = within(tableTester.findCell({text: 'Apples Crisp'})).getByRole('button'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toHaveFocus(); + + act(() => {jest.runAllTimers();}); + + expect(button).not.toHaveAttribute('aria-disabled'); + expect(button).toHaveFocus(); + }); + + it('should allow tabbing off a pending button', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard('Apples Crisp'); + await user.keyboard('{Enter}'); // implicitly submit through form + + act(() => {jest.advanceTimersByTime(5000);}); + + expect(dialog).not.toBeInTheDocument(); + expect(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).toBeInTheDocument(); + let button = within(tableTester.findCell({text: 'Apples Crisp'})).getByRole('button'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toHaveFocus(); + + await user.tab(); + expect(getByRole('button', {name: 'After'})).toHaveFocus(); + + act(() => {jest.runAllTimers();}); + + expect(button).not.toHaveAttribute('aria-disabled'); + }); + }); + }); + } }); diff --git a/packages/@react-stately/data/src/useListData.ts b/packages/@react-stately/data/src/useListData.ts index a705b413d19..ff4c411d77e 100644 --- a/packages/@react-stately/data/src/useListData.ts +++ b/packages/@react-stately/data/src/useListData.ts @@ -123,9 +123,9 @@ export interface ListData { /** * Updates an item in the list. * @param key - The key of the item to update. - * @param newValue - The new value for the item. + * @param newValue - The new value for the item, or a function that returns the new value based on the previous value. */ - update(key: Key, newValue: T): void + update(key: Key, newValue: T | ((prev: T) => T)): void } export interface ListState { @@ -344,7 +344,7 @@ export function createListActions(opts: CreateListOptions, dispatch: return move(state, indices, toIndex + 1); }); }, - update(key: Key, newValue: T) { + update(key: Key, newValue: T | ((prev: T) => T)) { dispatch(state => { let index = state.items.findIndex(item => getKey!(item) === key); if (index === -1) { @@ -355,7 +355,7 @@ export function createListActions(opts: CreateListOptions, dispatch: ...state, items: [ ...state.items.slice(0, index), - newValue, + typeof newValue === 'function' ? newValue(state.items[index]) : newValue, ...state.items.slice(index + 1) ] }; diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index 73caae6d5c3..cc0f8b6d0cb 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -733,8 +733,7 @@ export default function EditableTable(props) { if (value == null) { return; } - let prevItem = data.getItem(id); - data.update(id, {...prevItem, [columnId]: value}); + data.update(id, (prevItem) => ({...prevItem, [columnId]: value})); }, [data]); return ( From ee3fe7f09395bffdbec3515dc3027a88fe6d4e40 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 31 Oct 2025 14:50:02 +1100 Subject: [PATCH 6/7] fix TS --- packages/@react-stately/data/src/useListData.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/@react-stately/data/src/useListData.ts b/packages/@react-stately/data/src/useListData.ts index ff4c411d77e..82cd9063f63 100644 --- a/packages/@react-stately/data/src/useListData.ts +++ b/packages/@react-stately/data/src/useListData.ts @@ -351,11 +351,18 @@ export function createListActions(opts: CreateListOptions, dispatch: return state; } + let updatedValue: T; + if (typeof newValue === 'function') { + updatedValue = (newValue as (prev: T) => T)(state.items[index]); + } else { + updatedValue = newValue; + } + return { ...state, items: [ ...state.items.slice(0, index), - typeof newValue === 'function' ? newValue(state.items[index]) : newValue, + updatedValue, ...state.items.slice(index + 1) ] }; From b91085804ab4bc0cb8b7b3d75d0abe4a928b0a78 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 31 Oct 2025 14:57:43 +1100 Subject: [PATCH 7/7] fix more types --- .../s2/stories/TableView.stories.tsx | 100 +----------------- 1 file changed, 1 insertion(+), 99 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index c22ecff2220..c637a16981f 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -39,9 +39,9 @@ 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 {Key} from '@react-types/shared'; -import {ListState, SortDescriptor} from 'react-aria-components'; import type {Meta, StoryObj} from '@storybook/react'; import React, {ReactElement, useCallback, useEffect, useRef, useState} from 'react'; +import {SortDescriptor} from 'react-aria-components'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useAsyncList, useListData} from '@react-stately/data'; import {useEffectEvent} from '@react-aria/utils'; @@ -1697,101 +1697,3 @@ export const EditableTableWithAsyncSaving: StoryObj = { ); } }; - -export const ActionEditableTable = { - render: function ActionEditableTable(props: EditableTableProps & {delay?: number, onCancel?: () => void}) { - let {onCancel} = props; - let columns = editableColumns; - let data = useListData({initialItems: defaultItems}); - let [formState, formAction] = React.useActionState<{list: ListState}>((prev, formData) => { - let updateKeys = ['fruits', 'farmer']; - for (const key of updateKeys) { - let value = formData.get(key); - console.log('key', key, value); - if (value != null) { - prev.list.update(value, (prevItem) => ({...prevItem, [key]: value})); - } - } - return {list: prev.list}; - }, {list: data}); - - return ( -
- - - {(column) => ( - {column.name} - )} - - - {item => ( - - {(column) => { - if (column.id === 'fruits') { - return ( - ( - value.length > 0 ? null : 'Fruit name is required'} - defaultValue={item[column.id!]} - name={column.id! as string} /> - )}> -
{item[column.id]}
-
- ); - } - if (column.id === 'farmer') { - return ( - ( - - Eva - Steven - Michael - Sara - Karina - Otto - Matt - Emily - Amelia - Isla - - )}> -
{item[column.id]}
-
- ); - } - if (column.id === 'status') { - return ( - - {item[column.id]} - - ); - } - return {item[column.id!]}; - }} -
- )} -
-
- -
- ); - } -};