diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index b8e09c29cdd..34daaaa355f 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -11,13 +11,13 @@ */ import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared'; -import {focusSafely, isFocusVisible} from '@react-aria/interactions'; +import {focusSafely, isFocusVisible, useLongPress} from '@react-aria/interactions'; import {getFocusableTreeWalker} from '@react-aria/focus'; import {getScrollParent, mergeProps, scrollIntoViewport} from '@react-aria/utils'; import {GridCollection, GridNode} from '@react-types/grid'; import {gridMap} from './utils'; import {GridState} from '@react-stately/grid'; -import {KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; +import {KeyboardEvent as ReactKeyboardEvent, useEffect, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; import {useSelectableItem} from '@react-aria/selection'; @@ -44,7 +44,9 @@ export interface GridCellAria { /** Props for the grid cell element. */ gridCellProps: DOMAttributes, /** Whether the cell is currently in a pressed state. */ - isPressed: boolean + isPressed: boolean, + /** Whether the cell is currently in edit mode. */ + isEditing: boolean } /** @@ -280,9 +282,116 @@ export function useGridCell>(props: GridCellProps }; } + + // Edit mode. + let isEditing = state.editingKey === node.key; + let modality = useRef(''); + let isDoubleClickAllowed = /* state.selectionManager.isEmpty || */ !state.selectionManager.canSelectItem(node.parentKey!) || state.selectionManager.isSelected(node.parentKey!); + let editModeProps = { + onDoubleClick(e) { + // TODO: this conflicts with double click to perform an action with selectionBehavior="replace". + // With selectionBehavior="toggle", the action will also fire for each click. Probably should delay to ensure it wasn't a double click? + // If there is no action, then the selection will also toggle twice. Maybe you have to select via checkbox if there is editing? + + if (modality.current === 'touch') { + return; + } + + // With selectionBehavior="toggle", only support double click to edit when nothing is selected. See below. + // if (state.selectionManager.selectionBehavior !== 'toggle' || isDoubleClickAllowed) { + if (isDoubleClickAllowed) { + e.stopPropagation(); + state.startEditing(node.key); + } + }, + onPointerDown(e) { + // If selectionBehavior="toggle", we need to prevent row selection from toggling when trying to double click to edit. + // When no rows are selected, users can double click a cell to edit, or click the checkbox to start selecting. + // Once at least one row is selected, clicking other rows will select them, and double clicking does nothing. + modality.current = e.pointerType; + if ( + state.selectionManager.selectionBehavior === 'toggle' && + e.pointerType !== 'touch' && + isDoubleClickAllowed + ) { + // TODO: this doesn't work on touch because touch with selectionBehavior="replace" works like toggle. + // But if we prevent this, there is no other way to initiate a selection (e.g. checkbox). + // We could add long press, but seems like + e.stopPropagation(); + // focus(); + // state.selectionManager.setFocused(true); + // state.selectionManager.setFocusedKey(node.parentKey); + } + + if (isEditing) { + e.stopPropagation(); + } + }, + onMouseDown(e) { + if (isEditing) { + e.stopPropagation(); + } + }, + onKeyDown(e) { + if (e.key === 'Escape' && isEditing) { + e.stopPropagation(); + state.endEditing(); + } else if (e.key === 'Enter') { + e.stopPropagation(); + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { + return; + } + if (!isEditing) { + state.startEditing(node.key); + } else { + // Maybe not by default. User will want to save the value first. + // state.endEditing(); + } + } else if (!isEditing && (e.key.length === 1 || !/^[A-Z]/i.test(e.key))) { + // TODO: somehow only if the edit mode is a text input? + e.stopPropagation(); + state.startEditing(node.key); + } else if (isEditing) { + e.stopPropagation(); + } + }, + onBlur(e) { + if (isEditing && !e.currentTarget.contains(e.relatedTarget)) { + state.endEditing(); + } + } + }; + + let {longPressProps} = useLongPress({ + isDisabled: isEditing, + onLongPress(e) { + if (e.pointerType === 'touch') { + state.startEditing(node.key); + } + } + }); + + if (node.props?.allowsEditing) { + gridCellProps = isEditing ? {...gridCellProps, ...editModeProps} : mergeProps(gridCellProps, longPressProps, editModeProps); + } + + let wasEditing = useRef(false); + useEffect(() => { + if (ref.current && isEditing !== wasEditing.current && (isEditing || (state.selectionManager.isFocused && state.selectionManager.focusedKey === node.key))) { + focus(); + let input = document.activeElement; + if ((input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) && ref.current.contains(input)) { + input.select(); + } + } + + wasEditing.current = isEditing; + }, [isEditing, state.selectionManager.isFocused, state.selectionManager.focusedKey, focus, node.key, ref]); + return { gridCellProps, - isPressed + isPressed, + isEditing }; } diff --git a/packages/@react-aria/table/src/useTableCell.ts b/packages/@react-aria/table/src/useTableCell.ts index 1016cc13297..c1fa37ae3df 100644 --- a/packages/@react-aria/table/src/useTableCell.ts +++ b/packages/@react-aria/table/src/useTableCell.ts @@ -35,7 +35,9 @@ export interface TableCellAria { /** Props for the table cell element. */ gridCellProps: DOMAttributes, /** Whether the cell is currently in a pressed state. */ - isPressed: boolean + isPressed: boolean, + /** Whether the cell is currently in edit mode. */ + isEditing: boolean } /** @@ -45,7 +47,7 @@ export interface TableCellAria { * @param ref - The ref attached to the cell element. */ export function useTableCell(props: AriaTableCellProps, state: TableState, ref: RefObject): TableCellAria { - let {gridCellProps, isPressed} = useGridCell(props, state, ref); + let {gridCellProps, isPressed, isEditing} = useGridCell(props, state, ref); let columnKey = props.node.column?.key; if (columnKey != null && state.collection.rowHeaderColumnKeys.has(columnKey)) { gridCellProps.role = 'rowheader'; @@ -54,6 +56,7 @@ export function useTableCell(props: AriaTableCellProps, state: TableState, return { gridCellProps, - isPressed + isPressed, + isEditing }; } diff --git a/packages/@react-stately/grid/src/useGridState.ts b/packages/@react-stately/grid/src/useGridState.ts index 818c23391cb..06ffcf6142f 100644 --- a/packages/@react-stately/grid/src/useGridState.ts +++ b/packages/@react-stately/grid/src/useGridState.ts @@ -2,7 +2,7 @@ import {getChildNodes, getFirstItem, getLastItem} from '@react-stately/collectio import {GridCollection, GridNode} from '@react-types/grid'; import {Key} from '@react-types/shared'; import {MultipleSelectionState, MultipleSelectionStateProps, SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; -import {useEffect, useMemo, useRef} from 'react'; +import {useEffect, useMemo, useRef, useState} from 'react'; export interface GridState> { collection: C, @@ -11,7 +11,15 @@ export interface GridState> { /** A selection manager to read and update row selection state. */ selectionManager: SelectionManager, /** Whether keyboard navigation is disabled, such as when the arrow keys should be handled by a component within a cell. */ - isKeyboardNavigationDisabled: boolean + isKeyboardNavigationDisabled: boolean, + /** Set whether keyboard navigation is disabled, such as when the arrow keys should be handled by a component within a cell. */ + setKeyboardNavigationDisabled: (val: boolean) => void, + /** The currently editing cell in the table. */ + editingKey: Key | null, + /** Starts editing the given cell. */ + startEditing(key: Key): void, + /** Exits edit mode. */ + endEditing(): void } export interface GridStateOptions> extends MultipleSelectionStateProps { @@ -113,10 +121,23 @@ export function useGridState>(prop cachedCollection.current = collection; }, [collection, selectionManager, selectionState, selectionState.focusedKey]); + let [isKeyboardNavigationDisabled, setKeyboardNavigationDisabled] = useState(false); + let [editingKey, setEditingKey] = useState(null); + return { collection, disabledKeys, - isKeyboardNavigationDisabled: false, - selectionManager + isKeyboardNavigationDisabled: collection.size === 0 || isKeyboardNavigationDisabled, + setKeyboardNavigationDisabled, + selectionManager, + editingKey, + startEditing(key) { + setEditingKey(key); + setKeyboardNavigationDisabled(true); + }, + endEditing() { + setEditingKey(null); + setKeyboardNavigationDisabled(false); + } }; } diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index 0addeb10a35..004dfd509f3 100644 --- a/packages/@react-stately/table/src/useTableState.ts +++ b/packages/@react-stately/table/src/useTableState.ts @@ -14,7 +14,7 @@ import {GridState, useGridState} from '@react-stately/grid'; import {TableCollection as ITableCollection, TableBodyProps, TableHeaderProps} from '@react-types/table'; import {Key, Node, SelectionMode, Sortable, SortDescriptor, SortDirection} from '@react-types/shared'; import {MultipleSelectionState, MultipleSelectionStateProps} from '@react-stately/selection'; -import {ReactElement, useCallback, useMemo, useState} from 'react'; +import {ReactElement, useCallback, useMemo} from 'react'; import {TableCollection} from './TableCollection'; import {useCollection} from '@react-stately/collections'; @@ -26,11 +26,7 @@ export interface TableState extends GridState> { /** The current sorted column and direction. */ sortDescriptor: SortDescriptor | null, /** Calls the provided onSortChange handler with the provided column key and sort direction. */ - sort(columnKey: Key, direction?: 'ascending' | 'descending'): void, - /** Whether keyboard navigation is disabled, such as when the arrow keys should be handled by a component within a cell. */ - isKeyboardNavigationDisabled: boolean, - /** Set whether keyboard navigation is disabled, such as when the arrow keys should be handled by a component within a cell. */ - setKeyboardNavigationDisabled: (val: boolean) => void + sort(columnKey: Key, direction?: 'ascending' | 'descending'): void } export interface CollectionBuilderContext { @@ -67,7 +63,6 @@ const OPPOSITE_SORT_DIRECTION = { * of columns and rows from props. In addition, it tracks row selection and manages sort order changes. */ export function useTableState(props: TableStateProps): TableState { - let [isKeyboardNavigationDisabled, setKeyboardNavigationDisabled] = useState(false); let {selectionMode = 'none', showSelectionCheckboxes, showDragButtons} = props; let context = useMemo(() => ({ @@ -83,20 +78,17 @@ export function useTableState(props: TableStateProps): Tabl useCallback((nodes) => new TableCollection(nodes, null, context), [context]), context ); - let {disabledKeys, selectionManager} = useGridState({ + let gridState = useGridState({ ...props, collection, disabledBehavior: props.disabledBehavior || 'selection' }); return { + ...gridState, collection, - disabledKeys, - selectionManager, showSelectionCheckboxes: props.showSelectionCheckboxes || false, sortDescriptor: props.sortDescriptor ?? null, - isKeyboardNavigationDisabled: collection.size === 0 || isKeyboardNavigationDisabled, - setKeyboardNavigationDisabled, sort(columnKey: Key, direction?: 'ascending' | 'descending') { props.onSortChange?.({ column: columnKey, diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 18d838e4efc..9b533418ec8 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1189,7 +1189,20 @@ export interface CellRenderProps { /** * The unique id of the cell. **/ - id?: Key + id?: Key, + /** + * Whether the cell is currently in edit mode. + * @selector [data-editing] + */ + isEditing: boolean, + /** + * Starts editing the cell. + */ + startEditing: () => void, + /** + * Ends editing the cell. + */ + endEditing: () => void } export interface CellProps extends RenderProps, GlobalDOMAttributes { @@ -1198,7 +1211,9 @@ export interface CellProps extends RenderProps, GlobalDOMAttrib /** A string representation of the cell's contents, used for features like typeahead. */ textValue?: string, /** Indicates how many columns the data cell spans. */ - colSpan?: number + colSpan?: number, + /** Whether the cell allows inline editing. */ + allowsEditing?: boolean } /** @@ -1212,7 +1227,7 @@ export const Cell = /*#__PURE__*/ createLeafComponent('cell', (props: CellProps, cell.column = state.collection.columns[cell.index]; - let {gridCellProps, isPressed} = useTableCell({ + let {gridCellProps, isPressed, isEditing} = useTableCell({ node: cell, shouldSelectOnPressUp: !!dragState, isVirtualized @@ -1229,7 +1244,14 @@ export const Cell = /*#__PURE__*/ createLeafComponent('cell', (props: CellProps, isFocusVisible, isPressed, isHovered, - id: cell.key + id: cell.key, + isEditing, + startEditing() { + state.startEditing(cell.key); + }, + endEditing() { + state.endEditing(); + } } }); @@ -1243,7 +1265,8 @@ export const Cell = /*#__PURE__*/ createLeafComponent('cell', (props: CellProps, ref={ref as any} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} - data-pressed={isPressed || undefined}> + data-pressed={isPressed || undefined} + data-editing={isEditing || undefined}> {renderProps.children} diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 3cdadb39ad6..3e047523d56 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -11,15 +11,17 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Cell, Checkbox, CheckboxProps, Collection, Column, ColumnProps, ColumnResizer, Dialog, DialogTrigger, DropIndicator, Heading, Menu, MenuTrigger, Modal, ModalOverlay, Popover, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, useDragAndDrop, Virtualizer} from 'react-aria-components'; +import {Button, Cell, Checkbox, CheckboxProps, Collection, Column, ColumnProps, ColumnResizer, DateField, DateInput, DateSegment, Dialog, DialogTrigger, DropIndicator, Heading, Menu, MenuTrigger, Modal, ModalOverlay, Popover, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, useDragAndDrop, Virtualizer} from 'react-aria-components'; import {isTextDropItem} from 'react-aria'; import {LoadingSpinner, MyMenuItem} from './utils'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import React, {JSX, startTransition, Suspense, useState} from 'react'; import {Selection, useAsyncList, useListData} from 'react-stately'; import styles from '../example/index.css'; -import {TableLoadMoreItem} from '../src/Table'; +import {TableLoadMoreItem, useTableOptions} from '../src/Table'; import './styles.css'; +import {CalendarDate, getLocalTimeZone, parseDate} from '@internationalized/date'; +import clsx from 'clsx'; export default { title: 'React Aria Components/Table', @@ -1531,3 +1533,198 @@ export const TableWithReactTransition: TableStory = () => { ); }; + +const TableEditingExampleRender = (props) => { + let list = useListData({ + initialItems: [ + {id: 1, name: 'Games', date: '2020-06-07', type: 'File folder'}, + {id: 2, name: 'Program Files', date: '2021-04-07', type: 'File folder'}, + {id: 3, name: 'bootmgr', date: '2010-11-20', type: 'System file'}, + {id: 4, name: 'log.txt', date: '2016-01-18', type: 'Text Document'} + ] + }); + + return ( + + + Name + Type + Date Modified + Actions + + + {item => ( + + list.update(item.id, {...item, name: value})} /> + list.update(item.id, {...item, type: value})} /> + list.update(item.id, {...item, date: value})} /> + + + + + + + {({close}) => (<> + Delete item +

Are you sure?

+ + + )} +
+
+
+
+
+
+ )} +
+
+ ); +}; + + +function EditableMyTableHeader(props) { + let {selectionBehavior} = useTableOptions(); + return ( + + {selectionBehavior === 'toggle' && ( + + )} + {props.children} + + ); +} + +function EditableMyRow(props) { + let {selectionBehavior} = useTableOptions(); + + return ( + + {selectionBehavior === 'toggle' && ( + + )} + {props.children} + + ); +} + +function EditableMyCheckbox() { + return ( + + {({isIndeterminate, isSelected}) => ( + + )} + + ); +} + +function EditableCell(props) { + return ( + + {({isEditing, endEditing}) => ( + isEditing + ? { + props.onEdit(e.currentTarget.value); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + props.onEdit(e.currentTarget.value); + endEditing(); + } + }} /> + : props.value + )} + + ); +} + +function DateCell(props) { + let value = parseDate(props.value).toDate(getLocalTimeZone()).toLocaleDateString(); + return ( + + {({isEditing, endEditing}) => ( + isEditing + ? { + props.onEdit(value); + endEditing(); + }} /> + : value + )} + + ); +} + +function SubmittableDateField(props) { + let [value, setValue] = React.useState(() => parseDate(props.value)); + return ( + { + if (e.key === 'Enter') { + props.onEdit(value?.toString()); + } + }}> + + {segment => } + + + ); +} + +export const TableEditingExample: StoryObj = { + render: (args) => , + args: { + selectionBehavior: 'toggle', + selectionMode: 'multiple' + }, + argTypes: { + selectionBehavior: { + control: { + type: 'inline-radio', + options: ['toggle', 'replace'] + } + }, + selectionMode: { + control: { + type: 'inline-radio', + options: ['none', 'single', 'multiple'] + } + } + } +}; diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index 451d50eb9ba..2e7ba4e44d1 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -210,10 +210,28 @@ border-collapse: collapse; } +:global(.react-aria-Column) { + text-align: start; +} + :global(.react-aria-Cell) { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + + & input { + font: inherit; + padding: 0; + margin: 0; + border: none; + } +} + +:global(.react-aria-Row) { + &[aria-selected=true] { + background: blue; + color: white; + } } :global(.react-aria-ColumnResizer) {