diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 5bbaecf343b..b34cbca87c6 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'; @@ -67,11 +69,12 @@ 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'; 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?: (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'] } /** @@ -1173,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); @@ -1182,6 +1178,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'; @@ -1225,9 +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 && !prevIsOpen.current) { + let handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + cancel(); + e.stopPropagation(); + e.preventDefault(); + } + }; + dialog.addEventListener('keydown', handler); + prevIsOpen.current = isOpen; + return () => { + dialog.removeEventListener('keydown', handler); + }; + } + prevIsOpen.current = isOpen; + }, [isOpen, cancel]); 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}> + +
{ + onSubmit?.(e); + setIsOpen(false); + }} + className={style({width: 'full', display: 'flex', alignItems: 'start', gap: 16})} + style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}> + {renderEditing()} +
+ + +
+
+
+
+ )} + {isMobile && ( + formRef.current?.requestSubmit()}> + {isOpen && ( + +
{ + onSubmit?.(e); + 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..c637a16981f 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 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} 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,24 +1461,15 @@ interface EditableTableProps extends TableViewProps {} export const EditableTable: StoryObj = { render: function EditableTable(props) { let columns = editableColumns; - let [editableItems, setEditableItems] = useState(defaultItems); - let intermediateValue = useRef(null); + let data = useListData({initialItems: defaultItems}); - 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; - }, []); + data.update(id, (prevItem) => ({...prevItem, [columnId]: value})); + }, [data]); return (
@@ -1487,25 +1479,31 @@ export const EditableTable: StoryObj = { {column.name} )} - + {item => ( {(column) => { if (column.id === 'fruits') { return ( onChange(item.id, column.id!)} + 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={() => ( 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 +1518,12 @@ export const EditableTable: StoryObj = { onChange(item.id, column.id!)} + 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={() => ( = { 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 @@ -1565,48 +1568,44 @@ export const EditableTable: StoryObj = { export const EditableTableWithAsyncSaving: StoryObj = { render: function EditableTable(props) { + let delay = 5000; 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) => { - 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 onChange = useCallback((id: Key, columnId: Key) => { - let value = intermediateValue.current; + }); + let currentRequests = useRef}>>(new Map()); + 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 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 onIntermediateChange = useCallback((value: any) => { - intermediateValue.current = value; - }, []); + 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 (
@@ -1616,16 +1615,22 @@ export const EditableTableWithAsyncSaving: StoryObj = { {column.name} )} - + {item => ( {(column) => { if (column.id === 'fruits') { return ( onChange(item.id, column.id!)} + 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={() => ( = { 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 +1650,12 @@ export const EditableTableWithAsyncSaving: StoryObj = { onChange(item.id, column.id!)} + 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={() => ( = { 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..560fdd4a338 100644 --- a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx +++ b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx @@ -33,7 +33,9 @@ import { import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import {installPointerEvent, pointerMap, User} from '@react-aria/test-utils'; import {Key} from '@react-types/shared'; -import React, {useCallback, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; +import {useEffectEvent} from '@react-aria/utils'; +import {useListData} from '@react-stately/data'; import userEvent from '@testing-library/user-event'; // @ts-ignore @@ -65,53 +67,43 @@ describe('TableView', () => { 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,42 +116,43 @@ 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 intermediateValue = useRef(null); + 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) => { + 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) => { - 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 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 onIntermediateChange = useCallback((value: any) => { - intermediateValue.current = value; - }, []); + 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 (
@@ -169,7 +162,7 @@ describe('TableView', () => { {column.name} )} - + {item => ( {(column) => { @@ -178,7 +171,13 @@ describe('TableView', () => { onChange(item.id, column.id!)} + 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={() => ( { 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 +196,20 @@ describe('TableView', () => { onChange(item.id, column.id!)} + 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={() => ( onIntermediateChange(value)}> + name={column.id! as string}> Eva Steven Michael @@ -336,8 +341,9 @@ describe('TableView', () => { }); 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')}); @@ -360,11 +366,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')}); @@ -385,6 +393,7 @@ describe('TableView', () => { expect(dialog).not.toBeInTheDocument(); expect(tableTester.findRow({rowIndexOrText: 'Apples'})).toBeInTheDocument(); + expect(onCancel).toHaveBeenCalled(); }); }); @@ -482,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..82cd9063f63 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,18 +344,25 @@ 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) { 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), - newValue, + updatedValue, ...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 1d884e3c76f..cc0f8b6d0cb 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,24 +726,15 @@ let editableColumns: Array & {name: string}> = [ export default function EditableTable(props) { let columns = editableColumns; - let [editableItems, setEditableItems] = useState(defaultItems); - let intermediateValue = useRef(null); + let data = useListData({initialItems: defaultItems}); - let onChange = useCallback((id: Key, columnId: Key) => { - let value = intermediateValue.current; - if (value === null) { + 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; - }, []); + data.update(id, (prevItem) => ({...prevItem, [columnId]: value})); + }, [data]); return ( @@ -751,7 +743,7 @@ export default function EditableTable(props) { {column.name} )} - + {item => ( {(column) => { @@ -759,23 +751,30 @@ export default function EditableTable(props) { ///- begin highlight -/// return ( onChange(item.id, column.id!)} + 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={() => ( 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]} -
+ +
); ///- end highlight -/// @@ -786,14 +785,19 @@ export default function EditableTable(props) { onChange(item.id, column.id!)} + 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={() => ( onIntermediateChange(value)}> + name={column.id! as string}> Eva