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';
+ 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' && (
+