Skip to content

Commit de7e167

Browse files
authored
feat: table "inline" editing (#8754)
* table inline editing * remove extra exports * Add extra controls for different interactions, mobile, inline save, invalid state, click off * fix lint * Add fake saving logic * use better color and fix flex grow * add back hiding logic * simplify fake save logic * set boundary element of the table, design updates * Add picker, restore focus to cell when trigger is hidden, converge implementation to make generic, fix density, individual cell saving state, use touch detection for showing all the time * fix lint and small screen rendering * Change editable cell hover color when row is hovered * invert hover color for non-selection * use a pending action button and change background cell color for hover * fix lint * fix density, pending is disabled, some of cell sizing * Add bulk edit bar * fix lint * add "More" actions and remove actionbar bulk actions * fix rendering * Add other components so we know if there's anything else * simplify code and example * add comments and fix types * Make picker nice * fix lint * Implement our actual component and start tests * add default slot so that people can add other buttons to the row * fix lint * add tests * fix lint * review updates * fix tab behaviour * add tab tests
1 parent 39e6ade commit de7e167

File tree

8 files changed

+856
-7
lines changed

8 files changed

+856
-7
lines changed

packages/@react-aria/overlays/src/calculatePosition.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
536536
export function getRect(node: Element, ignoreScale: boolean) {
537537
let {top, left, width, height} = node.getBoundingClientRect();
538538

539-
// Use offsetWidth and offsetHeight if this is an HTML element, so that
539+
// Use offsetWidth and offsetHeight if this is an HTML element, so that
540540
// the size is not affected by scale transforms.
541541
if (ignoreScale && node instanceof node.ownerDocument.defaultView!.HTMLElement) {
542542
width = node.offsetWidth;

packages/@react-spectrum/s2/intl/ar-AE.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@
2929
"picker.selectedCount": "{count, plural, =0 {لم يتم تحديد عناصر} one {# عنصر محدد} other {# عنصر محدد}}",
3030
"slider.maximum": "أقصى",
3131
"slider.minimum": "أدنى",
32+
"table.cancel": "إلغاء",
33+
"table.editCell": "تعديل الخلية",
3234
"table.loading": "جارٍ التحميل...",
3335
"table.loadingMore": "جارٍ تحميل المزيد...",
3436
"table.resizeColumn": "تغيير حجم العمود",
37+
"table.save": "حفظ",
3538
"table.sortAscending": "فرز بترتيب تصاعدي",
3639
"table.sortDescending": "فرز بترتيب تنازلي",
3740
"tag.actions": "الإجراءات",

packages/@react-spectrum/s2/intl/en-US.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@
2929
"picker.selectedCount": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}",
3030
"slider.maximum": "Maximum",
3131
"slider.minimum": "Minimum",
32+
"table.cancel": "Cancel",
33+
"table.editCell": "Edit cell",
3234
"table.loading": "Loading…",
3335
"table.loadingMore": "Loading more…",
3436
"table.resizeColumn": "Resize column",
37+
"table.save": "Save",
3538
"table.sortAscending": "Sort Ascending",
3639
"table.sortDescending": "Sort Descending",
3740
"tag.actions": "Actions",

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

Lines changed: 198 additions & 2 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,9 +50,11 @@ 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 {centerPadding, colorScheme, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'};
4854
import {Checkbox} from './Checkbox';
55+
import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg';
4956
import Chevron from '../ui-icons/Chevron';
57+
import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg';
5058
import {ColumnSize} from '@react-types/table';
5159
import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LoadingState, Node} from '@react-types/shared';
5260
import {GridNode} from '@react-types/grid';
@@ -58,11 +66,12 @@ import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu';
5866
import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg';
5967
import {ProgressCircle} from './ProgressCircle';
6068
import {raw} from '../style/style-macro' with {type: 'macro'};
61-
import React, {createContext, forwardRef, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react';
69+
import React, {createContext, CSSProperties, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useMemo, useRef, useState} from 'react';
6270
import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg';
6371
import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg';
6472
import {useActionBarContainer} from './ActionBar';
6573
import {useDOMRef} from '@react-spectrum/utils';
74+
import {useLayoutEffect, useObjectRef} from '@react-aria/utils';
6675
import {useLocalizedStringFormatter} from '@react-aria/i18n';
6776
import {useScale} from './utils';
6877
import {useSpectrumContextProps} from './useSpectrumContextProps';
@@ -1044,6 +1053,193 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef<HTMLD
10441053
);
10451054
});
10461055

1056+
let editPopover = style({
1057+
...colorScheme(),
1058+
'--s2-container-bg': {
1059+
type: 'backgroundColor',
1060+
value: 'layer-2'
1061+
},
1062+
backgroundColor: '--s2-container-bg',
1063+
borderBottomRadius: 'default',
1064+
// Use box-shadow instead of filter when an arrow is not shown.
1065+
// This fixes the shadow stacking problem with submenus.
1066+
boxShadow: 'elevated',
1067+
borderStyle: 'solid',
1068+
borderWidth: 1,
1069+
borderColor: {
1070+
default: 'gray-200',
1071+
forcedColors: 'ButtonBorder'
1072+
},
1073+
boxSizing: 'content-box',
1074+
isolation: 'isolate',
1075+
pointerEvents: {
1076+
isExiting: 'none'
1077+
},
1078+
outlineStyle: 'none',
1079+
minWidth: '--trigger-width',
1080+
padding: 8,
1081+
display: 'flex',
1082+
alignItems: 'center'
1083+
}, getAllowedOverrides());
1084+
1085+
interface EditableCellProps extends Omit<CellProps, 'isSticky'> {
1086+
renderEditing: () => ReactNode,
1087+
isSaving?: boolean,
1088+
onSubmit: () => void,
1089+
onCancel: () => void
1090+
}
1091+
1092+
/**
1093+
* An exditable cell within a table row.
1094+
*/
1095+
export const EditableCell = forwardRef(function EditableCell(props: EditableCellProps, ref: ForwardedRef<HTMLDivElement>) {
1096+
let {children, showDivider = false, textValue, ...otherProps} = props;
1097+
let tableVisualOptions = useContext(InternalTableContext);
1098+
let domRef = useObjectRef(ref);
1099+
textValue ||= typeof children === 'string' ? children : undefined;
1100+
1101+
return (
1102+
<RACCell
1103+
ref={domRef}
1104+
className={renderProps => cell({
1105+
...renderProps,
1106+
...tableVisualOptions,
1107+
isDivider: showDivider
1108+
})}
1109+
textValue={textValue}
1110+
{...otherProps}>
1111+
{({isFocusVisible}) => (
1112+
<EditableCellInner {...props} isFocusVisible={isFocusVisible} cellRef={domRef as RefObject<HTMLDivElement>} />
1113+
)}
1114+
</RACCell>
1115+
);
1116+
});
1117+
1118+
function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, cellRef: RefObject<HTMLDivElement>}) {
1119+
let {children, align, renderEditing, isSaving, onSubmit, onCancel, isFocusVisible, cellRef} = props;
1120+
let [isOpen, setIsOpen] = useState(false);
1121+
let popoverRef = useRef<HTMLDivElement>(null);
1122+
let formRef = useRef<HTMLFormElement>(null);
1123+
let [triggerWidth, setTriggerWidth] = useState(0);
1124+
let [tableWidth, setTableWidth] = useState(0);
1125+
let [verticalOffset, setVerticalOffset] = useState(0);
1126+
let tableVisualOptions = useContext(InternalTableContext);
1127+
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');
1128+
1129+
let {density} = useContext(InternalTableContext);
1130+
let size: 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = 'M';
1131+
if (density === 'compact') {
1132+
size = 'S';
1133+
} else if (density === 'spacious') {
1134+
size = 'L';
1135+
}
1136+
1137+
1138+
// Popover positioning
1139+
useLayoutEffect(() => {
1140+
if (!isOpen) {
1141+
return;
1142+
}
1143+
let width = cellRef.current?.clientWidth || 0;
1144+
let cell = cellRef.current;
1145+
let boundingRect = cell?.parentElement?.getBoundingClientRect();
1146+
let verticalOffset = (boundingRect?.top ?? 0) - (boundingRect?.bottom ?? 0);
1147+
1148+
let tableWidth = cellRef.current?.closest('[role="grid"]')?.clientWidth || 0;
1149+
setTriggerWidth(width);
1150+
setVerticalOffset(verticalOffset);
1151+
setTableWidth(tableWidth);
1152+
}, [cellRef, density, isOpen]);
1153+
1154+
// Cancel, don't save the value
1155+
let cancel = () => {
1156+
setIsOpen(false);
1157+
onCancel();
1158+
};
1159+
1160+
return (
1161+
<Provider
1162+
values={[
1163+
[ButtonContext, null],
1164+
[ActionButtonContext, {
1165+
slots: {
1166+
[DEFAULT_SLOT]: {},
1167+
edit: {
1168+
onPress: () => setIsOpen(true),
1169+
isPending: isSaving,
1170+
isQuiet: !isSaving,
1171+
size,
1172+
excludeFromTabOrder: true,
1173+
styles: style({
1174+
// TODO: really need access to display here instead, but not possible right now
1175+
// will be addressable with displayOuter
1176+
visibility: {
1177+
default: 'hidden',
1178+
isForcedVisible: 'visible',
1179+
':is([role="row"]:hover *)': 'visible',
1180+
':is([role="row"][data-focus-visible-within] *)': 'visible',
1181+
'@media not (any-pointer: fine)': 'visible'
1182+
}
1183+
})({isForcedVisible: isOpen || !!isSaving})
1184+
}
1185+
}
1186+
}]
1187+
]}>
1188+
<span className={cellContent({...tableVisualOptions, align: align || 'start'})}>{children}</span>
1189+
{isFocusVisible && <CellFocusRing />}
1190+
1191+
<Provider
1192+
values={[
1193+
[ActionButtonContext, null]
1194+
]}>
1195+
<RACPopover
1196+
isOpen={isOpen}
1197+
onOpenChange={setIsOpen}
1198+
ref={popoverRef}
1199+
shouldCloseOnInteractOutside={() => {
1200+
if (!popoverRef.current?.contains(document.activeElement)) {
1201+
return false;
1202+
}
1203+
formRef.current?.requestSubmit();
1204+
return false;
1205+
}}
1206+
triggerRef={cellRef}
1207+
aria-label={stringFormatter.format('table.editCell')}
1208+
offset={verticalOffset}
1209+
placement="bottom start"
1210+
style={{
1211+
minWidth: `min(${triggerWidth}px, ${tableWidth}px)`,
1212+
maxWidth: `${tableWidth}px`,
1213+
// Override default z-index from useOverlayPosition. We use isolation: isolate instead.
1214+
zIndex: undefined
1215+
}}
1216+
className={editPopover}>
1217+
<Provider
1218+
values={[
1219+
[OverlayTriggerStateContext, null]
1220+
]}>
1221+
<Form
1222+
ref={formRef}
1223+
onSubmit={(e) => {
1224+
e.preventDefault();
1225+
onSubmit();
1226+
setIsOpen(false);
1227+
}}
1228+
className={style({width: 'full', display: 'flex', alignItems: 'baseline', gap: 16})}
1229+
style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}>
1230+
{renderEditing()}
1231+
<div className={style({display: 'flex', flexDirection: 'row', alignItems: 'baseline', flexShrink: 0, flexGrow: 0})}>
1232+
<ActionButton isQuiet onPress={cancel} aria-label={stringFormatter.format('table.cancel')}><Close /></ActionButton>
1233+
<ActionButton isQuiet type="submit" aria-label={stringFormatter.format('table.save')}><Checkmark /></ActionButton>
1234+
</div>
1235+
</Form>
1236+
</Provider>
1237+
</RACPopover>
1238+
</Provider>
1239+
</Provider>
1240+
);
1241+
}
1242+
10471243
// Use color-mix instead of transparency so sticky cells work correctly.
10481244
const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), colorMix('gray-25', 'informative-700', 10));
10491245
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)