Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/src/pages/docs/api.astro
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ interface CustomCellEditorContext<T> {
['<code>rdt_cellEditable</code>', 'Cell container', 'Added on every cell that has an editor defined (idle state).'],
['<code>rdt_cellEditing</code>', 'Cell container', 'Added while the editor is open. Removes padding and paints the focus ring.'],
['<code>rdt_editInput</code>', '<code>&lt;input&gt;</code>', 'Text editor control inside an editing cell.'],
['<code>rdt_editTextarea</code>', '<code>&lt;textarea&gt;</code>', 'Multi-line textarea editor control inside an editing cell.'],
['<code>rdt_editSelect</code>', '<code>&lt;select&gt;</code>', 'Dropdown editor control inside an editing cell.'],
]}
/>
Expand Down
31 changes: 23 additions & 8 deletions apps/docs/src/pages/docs/inline-editing.astro
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import DocsTable from '../../components/DocsTable.astro';
</p>

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

<h3>Multi-line text (textarea)</h3>
<p>
For multi-line notes, descriptions, or addresses. <kbd>Enter</kbd> inserts a newline, while
<kbd>Ctrl</kbd>+<kbd>Enter</kbd>, <kbd>Cmd</kbd>+<kbd>Enter</kbd>, or clicking away (blur) commits the edit:
</p>

<CodeBlock code={`{
id: 'notes', name: 'Notes', selector: r => r.notes,
editor: { type: 'textarea', placeholder: 'Add notes…', rows: 3 },
onCellEdit,
}`} />

<h3>Number input</h3>
<p>
A native <code>type="number"</code> field with optional <code>min</code>, <code>max</code>,
Expand Down Expand Up @@ -456,14 +468,16 @@ const columns: TableColumn<Employee>[] = [

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

<CodeBlock code={`/* Make the input bigger and chunkier */
.rdt_editInput,
.rdt_editTextarea,
.rdt_editSelect {
font-size: 14px;
font-weight: 500;
Expand All @@ -472,7 +486,7 @@ const columns: TableColumn<Employee>[] = [
/* Use a thicker focus ring */
.rdt_cellEditing {
box-shadow: inset 0 0 0 3px var(--rdt-color-primary);
}`} lang="css" />
} \`} lang="css" />

<h2>Keyboard cell navigation</h2>

Expand All @@ -488,7 +502,7 @@ const columns: TableColumn<Employee>[] = [

<h2>Limitations</h2>
<ul>
<li>Built-in editors cover text, number, date, checkbox, and select. For autocomplete, multi-line text, rich pickers, etc. use the <code>custom</code> editor.</li>
<li>Built-in editors cover text, textarea, number, date, checkbox, and select. For autocomplete, rich pickers, etc. use the <code>custom</code> editor.</li>
<li>The <code>value</code> passed to <code>onCellEdit</code> and <code>validate</code> is always a string. Parse it yourself.</li>
<li>Dropdown <code>label</code>s must be strings or numbers. They render inside native <code>&lt;option&gt;</code> elements which can't contain arbitrary React content.</li>
<li>There is no built-in "dirty" indicator or undo stack. Track that in your application state if you need it.</li>
Expand All @@ -499,6 +513,7 @@ const columns: TableColumn<Employee>[] = [

<CodeBlock code={`type CellEditor<T = unknown> =
| { 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' }
Expand Down
17 changes: 14 additions & 3 deletions src/DataTable.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down
165 changes: 165 additions & 0 deletions src/__tests__/inlineEditing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Row>[] = [
{
id: 'name',
name: 'Name',
selector: r => r.name,
editor: { type: 'textarea', placeholder: 'Enter description' },
onCellEdit,
},
];

const { getByText } = render(<DataTable columns={columns} data={rows} />);
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<Row>[] = [
{
id: 'name',
name: 'Name',
selector: r => r.name,
editor: { type: 'textarea', rows: 5 },
},
];

const { getByText } = render(<DataTable columns={columns} data={rows} />);
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<Row>[] = [
{
id: 'name',
name: 'Name',
selector: r => r.name,
editor: { type: 'textarea' },
onCellEdit,
},
];

const { getByText } = render(<DataTable columns={columns} data={rows} />);
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<Row>[] = [
{
id: 'name',
name: 'Name',
selector: r => r.name,
editor: { type: 'textarea' },
onCellEdit,
},
];

const { getByText } = render(<DataTable columns={columns} data={rows} />);
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<Row>[] = [
{
id: 'name',
name: 'Name',
selector: r => r.name,
editor: { type: 'textarea' },
onCellEdit,
},
];

const { getByText } = render(<DataTable columns={columns} data={rows} />);
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<Row>[] = [
{
id: 'name',
name: 'Name',
selector: r => r.name,
editor: { type: 'textarea' },
onCellEdit,
},
];

const { getByText } = render(<DataTable columns={columns} data={rows} />);
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<Row>[] = [
{
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(<DataTable columns={columns} data={rows} />);
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();
});
});
14 changes: 14 additions & 0 deletions src/components/CellEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ function CellEditor<T>({ edit, row, column, cellNavigation }: CellEditorProps<T>
onClick={e => e.stopPropagation()}
/>
)}
{editing && editor?.type === 'textarea' && (
<textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
className="rdt_editTextarea"
value={editValue}
placeholder={editor.placeholder}
rows={editor.rows ?? 3}
aria-invalid={!!editError}
onChange={e => setEditValue(e.target.value)}
onBlur={() => commitEdit()}
onKeyDown={handleInputKeyDown}
onClick={e => e.stopPropagation()}
/>
)}
{editor?.type === 'checkbox' && (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
Expand Down
11 changes: 9 additions & 2 deletions src/hooks/useCellEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface CellEditApi<T> {
startEdit: () => void;
cancelEdit: () => void;
commitEdit: (value?: string) => void;
handleInputKeyDown: (e: React.KeyboardEvent<HTMLInputElement | HTMLSelectElement>) => void;
handleInputKeyDown: (e: React.KeyboardEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void;
handleCheckboxCommit: (e: React.MouseEvent<HTMLDivElement> | React.ChangeEvent<HTMLInputElement>) => void;
}

Expand Down Expand Up @@ -85,8 +85,15 @@ export default function useCellEdit<T>(column: TableColumn<T>, row: T, rowIndex:
[column, row, editValue, cancelEdit],
);

const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLSelectElement>) => {
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
if (editor?.type === 'textarea') {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
commitEdit();
}
return;
}
commitEdit();
}
if (e.key === 'Escape') {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ export interface CustomCellEditorContext<T = unknown> {
/** Options for inline cell editors. */
export type CellEditor<T = unknown> =
| { 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' }
Expand Down