diff --git a/CHANGELOG.md b/CHANGELOG.md index a3aad930..253f5135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ A summary of notable changes per release. For the full commit history see the [repository on GitHub](https://github.com/jbetancur/react-data-table-component/commits/master). +## 8.12.0 + +### New features + +- **`textarea` cell editor** — multi-line text editor with newline insertion, modifier-key/blur commit, and row height expansion. → [Inline editing](/docs/inline-editing) ([#1354](https://github.com/jbetancur/react-data-table-component/issues/1354)) + +--- + ## 8.11.0 ### Behavior changes diff --git a/apps/docs/src/components/demos/InlineEditingDemo.tsx b/apps/docs/src/components/demos/InlineEditingDemo.tsx index bcc89f39..7a8f29e0 100644 --- a/apps/docs/src/components/demos/InlineEditingDemo.tsx +++ b/apps/docs/src/components/demos/InlineEditingDemo.tsx @@ -12,14 +12,55 @@ interface Employee { status: Status; salary: number; remote: boolean; + notes: string; } const initialData: Employee[] = [ - { id: 1, name: 'Aria Chen', department: 'Engineering', status: 'Active', salary: 155000, remote: true }, - { id: 2, name: 'Marcus Webb', department: 'Product', status: 'Active', salary: 132000, remote: false }, - { id: 3, name: 'Priya Kapoor', department: 'Design', status: 'On Leave', salary: 118000, remote: true }, - { id: 4, name: 'Jordan Ellis', department: 'Analytics', status: 'Active', salary: 143000, remote: false }, - { id: 5, name: 'Sam Rivera', department: 'Engineering', status: 'Terminated', salary: 128000, remote: false }, + { + id: 1, + name: 'Aria Chen', + department: 'Engineering', + status: 'Active', + salary: 155000, + remote: true, + notes: 'Tech lead on the platform team.\nMentoring two juniors.', + }, + { + id: 2, + name: 'Marcus Webb', + department: 'Product', + status: 'Active', + salary: 132000, + remote: false, + notes: 'Focusing on Q3 roadmap and feature delivery.', + }, + { + id: 3, + name: 'Priya Kapoor', + department: 'Design', + status: 'On Leave', + salary: 118000, + remote: true, + notes: 'Returning next month.\nDesign system lead.', + }, + { + id: 4, + name: 'Jordan Ellis', + department: 'Analytics', + status: 'Active', + salary: 143000, + remote: false, + notes: 'Leading the ETL pipeline migration project.', + }, + { + id: 5, + name: 'Sam Rivera', + department: 'Engineering', + status: 'Terminated', + salary: 128000, + remote: false, + notes: 'Completed handover of all open tickets.', + }, ]; const statusColors: Record = { @@ -106,6 +147,15 @@ export default function InlineEditingDemo() { }, onCellEdit: handleCellEdit, }, + { + id: 'notes', + name: 'Notes', + selector: r => r.notes, + grow: 2, + maxWidth: '150px', + editor: { type: 'textarea', placeholder: 'Add notes…', rows: 2 }, + onCellEdit: handleCellEdit, + }, { id: 'remote', name: 'Remote', @@ -121,7 +171,9 @@ export default function InlineEditingDemo() {

Click any cell to edit. Name and Salary are text inputs;{' '} - Department and Status are dropdowns; Remote is a checkbox.{' '} + Department and Status are dropdowns; Notes is a + multi-line textarea (Enter for newline, Ctrl/Cmd+Enter or blur + to commit); Remote is a checkbox.{' '} Enter commits, Esc cancels. Name and Salary are validated: try committing an empty name or a negative salary to see the inline error. Keyboard navigation is enabled too: click or Tab into the table, move between cells and headers with the arrow keys, and press{' '} diff --git a/apps/docs/src/pages/docs/api.astro b/apps/docs/src/pages/docs/api.astro index ac9c3eb6..8ca6b852 100644 --- a/apps/docs/src/pages/docs/api.astro +++ b/apps/docs/src/pages/docs/api.astro @@ -175,6 +175,7 @@ const columns: TableColumn[] = [ type CellEditor = | { type: 'text'; placeholder?: string } + | { type: 'textarea'; placeholder?: string; rows?: number } | { type: 'number'; placeholder?: string; min?: number; max?: number; step?: number } | { type: 'date'; min?: string; max?: string } | { type: 'checkbox' } @@ -202,8 +203,9 @@ interface CustomCellEditorContext { type', '"text" | "number" | "date" | "checkbox" | "select" | "custom"', 'Editor widget to render.'], - ['text / number / select', 'placeholder', 'string', 'Placeholder text. For select, shown as a disabled hidden option when the current value is empty.'], + ['All', 'type', '"text" | "textarea" | "number" | "date" | "checkbox" | "select" | "custom"', 'Editor widget to render.'], + ['text / textarea / number / select', 'placeholder', 'string', 'Placeholder text. For select, shown as a disabled hidden option when the current value is empty.'], + ['textarea', 'rows', 'number', 'Visible text lines for multi-line editing (default: 3). Forwarded to native <textarea>.'], ['number', 'min / max / step', 'number', 'Forwarded to the native <input type="number">.'], ['date', 'min / max', 'string', 'Forwarded to the native <input type="date"> as ISO dates.'], ['select', 'options', '{ value: string; label: ReactNode }[]', 'Dropdown options. The value is what gets committed; label is displayed (must be string or number inside native <option>).'], @@ -219,6 +221,7 @@ interface CustomCellEditorContext { ['rdt_cellEditable', 'Cell container', 'Added on every cell that has an editor defined (idle state).'], ['rdt_cellEditing', 'Cell container', 'Added while the editor is open. Removes padding and paints the focus ring.'], ['rdt_editInput', '<input>', 'Text editor control inside an editing cell.'], + ['rdt_editTextarea', '<textarea>', 'Multi-line textarea editor control inside an editing cell.'], ['rdt_editSelect', '<select>', 'Dropdown editor control inside an editing cell.'], ]} /> @@ -231,6 +234,7 @@ interface CustomCellEditorContext { ['--rdt-color-cell-edit-bg', '8% primary on bg', 'Background of the cell while editing.'], ['--rdt-color-cell-edit-hover', '6% primary', 'Background of an editable cell on hover.'], ['--rdt-color-cell-edit-hover-border', '40% primary', 'Dashed underline color on editable cell hover.'], + ['--rdt-cell-edit-textarea-max-height', '240px', 'Maximum height of the inline textarea editor before scrolling.'], ]} /> diff --git a/apps/docs/src/pages/docs/inline-editing.astro b/apps/docs/src/pages/docs/inline-editing.astro index 88d3470f..5c3f9d74 100644 --- a/apps/docs/src/pages/docs/inline-editing.astro +++ b/apps/docs/src/pages/docs/inline-editing.astro @@ -12,13 +12,14 @@ import DocsTable from '../../components/DocsTable.astro';

Click an editable cell to swap its content for an inline editor. Commit with Enter - or by clicking away; Esc discards the change. The library doesn't own your data -. You handle the update in onCellEdit. + (or Ctrl/Cmd+Enter for textareas) or by clicking away; + Esc discards the change. The library doesn't own your data. You handle the update + in onCellEdit.

- Six editor types are built in: text, number, date, - checkbox, select, and custom. The cell itself becomes + Seven editor types are built in: text, textarea, number, + date, checkbox, select, and custom. The cell itself becomes the editor: full-bleed input, primary-color focus ring, no nested borders. Add a validate function on any column to reject invalid input or surface an inline error message. @@ -147,6 +148,18 @@ export default function App() { editor: { type: 'text', placeholder: 'Enter a name…' }, onCellEdit }`} /> +

Multi-line text (textarea)

+

+ For multi-line notes, descriptions, or addresses. Enter inserts a newline, while + Ctrl+Enter, Cmd+Enter, or clicking away (blur) commits the edit: +

+ + r.notes, + editor: { type: 'textarea', placeholder: 'Add notes…', rows: 3 }, + onCellEdit, +}`} /> +

Number input

A native type="number" field with optional min, max, @@ -271,6 +284,7 @@ export default function App() { ['--rdt-color-cell-edit-bg', '8% primary on bg', 'Background of the cell while editing.'], ['--rdt-color-cell-edit-hover', '6% primary', 'Background of an editable cell on hover.'], ['--rdt-color-cell-edit-hover-border', '40% primary', 'Color of the dashed underline that hints "this cell is editable" on hover.'], + ['--rdt-cell-edit-textarea-max-height', '240px', 'Maximum height of the inline textarea editor before scrolling.'], ]} /> @@ -456,14 +470,16 @@ const columns: TableColumn[] = [

Styling the input

- Editor controls have class rdt_editInput (text) and rdt_editSelect - (dropdown). Both inherit font and color from the cell and stretch to fill its full - footprint. The cell itself uses rdt_cellEditable (idle hover state) and - rdt_cellEditing (active edit state). Override any of them in your stylesheet: + Editor controls have class rdt_editInput (text), rdt_editTextarea + (multi-line text), and rdt_editSelect (dropdown). All inherit font and color + from the cell and stretch to fill its full footprint. The cell itself uses + rdt_cellEditable (idle hover state) and rdt_cellEditing (active edit state). + Override any of them in your stylesheet:

[] = [

Limitations

    -
  • Built-in editors cover text, number, date, checkbox, and select. For autocomplete, multi-line text, rich pickers, etc. use the custom editor.
  • +
  • Built-in editors cover text, textarea, number, date, checkbox, and select. For autocomplete, rich pickers, etc. use the custom editor.
  • The value passed to onCellEdit and validate is always a string. Parse it yourself.
  • Dropdown labels must be strings or numbers. They render inside native <option> elements which can't contain arbitrary React content.
  • There is no built-in "dirty" indicator or undo stack. Track that in your application state if you need it.
  • @@ -499,6 +515,7 @@ const columns: TableColumn[] = [ = | { type: 'text'; placeholder?: string } + | { type: 'textarea'; placeholder?: string; rows?: number } | { type: 'number'; placeholder?: string; min?: number; max?: number; step?: number } | { type: 'date'; min?: string; max?: string } | { type: 'checkbox' } @@ -533,7 +550,7 @@ interface CustomCellEditorContext { ['editable', 'boolean', 'Shorthand for a text editor. Equivalent to editor: { type: \'text\' }. Ignored when editor is set.'], ['editor', 'CellEditor', 'Editor configuration. See CellEditor above for the full union.'], ['validate', '(value, row, column) => true | false | string', 'Runs before onCellEdit. Return true to accept, false to reject silently, or a string to keep the editor open with an inline error.'], - ['onCellEdit', '(row: T, value: string, column: TableColumn<T>) => void', 'Called when the user commits an edit (Enter, blur, or selection change for dropdowns). Receives the original row, the new string value, and the column definition.'], + ['onCellEdit', '(row: T, value: string, column: TableColumn<T>) => void', 'Called when the user commits an edit (Enter, Ctrl/Cmd+Enter for textareas, blur, or selection change for dropdowns). Receives the original row, the new string value, and the column definition.'], ]} /> diff --git a/src/DataTable.css b/src/DataTable.css index 91ed38aa..f9e26914 100644 --- a/src/DataTable.css +++ b/src/DataTable.css @@ -921,7 +921,8 @@ the cell keeps its horizontal padding (and thus its original width — no jitter). It stretches vertically because the cell uses align-items:stretch. */ .rdt_editInput, -.rdt_editSelect { +.rdt_editSelect, +.rdt_editTextarea { display: block; box-sizing: border-box; width: calc(100% + 2 * var(--rdt-cell-padding-x, 16px)); @@ -938,7 +939,18 @@ -webkit-appearance: none; } -.rdt_editInput::placeholder { +.rdt_editTextarea { + padding-top: 12px; + padding-bottom: 12px; + resize: none; + max-height: var(--rdt-cell-edit-textarea-max-height, 240px); + overflow-y: auto; + font-family: inherit; + line-height: inherit; +} + +.rdt_editInput::placeholder, +.rdt_editTextarea::placeholder { color: var(--rdt-color-text-secondary, rgba(0, 0, 0, 0.54)); opacity: 0.7; } @@ -970,7 +982,8 @@ /* Dense rows shrink the editor height to match */ .rdt_rowDense .rdt_editInput, -.rdt_rowDense .rdt_editSelect { +.rdt_rowDense .rdt_editSelect, +.rdt_rowDense .rdt_editTextarea { min-height: 32px; } diff --git a/src/__tests__/accessibility.test.tsx b/src/__tests__/accessibility.test.tsx index c0e056a8..2486b786 100644 --- a/src/__tests__/accessibility.test.tsx +++ b/src/__tests__/accessibility.test.tsx @@ -124,6 +124,15 @@ describe('axe', () => { expect(container.querySelector('.rdt_cellEditing input')).not.toBeNull(); await expectNoViolations(); }); + + test('cell editor open (textarea)', async () => { + const { container } = render( + , + ); + fireEvent.click(container.querySelector('.rdt_cellEditable') as HTMLElement); + expect(container.querySelector('.rdt_cellEditing textarea')).not.toBeNull(); + await expectNoViolations(); + }); }); describe('header cell semantics', () => { @@ -161,6 +170,27 @@ describe('header cell semantics', () => { expect(label?.closest('[role="columnheader"]')).not.toBeNull(); }); + test('the inline textarea editor is labelled by its column header', () => { + const jsxColumns: TableColumn[] = [ + { + id: 'name', + name: ( + + Full notes + + ), + selector: r => r.name, + editor: { type: 'textarea' }, + }, + ]; + const { container } = render(); + fireEvent.click(container.querySelector('.rdt_cellEditable') as HTMLElement); + const textarea = container.querySelector('.rdt_cellEditing textarea') as HTMLElement; + const label = document.getElementById(textarea.getAttribute('aria-labelledby') as string); + expect(label?.textContent).toBe('Full notes'); + expect(label?.closest('[role="columnheader"]')).not.toBeNull(); + }); + test('the sort control is queryable as a button named after its column', () => { const { container, getByRole } = render(); fireEvent.click(getByRole('button', { name: 'Name' })); diff --git a/src/__tests__/inlineEditing.test.tsx b/src/__tests__/inlineEditing.test.tsx index 2d28f91b..63ddd16c 100644 --- a/src/__tests__/inlineEditing.test.tsx +++ b/src/__tests__/inlineEditing.test.tsx @@ -303,3 +303,205 @@ describe('inline editing: custom editor', () => { expect(onCellEdit).not.toHaveBeenCalled(); }); }); + +describe('inline editing: textarea editor', () => { + test('renders a textarea with default rows (3) and commits on blur', async () => { + const onCellEdit = vi.fn(); + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + editor: { type: 'textarea', placeholder: 'Enter description' }, + onCellEdit, + }, + ]; + + const { getByText } = render(); + clickCell(getByText, 'Alice'); + + const textarea = await screen.findByDisplayValue('Alice'); + expect(textarea.tagName.toLowerCase()).toBe('textarea'); + expect(textarea).toHaveAttribute('rows', '3'); + expect(textarea).toHaveAttribute('placeholder', 'Enter description'); + + fireEvent.change(textarea, { target: { value: 'Alice\nLine 2\nLine 3' } }); + fireEvent.blur(textarea); + + await waitFor(() => expect(onCellEdit).toHaveBeenCalled()); + expect(onCellEdit.mock.calls[0][1]).toBe('Alice\nLine 2\nLine 3'); + }); + + test('renders a textarea with custom rows', async () => { + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + editor: { type: 'textarea', rows: 5 }, + }, + ]; + + const { getByText } = render(); + clickCell(getByText, 'Alice'); + + const textarea = await screen.findByDisplayValue('Alice'); + expect(textarea).toHaveAttribute('rows', '5'); + }); + + test('Enter key does not commit, allowing newlines', async () => { + const onCellEdit = vi.fn(); + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + editor: { type: 'textarea' }, + onCellEdit, + }, + ]; + + const { getByText } = render(); + clickCell(getByText, 'Alice'); + + const textarea = await screen.findByDisplayValue('Alice'); + fireEvent.change(textarea, { target: { value: 'Alice\nNext line' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + + // Enter should NOT commit for textarea + expect(onCellEdit).not.toHaveBeenCalled(); + expect(textarea).toHaveValue('Alice\nNext line'); + }); + + test('Ctrl+Enter commits the edit', async () => { + const onCellEdit = vi.fn(); + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + editor: { type: 'textarea' }, + onCellEdit, + }, + ]; + + const { getByText } = render(); + clickCell(getByText, 'Alice'); + + const textarea = await screen.findByDisplayValue('Alice'); + fireEvent.change(textarea, { target: { value: 'Alice\nNote line' } }); + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }); + + await waitFor(() => expect(onCellEdit).toHaveBeenCalled()); + expect(onCellEdit.mock.calls[0][1]).toBe('Alice\nNote line'); + }); + + test('Cmd/Meta+Enter commits the edit', async () => { + const onCellEdit = vi.fn(); + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + editor: { type: 'textarea' }, + onCellEdit, + }, + ]; + + const { getByText } = render(); + clickCell(getByText, 'Alice'); + + const textarea = await screen.findByDisplayValue('Alice'); + fireEvent.change(textarea, { target: { value: 'Alice\nMac note' } }); + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }); + + await waitFor(() => expect(onCellEdit).toHaveBeenCalled()); + expect(onCellEdit.mock.calls[0][1]).toBe('Alice\nMac note'); + }); + + test('Escape cancels the edit without committing', async () => { + const onCellEdit = vi.fn(); + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + editor: { type: 'textarea' }, + onCellEdit, + }, + ]; + + const { getByText } = render(); + clickCell(getByText, 'Alice'); + + const textarea = await screen.findByDisplayValue('Alice'); + fireEvent.change(textarea, { target: { value: 'Alicia\nDraft' } }); + fireEvent.keyDown(textarea, { key: 'Escape' }); + + await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument()); + expect(onCellEdit).not.toHaveBeenCalled(); + expect(getByText('Alice')).toBeInTheDocument(); + }); + + test('validates textarea edits and displays error tooltip on rejection', async () => { + const onCellEdit = vi.fn(); + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + editor: { type: 'textarea' }, + validate: value => (value.trim().length < 5 ? 'Minimum 5 characters' : true), + onCellEdit, + }, + ]; + + const { getByText } = render(); + clickCell(getByText, 'Alice'); + + const textarea = await screen.findByDisplayValue('Alice'); + fireEvent.change(textarea, { target: { value: 'Hi' } }); + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }); + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Minimum 5 characters')); + expect(textarea).toHaveAttribute('aria-invalid', 'true'); + expect(onCellEdit).not.toHaveBeenCalled(); + }); + + test('places caret at the end of text when opened', async () => { + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + editor: { type: 'textarea' }, + }, + ]; + + const { getByText } = render(); + clickCell(getByText, 'Alice'); + + const textarea = (await screen.findByDisplayValue('Alice')) as HTMLTextAreaElement; + expect(textarea.selectionStart).toBe('Alice'.length); + expect(textarea.selectionEnd).toBe('Alice'.length); + }); + + test('auto-grows height on content change', async () => { + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + editor: { type: 'textarea' }, + }, + ]; + + const { getByText } = render(); + clickCell(getByText, 'Alice'); + + const textarea = (await screen.findByDisplayValue('Alice')) as HTMLTextAreaElement; + expect(textarea.style.height).toBeDefined(); + fireEvent.change(textarea, { target: { value: 'Alice\nLine 2\nLine 3\nLine 4' } }); + expect(textarea.style.height).toBeDefined(); + }); +}); diff --git a/src/components/CellEditor.tsx b/src/components/CellEditor.tsx index 1559f90b..fb3ddfd5 100644 --- a/src/components/CellEditor.tsx +++ b/src/components/CellEditor.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import type { TableColumn } from '../types'; import type { CellEditApi } from '../hooks/useCellEdit'; +import useIsomorphicLayoutEffect from '../hooks/useIsomorphicLayoutEffect'; interface CellEditorProps { edit: CellEditApi; @@ -32,6 +33,14 @@ function CellEditor({ edit, row, column, labelledBy, cellNavigation }: CellEd handleCheckboxCommit, } = edit; + useIsomorphicLayoutEffect(() => { + const el = inputRef.current; + if (editing && el instanceof HTMLTextAreaElement) { + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + } + }, [editing, editValue, inputRef]); + return ( <> {editing && (editor?.type === 'text' || editor?.type === 'number' || editor?.type === 'date') && ( @@ -52,6 +61,21 @@ function CellEditor({ edit, row, column, labelledBy, cellNavigation }: CellEd onClick={e => e.stopPropagation()} /> )} + {editing && editor?.type === 'textarea' && ( +