Skip to content

Commit c71aa94

Browse files
authored
fix: EditableTable Cells from testing feedback (#9108)
* fix EditableTable Cells from testing feedback * fix Esc handling * remove console.log * add onCancel, use list data, move submit responsibilities * add tests for action * fix TS * fix more types * fix media query for isMobile detection * invert and move not out of media query
1 parent 91022f0 commit c71aa94

File tree

5 files changed

+649
-197
lines changed

5 files changed

+649
-197
lines changed

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

Lines changed: 108 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,14 @@ import {
5050
useTableOptions,
5151
Virtualizer
5252
} from 'react-aria-components';
53+
import {ButtonGroup} from './ButtonGroup';
5354
import {centerPadding, colorScheme, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'};
5455
import {Checkbox} from './Checkbox';
5556
import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg';
5657
import Chevron from '../ui-icons/Chevron';
5758
import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg';
5859
import {ColumnSize} from '@react-types/table';
60+
import {CustomDialog, DialogContainer} from '..';
5961
import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LoadingState, Node} from '@react-types/shared';
6062
import {getActiveElement, getOwnerDocument, useLayoutEffect, useObjectRef} from '@react-aria/utils';
6163
import {GridNode} from '@react-types/grid';
@@ -67,11 +69,12 @@ import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu';
6769
import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg';
6870
import {ProgressCircle} from './ProgressCircle';
6971
import {raw} from '../style/style-macro' with {type: 'macro'};
70-
import React, {createContext, CSSProperties, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, 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';
7173
import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg';
7274
import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg';
75+
import {Button as SpectrumButton} from './Button';
7376
import {useActionBarContainer} from './ActionBar';
74-
import {useDOMRef} from '@react-spectrum/utils';
77+
import {useDOMRef, useMediaQuery} from '@react-spectrum/utils';
7578
import {useLocalizedStringFormatter} from '@react-aria/i18n';
7679
import {useScale} from './utils';
7780
import {useSpectrumContextProps} from './useSpectrumContextProps';
@@ -1081,17 +1084,6 @@ const editableCell = style<CellRenderProps & S2TableProps & {isDivider: boolean,
10811084
borderColor: {
10821085
default: 'gray-300',
10831086
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'
10951087
}
10961088
});
10971089

@@ -1130,7 +1122,11 @@ interface EditableCellProps extends Omit<CellProps, 'isSticky'> {
11301122
/** Whether the cell is currently being saved. */
11311123
isSaving?: boolean,
11321124
/** Handler that is called when the value has been changed and is ready to be saved. */
1133-
onSubmit: () => void
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']
11341130
}
11351131

11361132
/**
@@ -1173,7 +1169,7 @@ const nonTextInputTypes = new Set([
11731169
]);
11741170

11751171
function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, cellRef: RefObject<HTMLDivElement>}) {
1176-
let {children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef} = props;
1172+
let {children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef, action, onCancel} = props;
11771173
let [isOpen, setIsOpen] = useState(false);
11781174
let popoverRef = useRef<HTMLDivElement>(null);
11791175
let formRef = useRef<HTMLFormElement>(null);
@@ -1182,6 +1178,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean,
11821178
let [verticalOffset, setVerticalOffset] = useState(0);
11831179
let tableVisualOptions = useContext(InternalTableContext);
11841180
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');
1181+
let dialogRef = useRef<DOMRefValue<HTMLElement>>(null);
11851182

11861183
let {density} = useContext(InternalTableContext);
11871184
let size: 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = 'M';
@@ -1225,9 +1222,32 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean,
12251222
}
12261223
}, [isOpen]);
12271224

1228-
let cancel = () => {
1225+
let cancel = useCallback(() => {
12291226
setIsOpen(false);
1230-
};
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]);
12311251

12321252
return (
12331253
<Provider
@@ -1265,53 +1285,81 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean,
12651285
values={[
12661286
[ActionButtonContext, null]
12671287
]}>
1268-
<RACPopover
1269-
isOpen={isOpen}
1270-
onOpenChange={setIsOpen}
1271-
ref={popoverRef}
1272-
shouldCloseOnInteractOutside={() => {
1273-
if (!popoverRef.current?.contains(document.activeElement)) {
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();
12741298
return false;
1275-
}
1276-
formRef.current?.requestSubmit();
1277-
return false;
1278-
}}
1279-
triggerRef={cellRef}
1280-
aria-label={stringFormatter.format('table.editCell')}
1281-
offset={verticalOffset}
1282-
placement="bottom start"
1283-
style={{
1284-
minWidth: `min(${triggerWidth}px, ${tableWidth}px)`,
1285-
maxWidth: `${tableWidth}px`,
1286-
// Override default z-index from useOverlayPosition. We use isolation: isolate instead.
1287-
zIndex: undefined
1288-
}}
1289-
className={editPopover}>
1290-
<Provider
1291-
values={[
1292-
[OverlayTriggerStateContext, null]
1293-
]}>
1294-
<Form
1295-
ref={formRef}
1296-
onSubmit={(e) => {
1297-
e.preventDefault();
1298-
onSubmit();
1299-
setIsOpen(false);
1300-
}}
1301-
className={style({width: 'full', display: 'flex', alignItems: 'start', gap: 16})}
1302-
style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}>
1303-
{renderEditing()}
1304-
<div className={style({display: 'flex', flexDirection: 'row', alignItems: 'baseline', flexShrink: 0, flexGrow: 0})}>
1305-
<ActionButton isQuiet onPress={cancel} aria-label={stringFormatter.format('table.cancel')}><Close /></ActionButton>
1306-
<ActionButton isQuiet type="submit" aria-label={stringFormatter.format('table.save')}><Checkmark /></ActionButton>
1307-
</div>
1308-
</Form>
1309-
</Provider>
1310-
</RACPopover>
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+
)}
13111359
</Provider>
13121360
</Provider>
13131361
);
1314-
}
1362+
};
13151363

13161364
// Use color-mix instead of transparency so sticky cells work correctly.
13171365
const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), colorMix('gray-25', 'informative-700', 10));

0 commit comments

Comments
 (0)