From 69ff46cdba5eb6088a9ec0e1a2a38d70a059a59c Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 9 Oct 2025 15:52:07 +1100 Subject: [PATCH 1/4] feat: Table inline editing polish --- packages/@react-spectrum/s2/src/TableView.tsx | 64 +++++++-- .../s2/stories/TableView.stories.tsx | 4 - packages/dev/s2-docs/pages/s2/TableView.mdx | 136 +++++++++++++++++- 3 files changed, 187 insertions(+), 17 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 9cc6791d954..5d3ce865bed 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -122,7 +122,7 @@ export interface TableViewProps extends Omit, setIsInResizeMode?:(val: boolean) => void, isInResizeMode?: boolean}>({}); +let InternalTableContext = createContext, setIsInResizeMode?:(val: boolean) => void, isInResizeMode?: boolean, selectionMode?: 'none' | 'single' | 'multiple'}>({}); const tableWrapper = style({ minHeight: 0, @@ -291,6 +291,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re onResizeEnd: propsOnResizeEnd, onAction, onLoadMore, + selectionMode = 'none', ...otherProps } = props; @@ -315,11 +316,12 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re loadingState, onLoadMore, isInResizeMode, - setIsInResizeMode - }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode]); + setIsInResizeMode, + selectionMode + }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode, selectionMode]); let scrollRef = useRef(null); - let isCheckboxSelection = props.selectionMode === 'multiple' || props.selectionMode === 'single'; + let isCheckboxSelection = selectionMode === 'multiple' || selectionMode === 'single'; let {selectedKeys, onSelectionChange, actionBar, actionBarHeight} = useActionBarContainer({...props, scrollRef}); @@ -362,6 +364,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re isQuiet })} selectionBehavior="toggle" + selectionMode={selectionMode} onRowAction={onAction} {...otherProps} selectedKeys={selectedKeys} @@ -1053,6 +1056,45 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef({ + ...commonCellStyles, + color: { + default: baseColor('neutral'), + isSaving: baseColor('neutral-subdued') + }, + paddingY: centerPadding(), + boxSizing: 'border-box', + height: 'calc(100% - 1px)', // so we don't overlap the border of the next cell + width: 'full', + fontSize: controlFont(), + alignItems: 'center', + display: 'flex', + borderStyle: { + default: 'none', + isDivider: 'solid' + }, + borderEndWidth: { + default: 0, + isDivider: 1 + }, + borderColor: { + default: 'gray-300', + forcedColors: 'ButtonBorder' + }, + backgroundColor: { + default: 'transparent', + ':is([role="rowheader"]:hover, [role="gridcell"]:hover)': { + selectionMode: { + none: colorMix('gray-25', 'gray-900', 7), + single: '--s2-container-bg', + multiple: '--s2-container-bg' + } + }, + ':is([role="row"][data-focus-visible-within] [role="rowheader"]:focus-within, [role="row"][data-focus-visible-within] [role="gridcell"]:focus-within)': '--s2-container-bg' + } +}); + let editPopover = style({ ...colorScheme(), '--s2-container-bg': { @@ -1085,15 +1127,14 @@ let editPopover = style({ interface EditableCellProps extends Omit { renderEditing: () => ReactNode, isSaving?: boolean, - onSubmit: () => void, - onCancel: () => void + onSubmit: () => void } /** * An exditable cell within a table row. */ export const EditableCell = forwardRef(function EditableCell(props: EditableCellProps, ref: ForwardedRef) { - let {children, showDivider = false, textValue, ...otherProps} = props; + let {children, showDivider = false, textValue, isSaving, ...otherProps} = props; let tableVisualOptions = useContext(InternalTableContext); let domRef = useObjectRef(ref); textValue ||= typeof children === 'string' ? children : undefined; @@ -1101,10 +1142,11 @@ export const EditableCell = forwardRef(function EditableCell(props: EditableCell return ( cell({ + className={renderProps => editableCell({ ...renderProps, ...tableVisualOptions, - isDivider: showDivider + isDivider: showDivider, + isSaving })} textValue={textValue} {...otherProps}> @@ -1128,7 +1170,7 @@ const nonTextInputTypes = new Set([ ]); function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, cellRef: RefObject}) { - let {children, align, renderEditing, isSaving, onSubmit, onCancel, isFocusVisible, cellRef} = props; + let {children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef} = props; let [isOpen, setIsOpen] = useState(false); let popoverRef = useRef(null); let formRef = useRef(null); @@ -1180,10 +1222,8 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, } }, [isOpen]); - // Cancel, don't save the value let cancel = () => { setIsOpen(false); - onCancel(); }; return ( diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 045b6a4ecf6..2fd5b40815b 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -1497,7 +1497,6 @@ export const EditableTable: StoryObj = { align={column.align} showDivider={column.showDivider} onSubmit={() => onChange(item.id, column.id!)} - onCancel={() => {}} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { align={column.align} showDivider={column.showDivider} onSubmit={() => onChange(item.id, column.id!)} - onCancel={() => {}} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { align={column.align} showDivider={column.showDivider} onSubmit={() => onChange(item.id, column.id!)} - onCancel={() => {}} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { align={column.align} showDivider={column.showDivider} onSubmit={() => onChange(item.id, column.id!)} - onCancel={() => {}} isSaving={item.isSaving[column.id!]} renderEditing={() => ( & {name: string}> = [ + {name: 'Fruits', id: 'fruits', isRowHeader: true, width: '6fr', minWidth: 300}, + {name: 'Task', id: 'task', width: '2fr', minWidth: 100}, + {name: 'Farmer', id: 'farmer', width: '2fr', minWidth: 150} +]; + +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; + 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 ( +
+ + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.id === 'fruits') { + ///- begin highlight -/// + return ( + onChange(item.id, column.id!)} + 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)} /> + )}> +
+ {item[column.id]} + + +
+
+ ); + ///- end highlight -/// + } + if (column.id === 'farmer') { + ///- begin highlight -/// + return ( + onChange(item.id, column.id!)} + isSaving={item.isSaving[column.id!]} + renderEditing={() => ( + onIntermediateChange(value)}> + Eva + Steven + Michael + Sara + Karina + Otto + Matt + Emily + Amelia + Isla + + )}> +
+ {item[column.id]} + +
+
+ ); + ///- end highlight -/// + } + return {item[column.id!]}; + }} +
+ )} +
+
+
+ ); +} +``` + ## API ```tsx links={{TableView: '#tableview', TableHeader: '#tableheader', Column: '#column', TableBody: '#tablebody', Row: '#row', Cell: '#cell'}} From 5e04fb6b6defe046f29cc33e2b95cd763552b3b2 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 9 Oct 2025 15:58:41 +1100 Subject: [PATCH 2/4] fix lint --- packages/@react-spectrum/s2/src/TableView.tsx | 1 + packages/@react-spectrum/s2/test/EditableTableView.test.tsx | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 5d3ce865bed..029145a0d1b 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -1242,6 +1242,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, styles: style({ // TODO: really need access to display here instead, but not possible right now // will be addressable with displayOuter + // Could use `hidden` attribute instead of css, but I don't have access to much of this state at the moment visibility: { default: 'hidden', isForcedVisible: 'visible', diff --git a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx index 745e36b9959..e56fa3a5827 100644 --- a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx +++ b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx @@ -179,7 +179,6 @@ describe('TableView', () => { align={column.align} showDivider={column.showDivider} onSubmit={() => onChange(item.id, column.id!)} - onCancel={() => {}} isSaving={item.isSaving[column.id!]} renderEditing={() => ( { align={column.align} showDivider={column.showDivider} onSubmit={() => onChange(item.id, column.id!)} - onCancel={() => {}} isSaving={item.isSaving[column.id!]} renderEditing={() => ( Date: Thu, 9 Oct 2025 17:50:43 +1100 Subject: [PATCH 3/4] fix styles and fix docs example --- packages/@react-spectrum/s2/src/TableView.tsx | 11 +- packages/dev/s2-docs/pages/s2/TableView.mdx | 200 +++++++++++------- 2 files changed, 127 insertions(+), 84 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 029145a0d1b..a12694692dd 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -1087,11 +1087,11 @@ const editableCell = style { + /** The component which will handle editing the cell. For example, a `TextField` or a `Picker`. */ renderEditing: () => ReactNode, + /** 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 } /** - * An exditable cell within a table row. + * An editable cell within a table row. */ export const EditableCell = forwardRef(function EditableCell(props: EditableCellProps, ref: ForwardedRef) { let {children, showDivider = false, textValue, isSaving, ...otherProps} = props; diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index 1b6fcf35afc..20a89a2f674 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -688,10 +688,19 @@ function subscribe(fn) { ## Editable Table +`EditableCell` represents an editable value in a single cell. It opens a popover when the end user clicks the user provided `ActionButton` that can contain any editable input or combination of inputs. + +The `EditableCell` relies on form validation to ensure the value is valid before closing the popover. + +An `ActionButton` with `slot="edit"` must be provided as a child of the `EditableCell` to open the popover. + ```tsx render type="s2" "use client"; -import {TableView, TableHeader, Column, TableBody, Row, Cell} from '@react-spectrum/s2'; +import {TableView, TableHeader, Column, TableBody, Row, Cell, EditableCell, TextField, ActionButton, Picker, PickerItem, Text, type Key} from '@react-spectrum/s2'; 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'; ///- begin collapse -/// let defaultItems = [ @@ -714,6 +723,7 @@ let editableColumns: Array & {name: string}> = [ {name: 'Task', id: 'task', width: '2fr', minWidth: 100}, {name: 'Farmer', id: 'farmer', width: '2fr', minWidth: 150} ]; +///- end collapse -/// export default function EditableTable(props) { let columns = editableColumns; @@ -737,85 +747,111 @@ export default function EditableTable(props) { }, []); return ( -
- - - {(column) => ( - {column.name} - )} - - - {item => ( - - {(column) => { - if (column.id === 'fruits') { - ///- begin highlight -/// - return ( - onChange(item.id, column.id!)} - 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)} /> - )}> -
- {item[column.id]} - - -
-
- ); - ///- end highlight -/// - } - if (column.id === 'farmer') { - ///- begin highlight -/// - return ( - onChange(item.id, column.id!)} - isSaving={item.isSaving[column.id!]} - renderEditing={() => ( - onIntermediateChange(value)}> - Eva - Steven - Michael - Sara - Karina - Otto - Matt - Emily - Amelia - Isla - - )}> -
- {item[column.id]} - -
-
- ); - ///- end highlight -/// - } - return {item[column.id!]}; - }} -
- )} -
-
-
+ + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.id === 'fruits') { + ///- begin highlight -/// + return ( + onChange(item.id, 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)} /> + )}> +
+ {item[column.id]} + + +
+
+ ); + ///- end highlight -/// + } + if (column.id === 'farmer') { + ///- begin highlight -/// + return ( + onChange(item.id, column.id!)} + renderEditing={() => ( + onIntermediateChange(value)}> + + + Eva + + + + Steven + + + + Michael + + + + Sara + + + + Karina + + + + Otto + + + + Matt + + + + Emily + + + + Amelia + + + + Isla + + + )}> +
+ {item[column.id]} + +
+
+ ); + ///- end highlight -/// + } + return {item[column.id!]}; + }} +
+ )} +
+
); } ``` @@ -858,3 +894,7 @@ export default function EditableTable(props) { ### Cell + +### EditableCell + + \ No newline at end of file From a21f57d583c3a3e071ab6ff22d28a8391fc1ed14 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 21 Oct 2025 06:44:35 +1100 Subject: [PATCH 4/4] Update packages/dev/s2-docs/pages/s2/TableView.mdx Co-authored-by: Daniel Lu --- packages/dev/s2-docs/pages/s2/TableView.mdx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index 20a89a2f674..1d884e3c76f 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -688,9 +688,7 @@ function subscribe(fn) { ## Editable Table -`EditableCell` represents an editable value in a single cell. It opens a popover when the end user clicks the user provided `ActionButton` that can contain any editable input or combination of inputs. - -The `EditableCell` relies on form validation to ensure the value is valid before closing the popover. +`EditableCell` represents an editable value in a single cell. It opens a popover that can contain any editable input or combination of inputs when the end user clicks the user provided `ActionButton` . An `ActionButton` with `slot="edit"` must be provided as a child of the `EditableCell` to open the popover.