From 3e995e417e8ddb43c962c6d84714f6d803eeb2d9 Mon Sep 17 00:00:00 2001 From: pardeep1916P Date: Sun, 27 Sep 2026 01:40:11 +0530 Subject: [PATCH 1/5] feat: add multi-line textarea variant for inline cell editing (#1354) --- apps/docs/src/pages/docs/api.astro | 1 + apps/docs/src/pages/docs/inline-editing.astro | 31 +++- src/DataTable.css | 17 +- src/__tests__/inlineEditing.test.tsx | 165 ++++++++++++++++++ src/components/CellEditor.tsx | 14 ++ src/hooks/useCellEdit.ts | 11 +- src/types.ts | 1 + 7 files changed, 227 insertions(+), 13 deletions(-) diff --git a/apps/docs/src/pages/docs/api.astro b/apps/docs/src/pages/docs/api.astro index ac9c3eb6..8dfe86ff 100644 --- a/apps/docs/src/pages/docs/api.astro +++ b/apps/docs/src/pages/docs/api.astro @@ -219,6 +219,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.'], ]} /> diff --git a/apps/docs/src/pages/docs/inline-editing.astro b/apps/docs/src/pages/docs/inline-editing.astro index 88d3470f..4ba00166 100644 --- a/apps/docs/src/pages/docs/inline-editing.astro +++ b/apps/docs/src/pages/docs/inline-editing.astro @@ -17,8 +17,8 @@ import DocsTable from '../../components/DocsTable.astro';

- 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 +147,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, @@ -456,14 +468,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:

[] = [ /* Use a thicker focus ring */ .rdt_cellEditing { box-shadow: inset 0 0 0 3px var(--rdt-color-primary); -}`} lang="css" /> +} \`} lang="css" />

Keyboard cell navigation

@@ -488,7 +502,7 @@ const columns: TableColumn[] = [

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 +513,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' } diff --git a/src/DataTable.css b/src/DataTable.css index 6fdb5b28..e7175b43 100644 --- a/src/DataTable.css +++ b/src/DataTable.css @@ -923,7 +923,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)); @@ -940,7 +941,16 @@ -webkit-appearance: none; } -.rdt_editInput::placeholder { +.rdt_editTextarea { + padding-top: 12px; + padding-bottom: 12px; + resize: vertical; + 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; } @@ -972,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__/inlineEditing.test.tsx b/src/__tests__/inlineEditing.test.tsx index 2d28f91b..9d2181d9 100644 --- a/src/__tests__/inlineEditing.test.tsx +++ b/src/__tests__/inlineEditing.test.tsx @@ -303,3 +303,168 @@ 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(); + }); +}); diff --git a/src/components/CellEditor.tsx b/src/components/CellEditor.tsx index 5b76fd9e..03524390 100644 --- a/src/components/CellEditor.tsx +++ b/src/components/CellEditor.tsx @@ -44,6 +44,20 @@ function CellEditor({ edit, row, column, cellNavigation }: CellEditorProps onClick={e => e.stopPropagation()} /> )} + {editing && editor?.type === 'textarea' && ( +