Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
320 changes: 317 additions & 3 deletions packages/@react-spectrum/s2/src/TableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,27 @@
* governing permissions and limitations under the License.
*/

import {ActionButton, ActionButtonContext} from './ActionButton';
import {baseColor, colorMix, focusRing, fontRelative, lightDark, space, style} from '../style' with {type: 'macro'};
import {
Button,
ButtonContext,
CellRenderProps,
Collection,
ColumnRenderProps,
ColumnResizer,
ContextValue,
DEFAULT_SLOT,
Form,
Key,
OverlayTriggerStateContext,
Provider,
Cell as RACCell,
CellProps as RACCellProps,
CheckboxContext as RACCheckboxContext,
Column as RACColumn,
ColumnProps as RACColumnProps,
Popover as RACPopover,
Row as RACRow,
RowProps as RACRowProps,
Table as RACTable,
Expand All @@ -44,11 +50,16 @@ import {
useTableOptions,
Virtualizer
} from 'react-aria-components';
import {centerPadding, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'};
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';
import {IconContext} from './Icon';
// @ts-ignore
Expand All @@ -58,11 +69,12 @@ import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu';
import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg';
import {ProgressCircle} from './ProgressCircle';
import {raw} from '../style/style-macro' with {type: 'macro'};
import React, {createContext, forwardRef, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react';
import React, {createContext, CSSProperties, FormEvent, FormHTMLAttributes, 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 @@ -1047,6 +1059,308 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef<HTMLD
);
});


const editableCell = style<CellRenderProps & S2TableProps & {isDivider: boolean, selectionMode?: 'none' | 'single' | 'multiple', isSaving?: boolean}>({
...commonCellStyles,
color: {
default: baseColor('neutral'),
isSaving: baseColor('neutral-subdued')
},
paddingY: centerPadding(),
boxSizing: 'border-box',
height: 'calc(100% - 1px)', // so we don't overlap the border of the next cell
width: 'full',
fontSize: controlFont(),
alignItems: 'center',
display: 'flex',
borderStyle: {
default: 'none',
isDivider: 'solid'
},
borderEndWidth: {
default: 0,
isDivider: 1
},
borderColor: {
default: 'gray-300',
forcedColors: 'ButtonBorder'
}
});

let editPopover = style({
...colorScheme(),
'--s2-container-bg': {
type: 'backgroundColor',
value: 'layer-2'
},
backgroundColor: '--s2-container-bg',
borderBottomRadius: 'default',
// Use box-shadow instead of filter when an arrow is not shown.
// This fixes the shadow stacking problem with submenus.
boxShadow: 'elevated',
borderStyle: 'solid',
borderWidth: 1,
borderColor: {
default: 'gray-200',
forcedColors: 'ButtonBorder'
},
boxSizing: 'content-box',
isolation: 'isolate',
pointerEvents: {
isExiting: 'none'
},
outlineStyle: 'none',
minWidth: '--trigger-width',
padding: 8,
display: 'flex',
alignItems: 'center'
}, getAllowedOverrides());

interface EditableCellProps extends Omit<CellProps, 'isSticky'> {
/** The component which will handle editing the cell. For example, a `TextField` or a `Picker`. */
renderEditing: () => ReactNode,
/** 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?: (e: FormEvent<HTMLFormElement>) => void,
/** Handler that is called when the user cancels the edit. */
onCancel?: () => void,
/** The action to submit the form to. Only available in React 19+. */
action?: string | FormHTMLAttributes<HTMLFormElement>['action']
}

/**
* An editable cell within a table row.
*/
export const EditableCell = forwardRef(function EditableCell(props: EditableCellProps, ref: ForwardedRef<HTMLDivElement>) {
let {children, showDivider = false, textValue, isSaving, ...otherProps} = props;
let tableVisualOptions = useContext(InternalTableContext);
let domRef = useObjectRef(ref);
textValue ||= typeof children === 'string' ? children : undefined;

return (
<RACCell
ref={domRef}
className={renderProps => editableCell({
...renderProps,
...tableVisualOptions,
isDivider: showDivider,
isSaving
})}
textValue={textValue}
{...otherProps}>
{({isFocusVisible}) => (
<EditableCellInner {...props} isFocusVisible={isFocusVisible} cellRef={domRef as RefObject<HTMLDivElement>} />
)}
</RACCell>
);
});

const nonTextInputTypes = new Set([
'checkbox',
'radio',
'range',
'color',
'file',
'image',
'button',
'submit',
'reset'
]);

function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, cellRef: RefObject<HTMLDivElement>}) {
let {children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef, action, onCancel} = props;
let [isOpen, setIsOpen] = useState(false);
let popoverRef = useRef<HTMLDivElement>(null);
let formRef = useRef<HTMLFormElement>(null);
let [triggerWidth, setTriggerWidth] = useState(0);
let [tableWidth, setTableWidth] = useState(0);
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';
if (density === 'compact') {
size = 'S';
} else if (density === 'spacious') {
size = 'L';
}

// Popover positioning
useLayoutEffect(() => {
if (!isOpen) {
return;
}
let width = cellRef.current?.clientWidth || 0;
let cell = cellRef.current;
let boundingRect = cell?.parentElement?.getBoundingClientRect();
let verticalOffset = (boundingRect?.top ?? 0) - (boundingRect?.bottom ?? 0);

let tableWidth = cellRef.current?.closest('[role="grid"]')?.clientWidth || 0;
setTriggerWidth(width);
setVerticalOffset(verticalOffset);
setTableWidth(tableWidth);
}, [cellRef, density, isOpen]);

// Auto select the entire text range of the autofocused input on overlay opening
// Maybe replace with FocusScope or one of those utilities
useEffect(() => {
if (isOpen) {
let activeElement = getActiveElement(getOwnerDocument(formRef.current));
if (activeElement
&& formRef.current?.contains(activeElement)
// not going to handle contenteditable https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element
// seems like an edge case anyways
&& (
(activeElement instanceof HTMLInputElement && !nonTextInputTypes.has(activeElement.type))
|| activeElement instanceof HTMLTextAreaElement)
&& typeof activeElement.select === 'function') {
activeElement.select();
}
}
}, [isOpen]);

let cancel = useCallback(() => {
setIsOpen(false);
onCancel?.();
}, [onCancel]);

let isMobile = !useMediaQuery('(hover: hover) and (pointer: fine)');
// Can't differentiate between Dialog click outside dismissal and Escape key dismissal
let prevIsOpen = useRef(isOpen);
useEffect(() => {
let dialog = dialogRef.current?.UNSAFE_getDOMNode();
if (isOpen && dialog && !prevIsOpen.current) {
let handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
cancel();
e.stopPropagation();
e.preventDefault();
}
};
dialog.addEventListener('keydown', handler);
prevIsOpen.current = isOpen;
return () => {
dialog.removeEventListener('keydown', handler);
};
}
prevIsOpen.current = isOpen;
}, [isOpen, cancel]);

return (
<Provider
values={[
[ButtonContext, null],
[ActionButtonContext, {
slots: {
[DEFAULT_SLOT]: {},
edit: {
onPress: () => setIsOpen(true),
isPending: isSaving,
isQuiet: !isSaving,
size,
excludeFromTabOrder: true,
styles: style({
// TODO: really need access to display here instead, but not possible right now
// will be addressable with displayOuter
// Could use `hidden` attribute instead of css, but I don't have access to much of this state at the moment
visibility: {
default: 'hidden',
isForcedVisible: 'visible',
':is([role="row"]:hover *)': 'visible',
':is([role="row"][data-focus-visible-within] *)': 'visible',
'@media not ((hover: hover) and (pointer: fine))': 'visible'
}
})({isForcedVisible: isOpen || !!isSaving})
}
}
}]
]}>
<span className={cellContent({...tableVisualOptions, align: align || 'start'})}>{children}</span>
{isFocusVisible && <CellFocusRing />}

<Provider
values={[
[ActionButtonContext, null]
]}>
{!isMobile && (
<RACPopover
isOpen={isOpen}
onOpenChange={setIsOpen}
ref={popoverRef}
shouldCloseOnInteractOutside={() => {
if (!popoverRef.current?.contains(document.activeElement)) {
return false;
}
formRef.current?.requestSubmit();
return false;
}}
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}
action={action}
onSubmit={(e) => {
onSubmit?.(e);
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}
action={action}
onSubmit={(e) => {
onSubmit?.(e);
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));
const selectedActiveBackground = lightDark(colorMix('gray-25', 'informative-900', 15), colorMix('gray-25', 'informative-700', 15));
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/s2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export {Skeleton, useIsSkeleton} from './Skeleton';
export {SkeletonCollection} from './SkeletonCollection';
export {StatusLight, StatusLightContext} from './StatusLight';
export {Switch, SwitchContext} from './Switch';
export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext} from './TableView';
export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext, EditableCell} from './TableView';
export {Tabs, TabList, Tab, TabPanel, TabsContext} from './Tabs';
export {TagGroup, Tag, TagGroupContext} from './TagGroup';
export {TextArea, TextField, TextAreaContext, TextFieldContext} from './TextField';
Expand Down
Loading