From 30a02efb2e8487d42322f63bb8458e83675d05b1 Mon Sep 17 00:00:00 2001 From: Denis Yakshov Date: Tue, 12 May 2026 10:00:59 +0300 Subject: [PATCH] feat(react,TableCell): add editable table cells --- .../src/components/Table/Table.stories.tsx | 48 +++++- .../components/Table/TableCell/TableCell.tsx | 144 +++++++++++++++++- .../Table/TableCell/TableCell.types.ts | 43 +++++- packages/react/src/components/locale/index.ts | 6 +- packages/theme/lib/components/_table.scss | 132 +++++++++++++++- 5 files changed, 359 insertions(+), 14 deletions(-) diff --git a/packages/react/src/components/Table/Table.stories.tsx b/packages/react/src/components/Table/Table.stories.tsx index 0140ae988..795fec27b 100644 --- a/packages/react/src/components/Table/Table.stories.tsx +++ b/packages/react/src/components/Table/Table.stories.tsx @@ -122,6 +122,36 @@ export default meta; type Story = StoryObj; +const TableCellAge = ({ value, onEditAccept }: { value: number; onEditAccept: (value: number) => void }) => { + const [inputValue, setInputValue] = useState(value.toString()); + + return ( + 150} + inputProps={{ + inputMode: 'numeric', + value: inputValue, + onChange: (e) => { + setInputValue(e.target.value); + }, + }} + onEdit={() => { + setInputValue(value.toString()); + }} + onEditAccept={(input) => { + onEditAccept(+input.value); + }} + onEditCancel={() => { + setInputValue(value.toString()); + }} + > + {value} + + ); +}; + export const Demo: Story = { render: function Render(args, context) { const { striped, colDividers, rowDividers } = args; @@ -130,6 +160,7 @@ export const Demo: Story = { const ref = useRef(null); const rowRef = useRef(null); + const [data, setData] = useState(DATA); const [fields] = useState>(['id', 'name', 'age', 'status', 'city']); const [columns, setColumns] = useState([ '56px', @@ -182,6 +213,7 @@ export const Demo: Story = { {fields.map((field, index) => ( - {DATA[locale].map((row) => { + {data[locale].map((row, i) => { const isSelected = selected.indexOf(row.id) !== -1; const labelId = `story-usage-checkbox-${row.id}`; @@ -224,11 +256,21 @@ export const Demo: Story = { {fields.map( (field) => field !== 'name' && - field !== 'id' && ( + field !== 'id' && + (field === 'age' ? ( + { + data[locale][i][field as 'age'] = value; + setData({ ...data }); + }} + /> + ) : ( {row[field]} - ) + )) )} diff --git a/packages/react/src/components/Table/TableCell/TableCell.tsx b/packages/react/src/components/Table/TableCell/TableCell.tsx index ad6b85e57..4dcc8fde1 100644 --- a/packages/react/src/components/Table/TableCell/TableCell.tsx +++ b/packages/react/src/components/Table/TableCell/TableCell.tsx @@ -10,12 +10,22 @@ import { useForkRef } from '@mui/material/utils'; import { useTableCellContext } from './TableCell.context'; import { useLatest } from '../../../hooks/useLatest'; +import { IconCheckLineW400, IconCloseLineW350, IconPencilFillW300 } from '../../../icons'; +import { Button } from '../../Button'; const RESIZE_STEPS: Record = { ArrowLeft: -16, ArrowRight: 16, }; +const onPreventDefault = (e: React.MouseEvent) => { + e.preventDefault(); +}; + +const onStopPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); +}; + export const TableCell = memo( forwardRef(function TableCell(inProps, inRef) { const context = useTableCellContext(); @@ -34,9 +44,22 @@ export const TableCell = memo( onResizeCommit, colSpan, minWidth, - labelResize, pin, + error, overlap, + editable, + onEdit, + onEditAccept: onEditAcceptProp, + onEditCancel: onEditCancelProp, + inputComponent: InputComponent = 'input', + inputProps, + inputRef: inputRefProp, + iconEdit = , + iconEditAccept = , + iconEditCancel = , + labelResize, + labelEditAccept, + labelEditCancel, } = useDefaultProps({ props: inProps, name: 'ESTableCell', @@ -90,10 +113,43 @@ export const TableCell = memo( } }; - const onClick = (event: React.MouseEvent) => { - if (overlap) { + const [editing, setEditing] = useState(false); + const inputRef = useRef(null); + const inputRefHandle = useForkRef(inputRef, inputRefProp); + + const onEditAccept = () => { + if (onEditAcceptProp && inputRef.current) { + onEditAcceptProp(inputRef.current); + } + + setEditing(false); + }; + + const onEditCancel = () => { + if (onEditCancelProp && inputRef.current) { + onEditCancelProp(inputRef.current); + } + + setEditing(false); + }; + + const onClick = (event: React.MouseEvent | React.FocusEvent) => { + if (overlap || editable) { event.stopPropagation(); } + + if (editable) { + if (!editing) { + setEditing(true); + onEdit?.(); + } + + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.focus?.(); + } + }); + } }; useEffect(() => { @@ -134,7 +190,10 @@ export const TableCell = memo( `es-table-cell--variant--${variant}`, `es-table-cell--padding--${padding}`, pin && `es-table-cell--pin--${pin}`, + error && 'es-table-cell--error', overlap && 'es-table-cell--overlap', + editable && 'es-table-cell--editable', + editing && 'es-table-cell--editing', isResizing && 'es-table-cell--resizing', rowDivider && 'es-table-cell--row-divider', colDivider && 'es-table-cell--col-divider', @@ -145,11 +204,62 @@ export const TableCell = memo( id={id} role={variant === 'head' ? 'columnheader' : 'cell'} style={{ '--es-table-cell-col-span': colSpan, ...style } as React.CSSProperties} + onBlur={(e) => { + if (!ref.current?.contains(e.relatedTarget)) { + if (error) { + onEditCancel(); + } else { + onEditAccept(); + } + } + }} onClick={onClick} > -
+ {editable && ( + + + + )} + +
{ + if (editing && e.target !== inputRef.current) { + e.preventDefault(); + } + }} + >
-
{children}
+
+ {editing ? ( + ) => { + inputProps?.onKeyDown?.(e); + + if (e.defaultPrevented) { + return; + } + + if (e.key === 'Enter' && !error) { + onEditAccept(); + } + + if (e.key === 'Escape') { + onEditCancel(); + } + }} + /> + ) : ( + children + )} +
{!!onResize && (
+ + {editable && !editing && ( +
+ {iconEdit} +
+ )} + + {editing && ( +
+ + +
+ )}
); }) diff --git a/packages/react/src/components/Table/TableCell/TableCell.types.ts b/packages/react/src/components/Table/TableCell/TableCell.types.ts index ae4bfac68..9abddd6d6 100644 --- a/packages/react/src/components/Table/TableCell/TableCell.types.ts +++ b/packages/react/src/components/Table/TableCell/TableCell.types.ts @@ -17,8 +17,7 @@ export interface TableCellProps { padding?: 'none' | 'normal' | 'checkbox'; /** A non-negative integer value that indicates for how many columns the cell extends. */ colSpan?: number; - /** If true, the table cell will overlap it's row. */ - overlap?: boolean; + /** If `true`, the table row divider is shown. * @default false */ @@ -34,6 +33,34 @@ export interface TableCellProps { align?: 'flex-start' | 'center' | 'flex-end'; /** Pin the cell to the left or right side. */ pin?: 'left' | 'right'; + /** Indicate if component is in error state. */ + error?: boolean; + /** If true, the table cell will overlap it's row. */ + overlap?: boolean; + /** If true, the table cell is editable. */ + editable?: boolean; + + /** + * The component used for the `input` element. + * Either a string to use a HTML element or a component. + * @default 'input' + */ + inputComponent?: React.ElementType; + /** + * Attributes applied to the `input` element. + */ + inputProps?: React.InputHTMLAttributes; + /** + * Pass a ref to the `input` element. + */ + inputRef?: React.Ref; + + /** Callback fired when user starts cell editing. */ + onEdit?: () => void; + /** Callback fired when user accepts cell editing. */ + onEditAccept?: (input: HTMLInputElement) => void; + /** Callback fired when user cancels cell editing. */ + onEditCancel?: (input: HTMLInputElement) => void; /** The id attribute passed to root element. */ id?: string; @@ -42,12 +69,24 @@ export interface TableCellProps { onResize?: (width: number, element: HTMLElement) => void; /** Callback function that is fired when the cell's width finished changing. */ onResizeCommit?: (width: number, element: HTMLElement) => void; + /** * The minimum width of the cell for manual resizing. * @default 0 */ minWidth?: number; + /** Icon for the edit hint. */ + iconEdit?: React.ReactNode; + /** Icon for the edit accept button. */ + iconEditAccept?: React.ReactNode; + /** Icon for the edit cancel button. */ + iconEditCancel?: React.ReactNode; + /** Text for the resize button aria-label. */ labelResize?: string; + /** Text for the edit accept button aria-label. */ + labelEditAccept?: string; + /** Text for the edit cancel button aria-label. */ + labelEditCancel?: string; } diff --git a/packages/react/src/components/locale/index.ts b/packages/react/src/components/locale/index.ts index 2e4a8de8e..f3700a7b6 100644 --- a/packages/react/src/components/locale/index.ts +++ b/packages/react/src/components/locale/index.ts @@ -153,7 +153,7 @@ export interface Localization { defaultProps: Pick; }; ESTableCell: { - defaultProps: Pick; + defaultProps: Pick; }; ESMadeBy: { defaultProps: Pick; @@ -379,6 +379,8 @@ export const en: Localization = { ESTableCell: { defaultProps: { labelResize: 'Resize', + labelEditAccept: 'Accept', + labelEditCancel: 'Cancel', }, }, ESMadeBy: { @@ -607,6 +609,8 @@ export const ru: Localization = { ESTableCell: { defaultProps: { labelResize: 'Изменить ширину', + labelEditAccept: 'Принять', + labelEditCancel: 'Отмена', }, }, ESMadeBy: { diff --git a/packages/theme/lib/components/_table.scss b/packages/theme/lib/components/_table.scss index ba517d48a..5aa3ba9fb 100644 --- a/packages/theme/lib/components/_table.scss +++ b/packages/theme/lib/components/_table.scss @@ -113,9 +113,11 @@ } } - &:hover .es-table-cell__resize::after { - background-color: var(--es-mono-a-a200); - width: 1px; + &:hover { + & .es-table-cell__resize::after { + background-color: var(--es-mono-a-a200); + width: 1px; + } } .es-table-cell__resize:hover::after { @@ -184,6 +186,40 @@ } } + &--editable { + cursor: pointer; + } + + &--error, + &--editing { + &::before { + content: ''; + inset: 0; + pointer-events: none; + position: absolute; + } + } + + &--error { + &::before { + border: 1px solid var(--es-error-300); + } + } + + &--editing { + cursor: text; + + &::before { + border: 2px solid var(--es-primary-300); + } + + &.es-table-cell--error { + &::before { + border-color: var(--es-error-300); + } + } + } + &__wrapper { height: 100%; width: 100%; @@ -252,6 +288,83 @@ } } } + + &__edit-icon { + color: var(--es-mono-a-a500); + display: flex; + opacity: 0; + pointer-events: none; + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + + &--align--flex-end { + left: 16px; + right: unset; + } + } + + &__edit-enter-link { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + opacity: 0; + overflow: hidden; + padding: 0; + pointer-events: none; + position: absolute; + white-space: nowrap; + width: 1px; + } + + &__buttons { + background-color: var(--es-surface-400); + border-radius: 6px; + box-shadow: var(--es-shadow-down-400); + cursor: default; + display: flex; + gap: 2px; + overflow: hidden; + position: absolute; + right: 0; + top: calc(100% + 2px); + z-index: 2; + + & .es-button { + &:focus-visible { + outline-offset: -2px; + } + } + + & .es-button--color--tertiary { + --icon: var(--es-mono-a-a500); + } + } + + &__input { + background: none; + border: 0; + font: inherit; + letter-spacing: inherit; + min-width: 0; + outline: 0; + padding: 0; + width: 100%; + + &.es-table-cell__input--align--flex-start { + text-align: left; + } + + &.es-table-cell__input--align--center { + text-align: center; + } + + &.es-table-cell__input--align--flex-end { + text-align: right; + } + } } .es-table-foot { @@ -325,6 +438,19 @@ } .es-table-row { + &:last-child { + & .es-table-cell__buttons { + bottom: calc(100% + 2px); + top: unset; + } + } + + &:hover { + & .es-table-cell__edit-icon { + opacity: 1; + } + } + &--selected { & .es-table-cell__container { background-color: var(--es-secondary-a100);