Skip to content

Commit 8ebc9f4

Browse files
authored
Revert "chore: remove editable cell (#9179)" (#9210)
This reverts commit 902fd02.
1 parent 142e82e commit 8ebc9f4

File tree

5 files changed

+1677
-7
lines changed

5 files changed

+1677
-7
lines changed

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

Lines changed: 317 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,27 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {ActionButton, ActionButtonContext} from './ActionButton';
1314
import {baseColor, colorMix, focusRing, fontRelative, lightDark, space, style} from '../style' with {type: 'macro'};
1415
import {
1516
Button,
17+
ButtonContext,
1618
CellRenderProps,
1719
Collection,
1820
ColumnRenderProps,
1921
ColumnResizer,
2022
ContextValue,
23+
DEFAULT_SLOT,
24+
Form,
2125
Key,
26+
OverlayTriggerStateContext,
2227
Provider,
2328
Cell as RACCell,
2429
CellProps as RACCellProps,
2530
CheckboxContext as RACCheckboxContext,
2631
Column as RACColumn,
2732
ColumnProps as RACColumnProps,
33+
Popover as RACPopover,
2834
Row as RACRow,
2935
RowProps as RACRowProps,
3036
Table as RACTable,
@@ -44,11 +50,16 @@ import {
4450
useTableOptions,
4551
Virtualizer
4652
} from 'react-aria-components';
47-
import {centerPadding, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'};
53+
import {ButtonGroup} from './ButtonGroup';
54+
import {centerPadding, colorScheme, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'};
4855
import {Checkbox} from './Checkbox';
56+
import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg';
4957
import Chevron from '../ui-icons/Chevron';
58+
import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg';
5059
import {ColumnSize} from '@react-types/table';
60+
import {CustomDialog, DialogContainer} from '..';
5161
import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LoadingState, Node} from '@react-types/shared';
62+
import {getActiveElement, getOwnerDocument, useLayoutEffect, useObjectRef} from '@react-aria/utils';
5263
import {GridNode} from '@react-types/grid';
5364
import {IconContext} from './Icon';
5465
// @ts-ignore
@@ -58,11 +69,12 @@ import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu';
5869
import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg';
5970
import {ProgressCircle} from './ProgressCircle';
6071
import {raw} from '../style/style-macro' with {type: 'macro'};
61-
import React, {createContext, forwardRef, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react';
72+
import React, {createContext, CSSProperties, FormEvent, FormHTMLAttributes, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
6273
import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg';
6374
import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg';
75+
import {Button as SpectrumButton} from './Button';
6476
import {useActionBarContainer} from './ActionBar';
65-
import {useDOMRef} from '@react-spectrum/utils';
77+
import {useDOMRef, useMediaQuery} from '@react-spectrum/utils';
6678
import {useLocalizedStringFormatter} from '@react-aria/i18n';
6779
import {useScale} from './utils';
6880
import {useSpectrumContextProps} from './useSpectrumContextProps';
@@ -1047,6 +1059,308 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef<HTMLD
10471059
);
10481060
});
10491061

1062+
1063+
const editableCell = style<CellRenderProps & S2TableProps & {isDivider: boolean, selectionMode?: 'none' | 'single' | 'multiple', isSaving?: boolean}>({
1064+
...commonCellStyles,
1065+
color: {
1066+
default: baseColor('neutral'),
1067+
isSaving: baseColor('neutral-subdued')
1068+
},
1069+
paddingY: centerPadding(),
1070+
boxSizing: 'border-box',
1071+
height: 'calc(100% - 1px)', // so we don't overlap the border of the next cell
1072+
width: 'full',
1073+
fontSize: controlFont(),
1074+
alignItems: 'center',
1075+
display: 'flex',
1076+
borderStyle: {
1077+
default: 'none',
1078+
isDivider: 'solid'
1079+
},
1080+
borderEndWidth: {
1081+
default: 0,
1082+
isDivider: 1
1083+
},
1084+
borderColor: {
1085+
default: 'gray-300',
1086+
forcedColors: 'ButtonBorder'
1087+
}
1088+
});
1089+
1090+
let editPopover = style({
1091+
...colorScheme(),
1092+
'--s2-container-bg': {
1093+
type: 'backgroundColor',
1094+
value: 'layer-2'
1095+
},
1096+
backgroundColor: '--s2-container-bg',
1097+
borderBottomRadius: 'default',
1098+
// Use box-shadow instead of filter when an arrow is not shown.
1099+
// This fixes the shadow stacking problem with submenus.
1100+
boxShadow: 'elevated',
1101+
borderStyle: 'solid',
1102+
borderWidth: 1,
1103+
borderColor: {
1104+
default: 'gray-200',
1105+
forcedColors: 'ButtonBorder'
1106+
},
1107+
boxSizing: 'content-box',
1108+
isolation: 'isolate',
1109+
pointerEvents: {
1110+
isExiting: 'none'
1111+
},
1112+
outlineStyle: 'none',
1113+
minWidth: '--trigger-width',
1114+
padding: 8,
1115+
display: 'flex',
1116+
alignItems: 'center'
1117+
}, getAllowedOverrides());
1118+
1119+
interface EditableCellProps extends Omit<CellProps, 'isSticky'> {
1120+
/** The component which will handle editing the cell. For example, a `TextField` or a `Picker`. */
1121+
renderEditing: () => ReactNode,
1122+
/** Whether the cell is currently being saved. */
1123+
isSaving?: boolean,
1124+
/** Handler that is called when the value has been changed and is ready to be saved. */
1125+
onSubmit?: (e: FormEvent<HTMLFormElement>) => void,
1126+
/** Handler that is called when the user cancels the edit. */
1127+
onCancel?: () => void,
1128+
/** The action to submit the form to. Only available in React 19+. */
1129+
action?: string | FormHTMLAttributes<HTMLFormElement>['action']
1130+
}
1131+
1132+
/**
1133+
* An editable cell within a table row.
1134+
*/
1135+
export const EditableCell = forwardRef(function EditableCell(props: EditableCellProps, ref: ForwardedRef<HTMLDivElement>) {
1136+
let {children, showDivider = false, textValue, isSaving, ...otherProps} = props;
1137+
let tableVisualOptions = useContext(InternalTableContext);
1138+
let domRef = useObjectRef(ref);
1139+
textValue ||= typeof children === 'string' ? children : undefined;
1140+
1141+
return (
1142+
<RACCell
1143+
ref={domRef}
1144+
className={renderProps => editableCell({
1145+
...renderProps,
1146+
...tableVisualOptions,
1147+
isDivider: showDivider,
1148+
isSaving
1149+
})}
1150+
textValue={textValue}
1151+
{...otherProps}>
1152+
{({isFocusVisible}) => (
1153+
<EditableCellInner {...props} isFocusVisible={isFocusVisible} cellRef={domRef as RefObject<HTMLDivElement>} />
1154+
)}
1155+
</RACCell>
1156+
);
1157+
});
1158+
1159+
const nonTextInputTypes = new Set([
1160+
'checkbox',
1161+
'radio',
1162+
'range',
1163+
'color',
1164+
'file',
1165+
'image',
1166+
'button',
1167+
'submit',
1168+
'reset'
1169+
]);
1170+
1171+
function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, cellRef: RefObject<HTMLDivElement>}) {
1172+
let {children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef, action, onCancel} = props;
1173+
let [isOpen, setIsOpen] = useState(false);
1174+
let popoverRef = useRef<HTMLDivElement>(null);
1175+
let formRef = useRef<HTMLFormElement>(null);
1176+
let [triggerWidth, setTriggerWidth] = useState(0);
1177+
let [tableWidth, setTableWidth] = useState(0);
1178+
let [verticalOffset, setVerticalOffset] = useState(0);
1179+
let tableVisualOptions = useContext(InternalTableContext);
1180+
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');
1181+
let dialogRef = useRef<DOMRefValue<HTMLElement>>(null);
1182+
1183+
let {density} = useContext(InternalTableContext);
1184+
let size: 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = 'M';
1185+
if (density === 'compact') {
1186+
size = 'S';
1187+
} else if (density === 'spacious') {
1188+
size = 'L';
1189+
}
1190+
1191+
// Popover positioning
1192+
useLayoutEffect(() => {
1193+
if (!isOpen) {
1194+
return;
1195+
}
1196+
let width = cellRef.current?.clientWidth || 0;
1197+
let cell = cellRef.current;
1198+
let boundingRect = cell?.parentElement?.getBoundingClientRect();
1199+
let verticalOffset = (boundingRect?.top ?? 0) - (boundingRect?.bottom ?? 0);
1200+
1201+
let tableWidth = cellRef.current?.closest('[role="grid"]')?.clientWidth || 0;
1202+
setTriggerWidth(width);
1203+
setVerticalOffset(verticalOffset);
1204+
setTableWidth(tableWidth);
1205+
}, [cellRef, density, isOpen]);
1206+
1207+
// Auto select the entire text range of the autofocused input on overlay opening
1208+
// Maybe replace with FocusScope or one of those utilities
1209+
useEffect(() => {
1210+
if (isOpen) {
1211+
let activeElement = getActiveElement(getOwnerDocument(formRef.current));
1212+
if (activeElement
1213+
&& formRef.current?.contains(activeElement)
1214+
// not going to handle contenteditable https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element
1215+
// seems like an edge case anyways
1216+
&& (
1217+
(activeElement instanceof HTMLInputElement && !nonTextInputTypes.has(activeElement.type))
1218+
|| activeElement instanceof HTMLTextAreaElement)
1219+
&& typeof activeElement.select === 'function') {
1220+
activeElement.select();
1221+
}
1222+
}
1223+
}, [isOpen]);
1224+
1225+
let cancel = useCallback(() => {
1226+
setIsOpen(false);
1227+
onCancel?.();
1228+
}, [onCancel]);
1229+
1230+
let isMobile = !useMediaQuery('(hover: hover) and (pointer: fine)');
1231+
// Can't differentiate between Dialog click outside dismissal and Escape key dismissal
1232+
let prevIsOpen = useRef(isOpen);
1233+
useEffect(() => {
1234+
let dialog = dialogRef.current?.UNSAFE_getDOMNode();
1235+
if (isOpen && dialog && !prevIsOpen.current) {
1236+
let handler = (e: KeyboardEvent) => {
1237+
if (e.key === 'Escape') {
1238+
cancel();
1239+
e.stopPropagation();
1240+
e.preventDefault();
1241+
}
1242+
};
1243+
dialog.addEventListener('keydown', handler);
1244+
prevIsOpen.current = isOpen;
1245+
return () => {
1246+
dialog.removeEventListener('keydown', handler);
1247+
};
1248+
}
1249+
prevIsOpen.current = isOpen;
1250+
}, [isOpen, cancel]);
1251+
1252+
return (
1253+
<Provider
1254+
values={[
1255+
[ButtonContext, null],
1256+
[ActionButtonContext, {
1257+
slots: {
1258+
[DEFAULT_SLOT]: {},
1259+
edit: {
1260+
onPress: () => setIsOpen(true),
1261+
isPending: isSaving,
1262+
isQuiet: !isSaving,
1263+
size,
1264+
excludeFromTabOrder: true,
1265+
styles: style({
1266+
// TODO: really need access to display here instead, but not possible right now
1267+
// will be addressable with displayOuter
1268+
// Could use `hidden` attribute instead of css, but I don't have access to much of this state at the moment
1269+
visibility: {
1270+
default: 'hidden',
1271+
isForcedVisible: 'visible',
1272+
':is([role="row"]:hover *)': 'visible',
1273+
':is([role="row"][data-focus-visible-within] *)': 'visible',
1274+
'@media not ((hover: hover) and (pointer: fine))': 'visible'
1275+
}
1276+
})({isForcedVisible: isOpen || !!isSaving})
1277+
}
1278+
}
1279+
}]
1280+
]}>
1281+
<span className={cellContent({...tableVisualOptions, align: align || 'start'})}>{children}</span>
1282+
{isFocusVisible && <CellFocusRing />}
1283+
1284+
<Provider
1285+
values={[
1286+
[ActionButtonContext, null]
1287+
]}>
1288+
{!isMobile && (
1289+
<RACPopover
1290+
isOpen={isOpen}
1291+
onOpenChange={setIsOpen}
1292+
ref={popoverRef}
1293+
shouldCloseOnInteractOutside={() => {
1294+
if (!popoverRef.current?.contains(document.activeElement)) {
1295+
return false;
1296+
}
1297+
formRef.current?.requestSubmit();
1298+
return false;
1299+
}}
1300+
triggerRef={cellRef}
1301+
aria-label={props['aria-label'] ?? stringFormatter.format('table.editCell')}
1302+
offset={verticalOffset}
1303+
placement="bottom start"
1304+
style={{
1305+
minWidth: `min(${triggerWidth}px, ${tableWidth}px)`,
1306+
maxWidth: `${tableWidth}px`,
1307+
// Override default z-index from useOverlayPosition. We use isolation: isolate instead.
1308+
zIndex: undefined
1309+
}}
1310+
className={editPopover}>
1311+
<Provider
1312+
values={[
1313+
[OverlayTriggerStateContext, null]
1314+
]}>
1315+
<Form
1316+
ref={formRef}
1317+
action={action}
1318+
onSubmit={(e) => {
1319+
onSubmit?.(e);
1320+
setIsOpen(false);
1321+
}}
1322+
className={style({width: 'full', display: 'flex', alignItems: 'start', gap: 16})}
1323+
style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}>
1324+
{renderEditing()}
1325+
<div className={style({display: 'flex', flexDirection: 'row', alignItems: 'baseline', flexShrink: 0, flexGrow: 0})}>
1326+
<ActionButton isQuiet onPress={cancel} aria-label={stringFormatter.format('table.cancel')}><Close /></ActionButton>
1327+
<ActionButton isQuiet type="submit" aria-label={stringFormatter.format('table.save')}><Checkmark /></ActionButton>
1328+
</div>
1329+
</Form>
1330+
</Provider>
1331+
</RACPopover>
1332+
)}
1333+
{isMobile && (
1334+
<DialogContainer onDismiss={() => formRef.current?.requestSubmit()}>
1335+
{isOpen && (
1336+
<CustomDialog
1337+
ref={dialogRef}
1338+
isDismissible
1339+
isKeyboardDismissDisabled
1340+
aria-label={props['aria-label'] ?? stringFormatter.format('table.editCell')}>
1341+
<Form
1342+
ref={formRef}
1343+
action={action}
1344+
onSubmit={(e) => {
1345+
onSubmit?.(e);
1346+
setIsOpen(false);
1347+
}}
1348+
className={style({width: 'full', display: 'flex', flexDirection: 'column', alignItems: 'start', gap: 16})}>
1349+
{renderEditing()}
1350+
<ButtonGroup align="end" styles={style({alignSelf: 'end'})}>
1351+
<SpectrumButton onPress={cancel} variant="secondary" fillStyle="outline">Cancel</SpectrumButton>
1352+
<SpectrumButton type="submit" variant="accent">Save</SpectrumButton>
1353+
</ButtonGroup>
1354+
</Form>
1355+
</CustomDialog>
1356+
)}
1357+
</DialogContainer>
1358+
)}
1359+
</Provider>
1360+
</Provider>
1361+
);
1362+
};
1363+
10501364
// Use color-mix instead of transparency so sticky cells work correctly.
10511365
const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), colorMix('gray-25', 'informative-700', 10));
10521366
const selectedActiveBackground = lightDark(colorMix('gray-25', 'informative-900', 15), colorMix('gray-25', 'informative-700', 15));

packages/@react-spectrum/s2/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export {Skeleton, useIsSkeleton} from './Skeleton';
7878
export {SkeletonCollection} from './SkeletonCollection';
7979
export {StatusLight, StatusLightContext} from './StatusLight';
8080
export {Switch, SwitchContext} from './Switch';
81-
export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext} from './TableView';
81+
export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext, EditableCell} from './TableView';
8282
export {Tabs, TabList, Tab, TabPanel, TabsContext} from './Tabs';
8383
export {TagGroup, Tag, TagGroupContext} from './TagGroup';
8484
export {TextArea, TextField, TextAreaContext, TextFieldContext} from './TextField';

0 commit comments

Comments
 (0)