Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions apps/website/content/docs/grid/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ interface SerializeRangesArgs<TRow, TRowId, TColumns> {
- `Pretable` — concise declarative preset.
- `usePretable` — surface internals for advanced custom rendering.
- `PretableBadge`, `PretableDelta`, `PretableEntity`, `PretableStatus` — shared cell presentations.
- `PretableButton`, `PretableIconButton`, `PretableSelect`, `PretableTextInput`, `PretableTextarea`, `PretableCheckbox` — [shared controls](/docs/grid/components#the-components), with corresponding `Props` and `Component` types.
- `PretableComponents` — the [replacement map](/docs/grid/components#replacing-a-component) accepted by the surface's `components` prop.
- `PretableSite` / `PretableBuiltInSite` — control placement names for styling and replacement logic.
- `PretableOverlayProvider` / `PretableOverlayProviderProps` — [popup portal scope](/docs/grid/components#overlay-scope) for application-owned hosts and local themes.
- `getDensityHeights` — public density-aware sizing for custom renderers.
- `serializeRanges` / `defaultCoerceForCopy` — clipboard primitives.
- `numberFormats.money` / `numberFormats.accounting` — native numeric option helpers.
Expand Down
495 changes: 230 additions & 265 deletions apps/website/content/docs/grid/components.mdx

Large diffs are not rendered by default.

118 changes: 53 additions & 65 deletions apps/website/content/docs/grid/editing.mdx

Large diffs are not rendered by default.

21 changes: 20 additions & 1 deletion apps/website/content/docs/grid/keyboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,29 @@ Every key the grid above responds to:
| `Enter` | Select the focused row as a full-width range; fires `onRowActivate`. |
| `Space` | As `Enter`, and additionally ticks the row's checkbox when [`rowSelectionColumn`](/docs/grid/selection) is enabled. |

Every row above describes the cursor on a **body** row. On a column header several of these keys mean something else — `Enter` and `Space` sort rather than select, `↓` re-enters the body, and `↑` has nowhere to go — so read [The column header](#the-column-header) alongside this table rather than in place of it.
The table describes a **body** row when editing does not handle the key (see [Editing](#editing)). On a column header several of these keys mean something else — `Enter` and `Space` sort rather than select, `↓` re-enters the body, and `↑` has nowhere to go — so read [The column header](#the-column-header) alongside this table rather than in place of it.

When [row grouping](/docs/grid/grouping#keyboard) is active the grid becomes a `treegrid`: on a group row `←` and `→` take on collapse and expand inside the group column, and `Enter` / `Space` toggles the group instead of selecting it.

## Editing

On an editable cell, Enter starts editing instead of activating the row. F2,
double-click, and typing also begin an edit. Editable boolean cells toggle
with Enter or Space; they do not open an editor.

While a built-in editor is open, it handles keys before grid navigation:

| Key | Effect |
| ------------------- | ------------------------------------------------------- |
| Enter / Shift+Enter | Save and move down / up |
| Tab / Shift+Tab | Save and move right / left, regardless of `tabBehavior` |
| Escape | Cancel and return focus to the cell |

In a multiline editor, Enter inserts a newline; Cmd/Ctrl+Enter saves. Number,
enum, and date editors also handle their own navigation keys. IME composition
retains browser behavior. See [Editing keyboard controls](/docs/grid/editing#keyboard)
for those details. A custom `renderEditor` supplies its own key handlers.

## The column header

The header is a row of the same focus model, reached with `↑` from the first data row rather than with `Tab`. While the cursor is on it:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ import { forwardRef } from "react";
import type { PretableButtonProps } from "@pretable/react";

/**
* The app's own button, standing in for every Button the grid renders. It
* receives exactly what pretable's does — `site` included — and forwards its
* ref, which is the one thing the grid asks of a replacement: menus anchor on
* the node, and focus returns to it.
* Replaces the kit's labelled actions. Preserve native props and the ref so
* the grid's handlers, accessible names, anchoring and focus still work.
*/
export const BrandButton = forwardRef<HTMLButtonElement, PretableButtonProps>(
function BrandButton({ site, variant, className, ...props }, ref) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function ComponentsGrid() {
return (
<div>
<p style={{ margin: "0 0 8px", fontSize: 13 }}>
Every labelled button below is <code>BrandButton</code>; the icon
The kit’s labelled actions below use <code>BrandButton</code>; the icon
buttons — the funnels, the ⋮ menus — are still pretable&apos;s, because
this demo replaces only <code>Button</code>. Reset columns is the
app&apos;s danger button (it branches on <code>site</code>); the Filters
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { forwardRef } from "react";
import type {
PretableIconButtonProps,
PretableTextareaProps,
PretableTextInputProps,
} from "@pretable/react";

// Keep these component identities stable: defining them inside the grid
// would remount the active editor each time the parent renders.
export const AppTextInput = forwardRef<
HTMLInputElement,
PretableTextInputProps
>(function AppTextInput({ site, className, ...props }, ref) {
return (
<input
{...props}
ref={ref}
className={["app-editor-input", className].filter(Boolean).join(" ")}
data-pretable-text-input=""
data-pretable-site={site}
/>
);
});

export const AppTextarea = forwardRef<
HTMLTextAreaElement,
PretableTextareaProps
>(function AppTextarea({ site, className, ...props }, ref) {
return (
<textarea
{...props}
ref={ref}
className={["app-editor-textarea", className].filter(Boolean).join(" ")}
data-pretable-textarea=""
data-pretable-site={site}
/>
);
});

export const AppIconButton = forwardRef<
HTMLButtonElement,
PretableIconButtonProps
>(function AppIconButton({ site, className, ...props }, ref) {
return (
<button
{...props}
ref={ref}
type="button"
className={["app-editor-icon-button", className]
.filter(Boolean)
.join(" ")}
data-pretable-icon-button=""
data-pretable-site={site}
/>
);
});

// The spread preserves value, native handlers (including composition),
// disabled, ARIA attributes, data attributes, children, and inline styles.
// The kit attributes above keep @pretable/ui's layout and state styling.
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"use client";

import { useState } from "react";
import { PretableSurface, type PretableColumn } from "@pretable/react";

import { AppIconButton, AppTextarea, AppTextInput } from "./EditorControls";
import "./editor-controls.css";

interface Item {
id: string;
name: string;
quantity: number | null;
notes: string;
}

const initialRows: Item[] = [
{
id: "notebook",
name: "Notebook",
quantity: 4,
notes: "Dot grid\nRecycled paper",
},
{ id: "pencil", name: "Pencil", quantity: 12, notes: "Pack of twelve" },
{ id: "folder", name: "Folder", quantity: 6, notes: "For project notes" },
];

const columns: PretableColumn<Item>[] = [
{ id: "name", header: "Name", editable: true, widthPx: 170 },
{
id: "quantity",
header: "Quantity",
type: "number",
editable: true,
widthPx: 150,
},
{ id: "notes", header: "Notes", editable: true, wrap: true, widthPx: 270 },
];

export function EditorControlsGrid() {
const [rows, setRows] = useState(initialRows);
const [status, setStatus] = useState(
"Ready to edit. Saves take about 800ms.",
);

return (
<div>
<p style={{ margin: "0 0 12px", fontSize: 13 }}>
Double-click a cell or select it and press <kbd>F2</kbd>. Try the
Quantity stepper, then enter <strong>-1</strong> and press{" "}
<kbd>Enter</kbd> to see a rejected save. Correct the value and press{" "}
<kbd>Enter</kbd> again. In Notes, <kbd>Enter</kbd> adds a line;{" "}
<kbd>Ctrl</kbd> / <kbd>Cmd</kbd> + <kbd>Enter</kbd> saves.{" "}
<kbd>Escape</kbd> cancels.
</p>
<PretableSurface
ariaLabel="Inventory with custom editors"
rows={rows}
columns={columns}
getRowId={(row) => row.id}
viewportHeight={230}
components={{
TextInput: AppTextInput,
Textarea: AppTextarea,
IconButton: AppIconButton,
}}
onRowChange={async ({ rowId, columnId, value, row }) => {
setStatus(`Saving ${columnId}…`);
await new Promise<void>((resolve) => setTimeout(resolve, 800));
if (
columnId === "quantity" &&
typeof value === "number" &&
value < 0
) {
setStatus(
"Quantity rejected. Correct it and press Enter to retry.",
);
throw new Error("Quantity must be zero or greater");
}
setRows((current) =>
current.map((item) => (item.id === rowId ? row : item)),
);
setStatus(`Saved ${columnId}.`);
}}
/>
<p role="status" style={{ margin: "8px 0 0", fontSize: 13 }}>
{status}
</p>
</div>
);
}
5 changes: 5 additions & 0 deletions apps/website/content/examples/components-editors/demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { EditorControlsGrid } from "./EditorControlsGrid";

export default function Demo() {
return <EditorControlsGrid />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/* Keep the skin's editor sizing, focus, saving, and invalid states. */
.app-editor-input,
.app-editor-textarea {
border-radius: 6px;
font-family: inherit;
background: color-mix(in srgb, var(--pretable-bg-grid) 94%, #0d9488);
}

.app-editor-icon-button {
border-radius: 4px;
color: var(--pretable-accent);
}

.app-editor-icon-button:not(:disabled):hover {
background: color-mix(in srgb, var(--pretable-bg-grid) 85%, #0d9488);
}
12 changes: 12 additions & 0 deletions apps/website/content/examples/components-editors/example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineExample } from "../../../lib/docs/examples/define";

export default defineExample({
title: "Use your own editor controls",
description:
"Native input, textarea, and icon-button replacements preserve built-in editing, including a number stepper, multiline drafts, and delayed saves with a corrected retry.",
files: [
"EditorControlsGrid.tsx",
"EditorControls.tsx",
"editor-controls.css",
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,22 @@ export function CustomEditorGrid() {
return (
<div>
<p style={{ margin: "0 0 8px", fontSize: 13 }}>
<strong>Title</strong> uses the built-in text editor. Double-click (or
press <kbd>Enter</kbd>) on a <strong>Priority</strong> cell to open the
custom <code>renderEditor</code> below — a plain
<code>{"<select>"}</code>, bridged to the numeric stored value by{" "}
<code>formatEditValue</code> and <code>parseEditValue</code>.
Edit Priority for Draft proposal, choose High, and press Enter to see a
rejected save. Choose Medium or Low to retry. Enter/Shift+Enter save
down/up; Tab/Shift+Tab save right/left. Escape cancels before saving.
Leaving the field saves in place.
</p>
<PretableSurface<Task>
ariaLabel="Tasks"
columns={columns}
getRowId={(row) => row.id}
rows={rows}
viewportHeight={VIEWPORT_HEIGHT}
onRowChange={({ rowId, row }) => {
onRowChange={async ({ rowId, columnId, row }) => {
await new Promise((resolve) => setTimeout(resolve, 600));
if (columnId === "priority" && rowId === "t1" && row.priority === 3) {
throw new Error("Choose Medium or Low for the proposal.");
}
setRows((previous) =>
previous.map((candidate) =>
candidate.id === rowId ? row : candidate,
Expand Down
113 changes: 113 additions & 0 deletions apps/website/content/examples/custom-cell-editor/PriorityEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useId, useRef } from "react";
import type { PretableEditorInput } from "@pretable/react";

import type { Task } from "./data";

// Keep the component at module scope so changing the draft preserves focus.
export function PriorityEditor({
draft,
setDraft,
status,
error,
commit,
cancel,
}: PretableEditorInput<Task>) {
const errorId = useId();
const composing = useRef(false);
const skipBlur = useRef(false);
const pending =
status === "checking" || status === "validating" || status === "saving";

return (
<div
style={{ width: "100%", display: "flex", alignItems: "center", gap: 8 }}
>
<select
autoFocus
aria-label="Priority"
aria-busy={pending || undefined}
aria-disabled={pending || undefined}
aria-invalid={error ? true : undefined}
aria-errormessage={error ? errorId : undefined}
aria-describedby={error ? errorId : undefined}
value={String(draft ?? "")}
style={{ width: 84, flexShrink: 0 }}
// A select has no readOnly. Keep it focusable while guarding changes.
onPointerDown={(event) => {
if (pending) event.preventDefault();
}}
onChange={(event) => {
if (!pending) {
skipBlur.current = false;
setDraft(event.target.value);
}
}}
onCompositionStart={() => {
composing.current = true;
}}
onCompositionEnd={() => {
composing.current = false;
}}
onKeyDown={(event) => {
event.stopPropagation();
if (
composing.current ||
event.nativeEvent.isComposing ||
event.nativeEvent.keyCode === 229
)
return;
if (pending) {
event.preventDefault();
} else if (event.key === "Escape") {
event.preventDefault();
skipBlur.current = true;
cancel();
} else if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
skipBlur.current = true;
commit(
event.key === "Tab"
? event.shiftKey
? "left"
: "right"
: event.shiftKey
? "up"
: "down",
);
}
// Arrow keys remain native option navigation; they never commit.
}}
onBlur={() => {
if (!skipBlur.current && status === "editing") commit();
skipBlur.current = false;
}}
>
<option value="1">Low</option>
<option value="2">Medium</option>
<option value="3">High</option>
</select>
{pending && (
<small role="status">
{status === "checking"
? "Checking…"
: status === "validating"
? "Validating…"
: "Saving…"}
</small>
)}
{/* renderEditor replaces the built-in error UI, so render it here. */}
{error && (
<small
id={errorId}
role="alert"
style={{
whiteSpace: "nowrap",
color: "var(--pretable-text-error, #b42318)",
}}
>
{error}
</small>
)}
</div>
);
}
Loading