Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 100 additions & 56 deletions packages/@react-spectrum/s2/src/TableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@ import {
useTableOptions,
Virtualizer
} from 'react-aria-components';
import {ButtonGroup} from './ButtonGroup';
import {centerPadding, colorScheme, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'};
import {Checkbox} from './Checkbox';
import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg';
import Chevron from '../ui-icons/Chevron';
import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg';
import {ColumnSize} from '@react-types/table';
import {CustomDialog, DialogContainer} from '..';
import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LoadingState, Node} from '@react-types/shared';
import {getActiveElement, getOwnerDocument, useLayoutEffect, useObjectRef} from '@react-aria/utils';
import {GridNode} from '@react-types/grid';
Expand All @@ -70,8 +72,9 @@ import {raw} from '../style/style-macro' with {type: 'macro'};
import React, {createContext, CSSProperties, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg';
import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg';
import {Button as SpectrumButton} from './Button';
import {useActionBarContainer} from './ActionBar';
import {useDOMRef} from '@react-spectrum/utils';
import {useDOMRef, useMediaQuery} from '@react-spectrum/utils';
import {useLocalizedStringFormatter} from '@react-aria/i18n';
import {useScale} from './utils';
import {useSpectrumContextProps} from './useSpectrumContextProps';
Expand Down Expand Up @@ -1081,17 +1084,6 @@ const editableCell = style<CellRenderProps & S2TableProps & {isDivider: boolean,
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: 'gray-25',
multiple: 'gray-25'
}
},
':is([role="row"][data-focus-visible-within] [role="rowheader"]:focus-within, [role="row"][data-focus-visible-within] [role="gridcell"]:focus-within)': 'gray-25'
}
});

Expand Down Expand Up @@ -1130,7 +1122,7 @@ interface EditableCellProps extends Omit<CellProps, 'isSticky'> {
/** 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
onSubmit: (values: Record<string, any>) => void
}

/**
Expand Down Expand Up @@ -1182,6 +1174,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean,
let [verticalOffset, setVerticalOffset] = useState(0);
let tableVisualOptions = useContext(InternalTableContext);
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');
let dialogRef = useRef<DOMRefValue<HTMLElement>>(null);

let {density} = useContext(InternalTableContext);
let size: 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = 'M';
Expand Down Expand Up @@ -1229,6 +1222,25 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean,
setIsOpen(false);
};

// Can't differentiate between Dialog click outside dismissal and Escape key dismissal
let isMobile = !useMediaQuery('(any-pointer: fine)');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't seem to trigger for Android, might need to use touch: '@media not ((hover: hover) and (pointer: fine))', which is what the style macro uses

useEffect(() => {
let dialog = dialogRef.current?.UNSAFE_getDOMNode();
if (isOpen && dialog) {
let handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
e.stopPropagation();
e.preventDefault();
}
};
dialog.addEventListener('keydown', handler);
return () => {
dialog.removeEventListener('keydown', handler);
};
}
}, [isOpen]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats a bit annoying that this needs to be done since dismissible dialogs close on touch start I assume... Feels like a relatively common use case too, wonder if we could support this by default

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, i was definitely a bit annoyed with this, it's related to these two issues
#1773
#2192


return (
<Provider
values={[
Expand Down Expand Up @@ -1265,53 +1277,85 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean,
values={[
[ActionButtonContext, null]
]}>
<RACPopover
isOpen={isOpen}
onOpenChange={setIsOpen}
ref={popoverRef}
shouldCloseOnInteractOutside={() => {
if (!popoverRef.current?.contains(document.activeElement)) {
{!isMobile && (
<RACPopover
isOpen={isOpen}
onOpenChange={setIsOpen}
ref={popoverRef}
shouldCloseOnInteractOutside={() => {
if (!popoverRef.current?.contains(document.activeElement)) {
return false;
}
formRef.current?.requestSubmit();
return false;
}
formRef.current?.requestSubmit();
return false;
}}
triggerRef={cellRef}
aria-label={stringFormatter.format('table.editCell')}
offset={verticalOffset}
placement="bottom start"
style={{
minWidth: `min(${triggerWidth}px, ${tableWidth}px)`,
maxWidth: `${tableWidth}px`,
// Override default z-index from useOverlayPosition. We use isolation: isolate instead.
zIndex: undefined
}}
className={editPopover}>
<Provider
values={[
[OverlayTriggerStateContext, null]
]}>
<Form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
onSubmit();
setIsOpen(false);
}}
className={style({width: 'full', display: 'flex', alignItems: 'start', gap: 16})}
style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}>
{renderEditing()}
<div className={style({display: 'flex', flexDirection: 'row', alignItems: 'baseline', flexShrink: 0, flexGrow: 0})}>
<ActionButton isQuiet onPress={cancel} aria-label={stringFormatter.format('table.cancel')}><Close /></ActionButton>
<ActionButton isQuiet type="submit" aria-label={stringFormatter.format('table.save')}><Checkmark /></ActionButton>
</div>
</Form>
</Provider>
</RACPopover>
}}
triggerRef={cellRef}
aria-label={props['aria-label'] ?? stringFormatter.format('table.editCell')}
offset={verticalOffset}
placement="bottom start"
style={{
minWidth: `min(${triggerWidth}px, ${tableWidth}px)`,
maxWidth: `${tableWidth}px`,
// Override default z-index from useOverlayPosition. We use isolation: isolate instead.
zIndex: undefined
}}
className={editPopover}>
<Provider
values={[
[OverlayTriggerStateContext, null]
]}>
<Form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
let formData = new FormData(formRef.current as HTMLFormElement);
let values = Object.fromEntries(formData.entries());
onSubmit(values);
setIsOpen(false);
}}
className={style({width: 'full', display: 'flex', alignItems: 'start', gap: 16})}
style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}>
{renderEditing()}
<div className={style({display: 'flex', flexDirection: 'row', alignItems: 'baseline', flexShrink: 0, flexGrow: 0})}>
<ActionButton isQuiet onPress={cancel} aria-label={stringFormatter.format('table.cancel')}><Close /></ActionButton>
<ActionButton isQuiet type="submit" aria-label={stringFormatter.format('table.save')}><Checkmark /></ActionButton>
</div>
</Form>
</Provider>
</RACPopover>
)}
{isMobile && (
<DialogContainer onDismiss={() => formRef.current?.requestSubmit()}>
{isOpen && (
<CustomDialog
ref={dialogRef}
isDismissible
isKeyboardDismissDisabled
aria-label={props['aria-label'] ?? stringFormatter.format('table.editCell')}>
<Form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
let formData = new FormData(formRef.current as HTMLFormElement);
let values = Object.fromEntries(formData.entries());
onSubmit(values);
setIsOpen(false);
}}
className={style({width: 'full', display: 'flex', flexDirection: 'column', alignItems: 'start', gap: 16})}>
{renderEditing()}
<ButtonGroup align="end" styles={style({alignSelf: 'end'})}>
<SpectrumButton onPress={cancel} variant="secondary" fillStyle="outline">Cancel</SpectrumButton>
<SpectrumButton type="submit" variant="accent">Save</SpectrumButton>
</ButtonGroup>
</Form>
</CustomDialog>
)}
</DialogContainer>
)}
</Provider>
</Provider>
);
}
};

// Use color-mix instead of transparency so sticky cells work correctly.
const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), colorMix('gray-25', 'informative-700', 10));
Expand Down
42 changes: 16 additions & 26 deletions packages/@react-spectrum/s2/stories/TableView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1461,24 +1461,18 @@ export const EditableTable: StoryObj<EditableTableProps> = {
render: function EditableTable(props) {
let columns = editableColumns;
let [editableItems, setEditableItems] = useState(defaultItems);
let intermediateValue = useRef<any>(null);

let onChange = useCallback((id: Key, columnId: Key) => {
let value = intermediateValue.current;
let onChange = useCallback((id: Key, columnId: Key, values: any) => {
let value = values[columnId];
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 (
<div className={style({display: 'flex', flexDirection: 'column', gap: 16})}>
<TableView aria-label="Dynamic table" {...props} styles={style({width: 800, maxWidth: 'calc(100vw - 2rem)', height: 208})}>
Expand All @@ -1494,18 +1488,19 @@ export const EditableTable: StoryObj<EditableTableProps> = {
if (column.id === 'fruits') {
return (
<EditableCell
aria-label={`Edit ${item[column.id]} in ${column.name}`}
align={column.align}
showDivider={column.showDivider}
onSubmit={() => onChange(item.id, column.id!)}
onSubmit={(values) => onChange(item.id, column.id!, values)}
isSaving={item.isSaving[column.id!]}
renderEditing={() => (
<TextField
aria-label="Edit fruit"
name={column.id! as string}
aria-label="Fruit name edit field"
autoFocus
validate={value => value.length > 0 ? null : 'Fruit name is required'}
styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})}
defaultValue={item[column.id!]}
onChange={value => onIntermediateChange(value)} />
defaultValue={item[column.id!]} />
)}>
<div className={style({display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between'})}>
{item[column.id]}
Expand All @@ -1520,15 +1515,15 @@ export const EditableTable: StoryObj<EditableTableProps> = {
<EditableCell
align={column.align}
showDivider={column.showDivider}
onSubmit={() => onChange(item.id, column.id!)}
onSubmit={(values) => onChange(item.id, column.id!, values)}
isSaving={item.isSaving[column.id!]}
renderEditing={() => (
<Picker
aria-label="Edit farmer"
autoFocus
styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})}
defaultValue={item[column.id!]}
onChange={value => onIntermediateChange(value)}>
name={column.id! as string}>
<PickerItem textValue="Eva" id="Eva"><User /><Text>Eva</Text></PickerItem>
<PickerItem textValue="Steven" id="Steven"><User /><Text>Steven</Text></PickerItem>
<PickerItem textValue="Michael" id="Michael"><User /><Text>Michael</Text></PickerItem>
Expand Down Expand Up @@ -1567,7 +1562,6 @@ export const EditableTableWithAsyncSaving: StoryObj<EditableTableProps> = {
render: function EditableTable(props) {
let columns = editableColumns;
let [editableItems, setEditableItems] = useState(defaultItems);
let intermediateValue = useRef<any>(null);

// Replace all of this with real API calls, this is purely demonstrative.
let saveItem = useCallback((id: Key, columnId: Key, prevValue: any) => {
Expand All @@ -1580,12 +1574,11 @@ export const EditableTableWithAsyncSaving: StoryObj<EditableTableProps> = {
currentRequests.current.delete(id);
}, []);
let currentRequests = useRef<Map<Key, {request: ReturnType<typeof setTimeout>, prevValue: any}>>(new Map());
let onChange = useCallback((id: Key, columnId: Key) => {
let value = intermediateValue.current;
let onChange = useCallback((id: Key, columnId: Key, values: any) => {
let value = values[columnId];
if (value === null) {
return;
}
intermediateValue.current = null;
let alreadySaving = currentRequests.current.get(id);
if (alreadySaving) {
// remove and cancel the previous request
Expand All @@ -1604,10 +1597,6 @@ export const EditableTableWithAsyncSaving: StoryObj<EditableTableProps> = {
});
}, [saveItem]);

let onIntermediateChange = useCallback((value: any) => {
intermediateValue.current = value;
}, []);

return (
<div className={style({display: 'flex', flexDirection: 'column', gap: 16})}>
<TableView aria-label="Dynamic table" {...props} styles={style({width: 800, height: 208})}>
Expand All @@ -1623,9 +1612,10 @@ export const EditableTableWithAsyncSaving: StoryObj<EditableTableProps> = {
if (column.id === 'fruits') {
return (
<EditableCell
aria-label={`Edit ${item[column.id]} in ${column.name}`}
align={column.align}
showDivider={column.showDivider}
onSubmit={() => onChange(item.id, column.id!)}
onSubmit={(values) => onChange(item.id, column.id!, values)}
isSaving={item.isSaving[column.id!]}
renderEditing={() => (
<TextField
Expand All @@ -1634,7 +1624,7 @@ export const EditableTableWithAsyncSaving: StoryObj<EditableTableProps> = {
validate={value => value.length > 0 ? null : 'Fruit name is required'}
styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})}
defaultValue={item[column.id!]}
onChange={value => onIntermediateChange(value)} />
name={column.id! as string} />
)}>
<div className={style({display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between'})}>{item[column.id]}<ActionButton slot="edit" aria-label="Edit fruit"><Edit /></ActionButton></div>
</EditableCell>
Expand All @@ -1645,15 +1635,15 @@ export const EditableTableWithAsyncSaving: StoryObj<EditableTableProps> = {
<EditableCell
align={column.align}
showDivider={column.showDivider}
onSubmit={() => onChange(item.id, column.id!)}
onSubmit={(values) => onChange(item.id, column.id!, values)}
isSaving={item.isSaving[column.id!]}
renderEditing={() => (
<Picker
aria-label="Edit farmer"
autoFocus
styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})}
defaultValue={item[column.id!]}
onChange={value => onIntermediateChange(value)}>
name={column.id! as string}>
<PickerItem textValue="Eva" id="Eva"><User /><Text>Eva</Text></PickerItem>
<PickerItem textValue="Steven" id="Steven"><User /><Text>Steven</Text></PickerItem>
<PickerItem textValue="Michael" id="Michael"><User /><Text>Michael</Text></PickerItem>
Expand Down
Loading
Loading