Skip to content

Commit 324609f

Browse files
feat: Table inline editing polish (#9002)
* feat: Table inline editing polish * fix lint * fix styles and fix docs example * Update packages/dev/s2-docs/pages/s2/TableView.mdx Co-authored-by: Daniel Lu <dl1644@gmail.com> --------- Co-authored-by: Daniel Lu <dl1644@gmail.com>
1 parent 27edf8f commit 324609f

File tree

4 files changed

+230
-20
lines changed

4 files changed

+230
-20
lines changed

packages/@react-spectrum/s2/src/TableView.tsx

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export interface TableViewProps extends Omit<RACTableProps, 'style' | 'disabledB
122122
styles?: StylesPropWithHeight
123123
}
124124

125-
let InternalTableContext = createContext<TableViewProps & {layout?: S2TableLayout<unknown>, setIsInResizeMode?:(val: boolean) => void, isInResizeMode?: boolean}>({});
125+
let InternalTableContext = createContext<TableViewProps & {layout?: S2TableLayout<unknown>, setIsInResizeMode?:(val: boolean) => void, isInResizeMode?: boolean, selectionMode?: 'none' | 'single' | 'multiple'}>({});
126126

127127
const tableWrapper = style({
128128
minHeight: 0,
@@ -291,6 +291,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re
291291
onResizeEnd: propsOnResizeEnd,
292292
onAction,
293293
onLoadMore,
294+
selectionMode = 'none',
294295
...otherProps
295296
} = props;
296297

@@ -315,11 +316,12 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re
315316
loadingState,
316317
onLoadMore,
317318
isInResizeMode,
318-
setIsInResizeMode
319-
}), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode]);
319+
setIsInResizeMode,
320+
selectionMode
321+
}), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode, selectionMode]);
320322

321323
let scrollRef = useRef<HTMLElement | null>(null);
322-
let isCheckboxSelection = props.selectionMode === 'multiple' || props.selectionMode === 'single';
324+
let isCheckboxSelection = selectionMode === 'multiple' || selectionMode === 'single';
323325

324326
let {selectedKeys, onSelectionChange, actionBar, actionBarHeight} = useActionBarContainer({...props, scrollRef});
325327

@@ -362,6 +364,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re
362364
isQuiet
363365
})}
364366
selectionBehavior="toggle"
367+
selectionMode={selectionMode}
365368
onRowAction={onAction}
366369
{...otherProps}
367370
selectedKeys={selectedKeys}
@@ -1053,6 +1056,45 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef<HTMLD
10531056
);
10541057
});
10551058

1059+
1060+
const editableCell = style<CellRenderProps & S2TableProps & {isDivider: boolean, selectionMode?: 'none' | 'single' | 'multiple', isSaving?: boolean}>({
1061+
...commonCellStyles,
1062+
color: {
1063+
default: baseColor('neutral'),
1064+
isSaving: baseColor('neutral-subdued')
1065+
},
1066+
paddingY: centerPadding(),
1067+
boxSizing: 'border-box',
1068+
height: 'calc(100% - 1px)', // so we don't overlap the border of the next cell
1069+
width: 'full',
1070+
fontSize: controlFont(),
1071+
alignItems: 'center',
1072+
display: 'flex',
1073+
borderStyle: {
1074+
default: 'none',
1075+
isDivider: 'solid'
1076+
},
1077+
borderEndWidth: {
1078+
default: 0,
1079+
isDivider: 1
1080+
},
1081+
borderColor: {
1082+
default: 'gray-300',
1083+
forcedColors: 'ButtonBorder'
1084+
},
1085+
backgroundColor: {
1086+
default: 'transparent',
1087+
':is([role="rowheader"]:hover, [role="gridcell"]:hover)': {
1088+
selectionMode: {
1089+
none: colorMix('gray-25', 'gray-900', 7),
1090+
single: 'gray-25',
1091+
multiple: 'gray-25'
1092+
}
1093+
},
1094+
':is([role="row"][data-focus-visible-within] [role="rowheader"]:focus-within, [role="row"][data-focus-visible-within] [role="gridcell"]:focus-within)': 'gray-25'
1095+
}
1096+
});
1097+
10561098
let editPopover = style({
10571099
...colorScheme(),
10581100
'--s2-container-bg': {
@@ -1083,28 +1125,31 @@ let editPopover = style({
10831125
}, getAllowedOverrides());
10841126

10851127
interface EditableCellProps extends Omit<CellProps, 'isSticky'> {
1128+
/** The component which will handle editing the cell. For example, a `TextField` or a `Picker`. */
10861129
renderEditing: () => ReactNode,
1130+
/** Whether the cell is currently being saved. */
10871131
isSaving?: boolean,
1088-
onSubmit: () => void,
1089-
onCancel: () => void
1132+
/** Handler that is called when the value has been changed and is ready to be saved. */
1133+
onSubmit: () => void
10901134
}
10911135

10921136
/**
1093-
* An exditable cell within a table row.
1137+
* An editable cell within a table row.
10941138
*/
10951139
export const EditableCell = forwardRef(function EditableCell(props: EditableCellProps, ref: ForwardedRef<HTMLDivElement>) {
1096-
let {children, showDivider = false, textValue, ...otherProps} = props;
1140+
let {children, showDivider = false, textValue, isSaving, ...otherProps} = props;
10971141
let tableVisualOptions = useContext(InternalTableContext);
10981142
let domRef = useObjectRef(ref);
10991143
textValue ||= typeof children === 'string' ? children : undefined;
11001144

11011145
return (
11021146
<RACCell
11031147
ref={domRef}
1104-
className={renderProps => cell({
1148+
className={renderProps => editableCell({
11051149
...renderProps,
11061150
...tableVisualOptions,
1107-
isDivider: showDivider
1151+
isDivider: showDivider,
1152+
isSaving
11081153
})}
11091154
textValue={textValue}
11101155
{...otherProps}>
@@ -1128,7 +1173,7 @@ const nonTextInputTypes = new Set([
11281173
]);
11291174

11301175
function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, cellRef: RefObject<HTMLDivElement>}) {
1131-
let {children, align, renderEditing, isSaving, onSubmit, onCancel, isFocusVisible, cellRef} = props;
1176+
let {children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef} = props;
11321177
let [isOpen, setIsOpen] = useState(false);
11331178
let popoverRef = useRef<HTMLDivElement>(null);
11341179
let formRef = useRef<HTMLFormElement>(null);
@@ -1180,10 +1225,8 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean,
11801225
}
11811226
}, [isOpen]);
11821227

1183-
// Cancel, don't save the value
11841228
let cancel = () => {
11851229
setIsOpen(false);
1186-
onCancel();
11871230
};
11881231

11891232
return (
@@ -1202,6 +1245,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean,
12021245
styles: style({
12031246
// TODO: really need access to display here instead, but not possible right now
12041247
// will be addressable with displayOuter
1248+
// Could use `hidden` attribute instead of css, but I don't have access to much of this state at the moment
12051249
visibility: {
12061250
default: 'hidden',
12071251
isForcedVisible: 'visible',

packages/@react-spectrum/s2/stories/TableView.stories.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,7 +1497,6 @@ export const EditableTable: StoryObj<EditableTableProps> = {
14971497
align={column.align}
14981498
showDivider={column.showDivider}
14991499
onSubmit={() => onChange(item.id, column.id!)}
1500-
onCancel={() => {}}
15011500
isSaving={item.isSaving[column.id!]}
15021501
renderEditing={() => (
15031502
<TextField
@@ -1522,7 +1521,6 @@ export const EditableTable: StoryObj<EditableTableProps> = {
15221521
align={column.align}
15231522
showDivider={column.showDivider}
15241523
onSubmit={() => onChange(item.id, column.id!)}
1525-
onCancel={() => {}}
15261524
isSaving={item.isSaving[column.id!]}
15271525
renderEditing={() => (
15281526
<Picker
@@ -1628,7 +1626,6 @@ export const EditableTableWithAsyncSaving: StoryObj<EditableTableProps> = {
16281626
align={column.align}
16291627
showDivider={column.showDivider}
16301628
onSubmit={() => onChange(item.id, column.id!)}
1631-
onCancel={() => {}}
16321629
isSaving={item.isSaving[column.id!]}
16331630
renderEditing={() => (
16341631
<TextField
@@ -1649,7 +1646,6 @@ export const EditableTableWithAsyncSaving: StoryObj<EditableTableProps> = {
16491646
align={column.align}
16501647
showDivider={column.showDivider}
16511648
onSubmit={() => onChange(item.id, column.id!)}
1652-
onCancel={() => {}}
16531649
isSaving={item.isSaving[column.id!]}
16541650
renderEditing={() => (
16551651
<Picker

packages/@react-spectrum/s2/test/EditableTableView.test.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,6 @@ describe('TableView', () => {
179179
align={column.align}
180180
showDivider={column.showDivider}
181181
onSubmit={() => onChange(item.id, column.id!)}
182-
onCancel={() => {}}
183182
isSaving={item.isSaving[column.id!]}
184183
renderEditing={() => (
185184
<TextField
@@ -199,7 +198,6 @@ describe('TableView', () => {
199198
align={column.align}
200199
showDivider={column.showDivider}
201200
onSubmit={() => onChange(item.id, column.id!)}
202-
onCancel={() => {}}
203201
isSaving={item.isSaving[column.id!]}
204202
renderEditing={() => (
205203
<Picker

packages/dev/s2-docs/pages/s2/TableView.mdx

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
4949

5050
## Content
5151

52-
`TableView` follows the [Collection Components API](collections.html?component=Table), accepting both static and dynamic collections.
52+
`TableView` follows the [Collection Components API](collections.html?component=Table), accepting both static and dynamic collections.
5353
In this example, both the columns and the rows are provided to the table via a render function, enabling the user to hide and show columns and add additional rows.
5454

5555
```tsx render type="s2"
@@ -686,6 +686,174 @@ function subscribe(fn) {
686686
}
687687
```
688688

689+
## Editable Table
690+
691+
`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` .
692+
693+
An `ActionButton` with `slot="edit"` must be provided as a child of the `EditableCell` to open the popover.
694+
695+
```tsx render type="s2"
696+
"use client";
697+
import {TableView, TableHeader, Column, TableBody, Row, Cell, EditableCell, TextField, ActionButton, Picker, PickerItem, Text, type Key} from '@react-spectrum/s2';
698+
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
699+
import User from '@react-spectrum/s2/icons/User';
700+
import Edit from '@react-spectrum/s2/icons/Edit';
701+
import {useCallback,useRef, useState} from 'react';
702+
703+
///- begin collapse -///
704+
let defaultItems = [
705+
{id: 1, fruits: 'Apples', task: 'Collect', farmer: 'Eva'},
706+
{id: 2, fruits: 'Oranges', task: 'Collect', farmer: 'Steven'},
707+
{id: 3, fruits: 'Pears', task: 'Collect', farmer: 'Michael'},
708+
{id: 4, fruits: 'Cherries', task: 'Collect', farmer: 'Sara'},
709+
{id: 5, fruits: 'Dates', task: 'Collect', farmer: 'Karina'},
710+
{id: 6, fruits: 'Bananas', task: 'Collect', farmer: 'Otto'},
711+
{id: 7, fruits: 'Melons', task: 'Collect', farmer: 'Matt'},
712+
{id: 8, fruits: 'Figs', task: 'Collect', farmer: 'Emily'},
713+
{id: 9, fruits: 'Blueberries', task: 'Collect', farmer: 'Amelia'},
714+
{id: 10, fruits: 'Blackberries', task: 'Collect', farmer: 'Isla'}
715+
];
716+
///- end collapse -///
717+
718+
///- begin collapse -///
719+
let editableColumns: Array<Omit<ColumnProps, 'children'> & {name: string}> = [
720+
{name: 'Fruits', id: 'fruits', isRowHeader: true, width: '6fr', minWidth: 300},
721+
{name: 'Task', id: 'task', width: '2fr', minWidth: 100},
722+
{name: 'Farmer', id: 'farmer', width: '2fr', minWidth: 150}
723+
];
724+
///- end collapse -///
725+
726+
export default function EditableTable(props) {
727+
let columns = editableColumns;
728+
let [editableItems, setEditableItems] = useState(defaultItems);
729+
let intermediateValue = useRef<any>(null);
730+
731+
let onChange = useCallback((id: Key, columnId: Key) => {
732+
let value = intermediateValue.current;
733+
if (value === null) {
734+
return;
735+
}
736+
intermediateValue.current = null;
737+
setEditableItems(prev => {
738+
let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value} : i);
739+
return newItems;
740+
});
741+
}, []);
742+
743+
let onIntermediateChange = useCallback((value: any) => {
744+
intermediateValue.current = value;
745+
}, []);
746+
747+
return (
748+
<TableView aria-label="Dynamic table" {...props} styles={style({height: 208})}>
749+
<TableHeader columns={columns}>
750+
{(column) => (
751+
<Column {...column}>{column.name}</Column>
752+
)}
753+
</TableHeader>
754+
<TableBody items={editableItems}>
755+
{item => (
756+
<Row id={item.id} columns={columns}>
757+
{(column) => {
758+
if (column.id === 'fruits') {
759+
///- begin highlight -///
760+
return (
761+
<EditableCell
762+
align={column.align}
763+
showDivider={column.showDivider}
764+
onSubmit={() => onChange(item.id, column.id!)}
765+
renderEditing={() => (
766+
<TextField
767+
aria-label="Edit fruit"
768+
autoFocus
769+
validate={value => value.length > 0 ? null : 'Fruit name is required'}
770+
styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})}
771+
defaultValue={item[column.id!]}
772+
onChange={value => onIntermediateChange(value)} />
773+
)}>
774+
<div className={style({display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between'})}>
775+
{item[column.id]}
776+
<ActionButton slot="edit" aria-label="Edit fruit">
777+
<Edit />
778+
</ActionButton></div>
779+
</EditableCell>
780+
);
781+
///- end highlight -///
782+
}
783+
if (column.id === 'farmer') {
784+
///- begin highlight -///
785+
return (
786+
<EditableCell
787+
align={column.align}
788+
showDivider={column.showDivider}
789+
onSubmit={() => onChange(item.id, column.id!)}
790+
renderEditing={() => (
791+
<Picker
792+
aria-label="Edit farmer"
793+
autoFocus
794+
styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})}
795+
defaultValue={item[column.id!]}
796+
onChange={value => onIntermediateChange(value)}>
797+
<PickerItem textValue="Eva" id="Eva">
798+
<User />
799+
<Text>Eva</Text>
800+
</PickerItem>
801+
<PickerItem textValue="Steven" id="Steven">
802+
<User />
803+
<Text>Steven</Text>
804+
</PickerItem>
805+
<PickerItem textValue="Michael" id="Michael">
806+
<User />
807+
<Text>Michael</Text>
808+
</PickerItem>
809+
<PickerItem textValue="Sara" id="Sara">
810+
<User />
811+
<Text>Sara</Text>
812+
</PickerItem>
813+
<PickerItem textValue="Karina" id="Karina">
814+
<User />
815+
<Text>Karina</Text>
816+
</PickerItem>
817+
<PickerItem textValue="Otto" id="Otto">
818+
<User />
819+
<Text>Otto</Text>
820+
</PickerItem>
821+
<PickerItem textValue="Matt" id="Matt">
822+
<User />
823+
<Text>Matt</Text>
824+
</PickerItem>
825+
<PickerItem textValue="Emily" id="Emily">
826+
<User />
827+
<Text>Emily</Text>
828+
</PickerItem>
829+
<PickerItem textValue="Amelia" id="Amelia">
830+
<User />
831+
<Text>Amelia</Text>
832+
</PickerItem>
833+
<PickerItem textValue="Isla" id="Isla">
834+
<User />
835+
<Text>Isla</Text>
836+
</PickerItem>
837+
</Picker>
838+
)}>
839+
<div className={style({display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between'})}>
840+
{item[column.id]}
841+
<ActionButton slot="edit" aria-label="Edit fruit"><Edit /></ActionButton>
842+
</div>
843+
</EditableCell>
844+
);
845+
///- end highlight -///
846+
}
847+
return <Cell align={column.align} showDivider={column.showDivider}>{item[column.id!]}</Cell>;
848+
}}
849+
</Row>
850+
)}
851+
</TableBody>
852+
</TableView>
853+
);
854+
}
855+
```
856+
689857
## API
690858

691859
```tsx links={{TableView: '#tableview', TableHeader: '#tableheader', Column: '#column', TableBody: '#tablebody', Row: '#row', Cell: '#cell'}}
@@ -724,3 +892,7 @@ function subscribe(fn) {
724892
### Cell
725893

726894
<PropTable component={docs.exports.Cell} links={docs.links} showDescription />
895+
896+
### EditableCell
897+
898+
<PropTable component={docs.exports.EditableCell} links={docs.links} showDescription />

0 commit comments

Comments
 (0)