diff --git a/CONTEXT.md b/CONTEXT.md
index c3e1ce7..f6c03bc 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -30,8 +30,19 @@ time.
**Row key** — the value that identifies a row for its whole life, independent of
where the row currently sits. Positions move; keys do not.
-**Cell kind** — a string naming how one column draws and edits. A **cell
-registry** maps every kind to its renderer and its editor.
+**Cell type** — the string in a column's `type` naming how that column draws
+and edits. The ones this package draws are prefixed `dg:`; glide's own kinds are
+named as glide names them. A **cell registry** maps every type to its maker, its
+renderer, and its validator; the engine builds one per grid from the built-in
+types plus the descriptor's own.
+
+**Cell options** — one column's settings for its cell type, written on the
+column and carried inside every cell that column makes. Only the cell type they
+belong to reads them.
+
+**Custom column** — a column whose `type` names a cell the host registered
+rather than one this package ships. It says so with `custom: true`, which is
+what lets every other column's `type` and options be checked.
## Rows and where they live
@@ -110,3 +121,12 @@ flash.
**Appearance watcher** — the host-owned thing that decides: an OS media query,
a host page's class, a user toggle. Always outside this package.
+
+## Column layout
+
+**Columns state** — the user's own arrangement of the columns: their order,
+the widths they dragged, and the ones they hid. Client-owned, and the only part
+of the presentation the user changes directly.
+
+**Columns adapter** — where that arrangement is read from and written to. The
+engine's default writes to Web Storage; a descriptor may name its own.
diff --git a/README.md b/README.md
index fd77c89..a624571 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,10 @@ column layout, and typed cell editors — all against a server that holds far
more rows than the browser ever will.
> **Status: 0.x.** The API will break between minor versions. It is pinned to a
-> **pre-release** build of glide-data-grid (`6.0.4-alpha24`); that pin loosens
-> when glide 6 ships stable.
+> **pre-release** build of glide-data-grid (`6.0.4-alpha24`). This is not a
+> choice: glide's latest stable release, `6.0.3`, declares a React peer of
+> `^16.12.0 || 17.x || 18.x`. React 19 support first appears in the `6.0.4`
+> alpha line. The pin loosens when glide ships a stable `6.0.4`.
## What it looks like
@@ -66,25 +68,51 @@ import "@matthewhsu1/datagrid/datagrid.css";
// 2. Point the grid at your accent, once, before the first grid renders.
configureGridTheme({ accentColor: "grass", grayColor: "slate" });
-// 3. Describe one grid.
-const orderGrid = createGridInstance(
- {
- name: "orders",
- rowKey: (row) => row.id,
- columns: { defs: COLUMN_DEFS, defaultOrder: DEFAULT_ORDER },
- cells: orderCells,
- api: { fetchRows, fetchCount, fetchRow, updateRow },
+// 3. Describe the columns. Each names its cell by type, the way glide does.
+const COLUMN_DEFS = {
+ reference: {
+ field: "reference",
+ title: "Ref",
+ defaultWidth: 160,
+ editable: false,
+ type: "dg:text",
},
- { startListening: listenerMiddleware.startListening },
-);
-
-// 4. Mount its reducer under the descriptor's name, then render.
+ total: {
+ field: "total",
+ title: "Total",
+ defaultWidth: 120,
+ editable: true,
+ type: "dg:number",
+ options: { format: "currency", currency: "USD" },
+ },
+ customer: {
+ field: "customer",
+ title: "Customer",
+ defaultWidth: 200,
+ editable: true,
+ type: "text",
+ }, // one of glide's own kinds
+};
+
+// 4. Describe one grid.
+const orderGrid = createGridInstance({
+ name: "orders",
+ rowKey: (row) => row.id,
+ columns: { defs: COLUMN_DEFS, defaultOrder: ["reference", "total", "customer"] },
+ api: { fetchRows, fetchCount, fetchRow, updateRow },
+});
+
+// 5. Mount its reducer under the descriptor's name, then render.
//
```
-The grid needs a Redux store, your listener middleware, and a
-`QueryClientProvider` above it. It creates its own `
` if your
-page has none — without one, no cell can be edited and nothing says so.
+Nothing registers a cell: the five the engine draws are prefixed `dg:`, glide's
+own kinds keep glide's own names, and the user's column layout is remembered in
+Web Storage on its own.
+
+The grid needs a Redux store and a `QueryClientProvider` above it. It creates
+its own `
` if your page has none — without one, no cell can be
+edited and nothing says so.
## Run the demo
@@ -114,7 +142,7 @@ changing anything in `src/features/dataGrid/`.
## Develop
```bash
-npm test # 615 tests
+npm test
npm run typecheck
npm run check:docs # typechecks every code block in docs/
npm run lint
diff --git a/demo/src/grid/testGrid.tsx b/demo/src/grid/testGrid.tsx
index 8f4bbc5..5a31e92 100644
--- a/demo/src/grid/testGrid.tsx
+++ b/demo/src/grid/testGrid.tsx
@@ -1,6 +1,4 @@
-import { startAppListening } from "../listener";
import { createGridInstance } from "@matthewhsu1/datagrid";
-import { localStorageColumnsAdapter } from "@matthewhsu1/datagrid";
import type { ColumnDef, GridDescriptor, GridSliceState } from "@matthewhsu1/datagrid";
import { REGIONS, type TestRow } from "./api/types";
import {
@@ -9,42 +7,104 @@ import {
fetchTestRows,
updateTestRow,
} from "./api/testRowQueries";
-import { regionCell, testGridCells } from "./testGridCells";
import { RegionHeader } from "./RegionHeader";
export const GRID_NAME = "testGrid";
+/**
+ * Every column names its cell by `type` and carries that cell's settings on
+ * `options`. Nothing is registered and nothing is built: `dg:` names the cells
+ * the engine draws, and glide's own names are available beside them.
+ *
+ * Colors come from RADIX_BADGE_SCALES in lib/grid/radixBadgePalette.ts. That
+ * list is the whole `RadixColor` union — "blue", "purple", and "gray" are NOT
+ * in it, and each scale must also be imported in theme/radixStyles.ts.
+ */
const COLUMN_DEFS: Record
= {
- id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "int" },
- name: { field: "name", title: "Name", defaultWidth: 220, editable: true, type: "text" },
- sector: { field: "sector", title: "Sector", defaultWidth: 130, editable: false, type: "text" },
+ id: {
+ field: "id",
+ title: "ID",
+ defaultWidth: 80,
+ editable: false,
+ type: "dg:number",
+ options: { format: "integer", thousandSeparator: true, min: 0 },
+ },
+ name: { field: "name", title: "Name", defaultWidth: 220, editable: true, type: "dg:text" },
+ sector: { field: "sector", title: "Sector", defaultWidth: 130, editable: false, type: "dg:text" },
// Read-only because it is the GROUP field. An edit that changed a row's group
// would have to move the row, and only a server-side move bumps the data
// generation — the edit path patches a row in place. `active` is the editable
// enum this page exercises instead.
- region: { field: "region", title: "Region", defaultWidth: 120, editable: false, type: "region" },
+ region: {
+ field: "region",
+ title: "Region",
+ defaultWidth: 120,
+ editable: false,
+ type: "dg:enum",
+ options: {
+ choices: [
+ { value: REGIONS[0].value, label: REGIONS[0].label, color: "cyan" },
+ { value: REGIONS[1].value, label: REGIONS[1].label, color: "amber" },
+ { value: REGIONS[2].value, label: REGIONS[2].label, color: "plum" },
+ ],
+ },
+ },
quantity: {
field: "quantity",
title: "Quantity",
defaultWidth: 110,
editable: true,
- type: "int",
+ type: "dg:number",
+ options: { format: "integer", thousandSeparator: true, min: 0 },
+ },
+ price: {
+ field: "price",
+ title: "Price",
+ defaultWidth: 120,
+ editable: true,
+ type: "dg:number",
+ options: { format: "currency", currency: "USD", decimalScale: 2, min: 0 },
+ },
+ value: {
+ field: "value",
+ title: "Value",
+ defaultWidth: 140,
+ editable: false,
+ type: "dg:number",
+ options: { format: "currency", currency: "USD", decimalScale: 2, min: 0 },
+ },
+ contact: {
+ field: "contact",
+ title: "Contact",
+ defaultWidth: 160,
+ editable: true,
+ type: "dg:phone",
+ options: { nullable: true, defaultCountry: "US" },
},
- price: { field: "price", title: "Price", defaultWidth: 120, editable: true, type: "currency" },
- value: { field: "value", title: "Value", defaultWidth: 140, editable: false, type: "currency" },
- contact: { field: "contact", title: "Contact", defaultWidth: 160, editable: true, type: "phone" },
updatedAt: {
field: "updatedAt",
title: "Updated",
defaultWidth: 120,
editable: true,
- type: "date",
+ type: "dg:date",
+ options: { nullable: true },
+ },
+ active: {
+ field: "active",
+ title: "Active",
+ defaultWidth: 110,
+ editable: true,
+ type: "dg:enum",
+ options: {
+ nullable: true,
+ choices: [
+ { value: 0, label: "Inactive", color: "tomato" },
+ { value: 1, label: "Active", color: "green" },
+ ],
+ },
},
- active: { field: "active", title: "Active", defaultWidth: 110, editable: true, type: "active" },
};
-const columnsAdapter = localStorageColumnsAdapter(`${GRID_NAME}:columns`);
-
/**
* The development test grid: 100,000 synthetic rows, grouped by region, with
* every editable cell type the app has.
@@ -71,6 +131,8 @@ export const testGridDescriptor: GridDescriptor = {
"updatedAt",
"active",
],
+ // `persist` and `adapter` are both left out: the layout is remembered in
+ // Web Storage under `datagrid:testGrid:columns` with no wiring at all.
},
grouping: {
field: "region",
@@ -80,9 +142,10 @@ export const testGridDescriptor: GridDescriptor = {
// `GridGrouping.order`.
order: (region) => region,
label: (region) => REGIONS[region]?.label ?? String(region),
- // The header reads its colour from the very cell that draws the column, so
- // a group's header text and that group's badges cannot drift apart.
- color: (region) => regionCell.colorOf(region),
+
+ // No `color` here. The grid groups by a `dg:enum` column, so the engine
+ // takes the header's colour from that column's own choices and a group's
+ // header text cannot drift from its badges.
// The whole reason group headers left the canvas. Each region gets a
// DIFFERENT set of components, which is what the hook is for.
@@ -94,17 +157,10 @@ export const testGridDescriptor: GridDescriptor = {
fetchCount: fetchTestRowCount,
fetchRow: fetchTestRow,
updateRow: updateTestRow,
- loadColumns: columnsAdapter.loadColumns,
- saveColumns: columnsAdapter.saveColumns,
},
- cells: testGridCells,
};
-export const testGrid = createGridInstance(testGridDescriptor, {
- // The engine ships no listener middleware of its own — column persistence
- // runs on the host app's. See `src/listener.ts`.
- startListening: startAppListening,
-});
+export const testGrid = createGridInstance(testGridDescriptor);
/** The shape this grid's slice adds to the demo store, mounted by `store.ts`. */
export type TestGridState = GridSliceState;
diff --git a/demo/src/grid/testGridCells.ts b/demo/src/grid/testGridCells.ts
deleted file mode 100644
index 4402ee3..0000000
--- a/demo/src/grid/testGridCells.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import type { CustomRenderer } from "@glideapps/glide-data-grid";
-import {
- createCellRegistry,
- type CellContext,
- type CellRegistry,
- type CellTypeDef,
-} from "@matthewhsu1/datagrid";
-import { createDateCell } from "@matthewhsu1/datagrid";
-import { createEnumCell } from "@matthewhsu1/datagrid";
-import { createNumberCell } from "@matthewhsu1/datagrid";
-import { createPhoneCell } from "@matthewhsu1/datagrid";
-import { createTextCell } from "@matthewhsu1/datagrid";
-import { REGIONS } from "./api/types";
-
-// Shared cell primitives, built once at module scope. Renderer and editor
-// identity is tied to the create* call, so building these inside a React render
-// would remount the editor on every keystroke.
-const textCell = createTextCell({ kind: "test-text" });
-
-const intCell = createNumberCell({
- kind: "test-int",
- format: "integer",
- thousandSeparator: true,
- min: 0,
-});
-
-const currencyCell = createNumberCell({
- kind: "test-currency",
- format: "currency",
- currency: "USD",
- decimalScale: 2,
- min: 0,
-});
-
-const phoneCell = createPhoneCell({ kind: "test-phone", nullable: true, defaultCountry: "US" });
-
-const dateCell = createDateCell({ kind: "test-date", nullable: true });
-
-// Colors come from RADIX_BADGE_SCALES in lib/grid/radixBadgePalette.ts. That
-// list is the whole `RadixColor` union — "blue", "purple", and "gray" are NOT
-// in it, and each scale must also be imported in theme/radixStyles.ts.
-export const regionCell = createEnumCell({
- kind: "test-region",
- nullable: false,
- options: [
- { value: REGIONS[0].value, label: REGIONS[0].label, color: "cyan" },
- { value: REGIONS[1].value, label: REGIONS[1].label, color: "amber" },
- { value: REGIONS[2].value, label: REGIONS[2].label, color: "plum" },
- ],
-});
-
-const activeCell = createEnumCell({
- kind: "test-active",
- nullable: true,
- options: [
- { value: 0, label: "Inactive", color: "tomato" },
- { value: 1, label: "Active", color: "green" },
- ],
-});
-
-const asNum = (raw: unknown): number | null => (raw == null ? null : Number(raw));
-const asStr = (raw: unknown): string | null => (raw == null ? null : String(raw));
-
-/**
- * Left unannotated (not `CellTypeDef[]`) so each `renderer` keeps its precise
- * type instead of widening to the registry's unparameterized `CustomRenderer`.
- * See the same pattern, and the reason for the cast below, in
- * `features/bonds/bondCells.ts`.
- */
-const testCellDefs = [
- {
- type: "text",
- kind: "test-text",
- renderer: textCell.renderer,
- make: (raw: unknown, ctx: CellContext) => textCell.makeCell(asStr(raw), ctx.editable),
- },
- {
- type: "int",
- kind: "test-int",
- renderer: intCell.renderer,
- make: (raw: unknown, ctx: CellContext) => intCell.makeCell(asNum(raw), ctx.editable),
- },
- {
- type: "currency",
- kind: "test-currency",
- renderer: currencyCell.renderer,
- make: (raw: unknown, ctx: CellContext) => currencyCell.makeCell(asNum(raw), ctx.editable),
- },
- {
- type: "region",
- kind: "test-region",
- renderer: regionCell.renderer,
- make: (raw: unknown, ctx: CellContext) => regionCell.makeCell(asNum(raw), ctx.editable),
- },
- {
- type: "phone",
- kind: "test-phone",
- renderer: phoneCell.renderer,
- make: (raw: unknown, ctx: CellContext) => phoneCell.makeCell(asStr(raw), ctx.editable),
- },
- {
- type: "date",
- kind: "test-date",
- renderer: dateCell.renderer,
- make: (raw: unknown, ctx: CellContext) =>
- dateCell.makeCell(asStr(raw), ctx.withTime, ctx.editable),
- },
- {
- type: "active",
- kind: "test-active",
- renderer: activeCell.renderer,
- make: (raw: unknown, ctx: CellContext) =>
- activeCell.makeCell(raw == null ? null : raw ? 1 : 0, ctx.editable),
- },
-];
-
-/**
- * Cell registry for the development test grid. Column `type` keys: "text",
- * "int", "currency", "region", "phone", "date", "active".
- *
- * Unlike `bondCells`, every `make` passes `ctx.editable` through as it comes, so
- * a column marked editable in the column def opens its editor.
- */
-// The `renderer` cast is narrowed to that one field for the reason spelled out
-// at the foot of `features/bonds/bondCells.ts`: `strictFunctionTypes` makes the
-// per-cell renderer types and the registry's unparameterized `CustomRenderer`
-// incomparable in either direction, and routing the whole def through `unknown`
-// would drop the structural check on every other field. Runtime routing is by
-// `data.kind` through each renderer's `isMatch`, which is unaffected.
-export const testGridCells: CellRegistry = createCellRegistry(
- testCellDefs.map((d): CellTypeDef => ({
- ...d,
- renderer: d.renderer as unknown as CustomRenderer,
- })),
-);
diff --git a/demo/src/listener.ts b/demo/src/listener.ts
deleted file mode 100644
index c001c86..0000000
--- a/demo/src/listener.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { createListenerMiddleware } from "@reduxjs/toolkit";
-
-/**
- * The demo app's one listener middleware.
- *
- * `@matthewhsu1/datagrid` ships none of its own: a listener already
- * discriminates by action type, so a middleware per library would buy nothing
- * and force every host to `.concat` another entry. A grid is handed this one
- * through `createGridInstance(descriptor, { startListening })`.
- */
-export const appListener = createListenerMiddleware();
-
-export const startAppListening = appListener.startListening;
diff --git a/demo/src/store.ts b/demo/src/store.ts
index e95a68c..a57f75d 100644
--- a/demo/src/store.ts
+++ b/demo/src/store.ts
@@ -1,6 +1,5 @@
import { configureStore } from "@reduxjs/toolkit";
import { appearanceReducer } from "@matthewhsu1/datagrid";
-import { appListener } from "./listener";
import { GRID_NAME, testGrid } from "./grid/testGrid";
/**
@@ -12,6 +11,9 @@ import { GRID_NAME, testGrid } from "./grid/testGrid";
*
* serializableCheck is off because grid state is not serializable: row windows
* hold arbitrary row objects and group keys are caller-defined.
+ *
+ * No listener middleware. The engine needs none: column persistence runs from
+ * the mounted grid.
*/
export const store = configureStore({
reducer: {
@@ -20,8 +22,7 @@ export const store = configureStore({
// reducer map to a string index, which unions every slice's state together.
[GRID_NAME]: testGrid.reducer,
},
- middleware: (getDefault) =>
- getDefault({ serializableCheck: false }).concat(appListener.middleware),
+ middleware: (getDefault) => getDefault({ serializableCheck: false }),
});
export type RootState = ReturnType;
diff --git a/docs/examples/configuration.md b/docs/examples/configuration.md
index ffd4345..744f0e9 100644
--- a/docs/examples/configuration.md
+++ b/docs/examples/configuration.md
@@ -118,31 +118,50 @@ neither gets a permanently light grid rather than a crash.
## 5. Cells
-A **cell kind** names how one column draws and edits. Build each maker **once at
-module scope**, never inside a render: the editor component's identity is tied
-to the call, so rebuilding it per render remounts the editor on every keystroke.
-
-Every `kind` must be unique across the grid. The registry routes drawing and
-validation by `cell.data.kind`, so two makers sharing a kind behave as one.
-
-### Text
-
-```ts
-import { createTextCell } from "@matthewhsu1/datagrid";
-
-export const referenceCell = createTextCell({
- kind: "reference", // REQUIRED. Unique across the grid.
- validation: {
+A column names its cell the way glide does: a string in `ColumnDef.type`. There
+is nothing to build, nothing to register, and no factory to call at module
+scope. Each cell's settings ride on that column's own `options`.
+
+The five this package draws are prefixed `dg:`. Glide's own kinds are reachable
+under glide's own names, unprefixed. The prefix is what keeps the two sets apart
+for good — glide may add kinds to its set without ever colliding with ours.
+
+| `type` | Drawn by | Edits | Options |
+| -------------- | -------- | ----- | ------- |
+| `dg:text` | engine | yes | `TextCellOptions` |
+| `dg:number` | engine | yes | `NumberCellOptions` |
+| `dg:date` | engine | yes | `DateCellOptions` |
+| `dg:enum` | engine | yes | `EnumCellOptions` (required) |
+| `dg:phone` | engine | yes | `PhoneCellOptions` |
+| `text` | glide | yes | none |
+| `number` | glide | yes | none |
+| `boolean` | glide | yes | none |
+| `uri` | glide | yes | none |
+| `markdown` | glide | yes | none |
+| `image` | glide | no | none |
+| `bubble` | glide | no | none |
+| `drilldown` | glide | no | none |
+
+`image`, `bubble`, and `drilldown` read a list. Give them a `string[]`, or a
+single string that the engine wraps for you — a row field like `avatarUrl` needs
+no mapping. All three are read-only whatever the column's `editable` says.
+
+### `dg:text`
+
+```ts no-check
+{
+ type: "dg:text",
+ options: {
required: true, // default false. Empty fails. This cell's "nullable".
minLength: 3, // default undefined. Shortest accepted string.
maxLength: 32, // default undefined. Longest accepted string.
email: false, // default false. Pragmatic check, not full RFC 5322.
pattern: { value: /^ORD-/, message: "Must start with ORD-" },
// ^ default undefined. A bare RegExp works too; then the message is generic.
- validate: (value) => (value.endsWith("!") ? "No exclamation marks" : null),
+ validate: (value: string) => (value.endsWith("!") ? "No exclamation marks" : null),
// ^ default undefined. Escape hatch. Return a message, or null when valid.
},
-});
+}
```
Rules run in one fixed order and the editor shows the **first** failure:
@@ -150,84 +169,87 @@ Rules run in one fixed order and the editor shows the **first** failure:
empty string is valid unless `required`, and when empty-and-optional the
remaining rules are skipped entirely.
-### Number
-
-```ts
-import { createNumberCell } from "@matthewhsu1/datagrid";
-
-export const totalCell = createNumberCell({
- kind: "total", // REQUIRED. Unique across the grid.
- nullable: false, // default false. true lets the user clear the cell.
- format: "currency", // default undefined. "integer" | "decimal" | "currency".
- decimalScale: 2, // default undefined. Digits after the point.
- currency: "USD", // default undefined. ISO code; used by format "currency".
- min: 0, // default undefined. Editor blocks entry below this.
- max: 1_000_000, // default undefined. Editor blocks entry above this.
- thousandSeparator: true, // default undefined. Group digits by locale.
- prefix: undefined, // default undefined. Text drawn before the number.
- suffix: undefined, // default undefined. Text drawn after the number.
-});
+### `dg:number`
+
+```ts no-check
+{
+ type: "dg:number",
+ options: {
+ nullable: false, // default false. true lets the user clear the cell.
+ format: "currency", // default undefined. "integer" | "decimal" | "currency".
+ decimalScale: 2, // default undefined. Digits after the point.
+ currency: "USD", // default undefined. ISO code; used by format "currency".
+ min: 0, // default undefined. Editor blocks entry below this.
+ max: 1_000_000, // default undefined. Editor blocks entry above this.
+ thousandSeparator: true, // default undefined. Group digits by locale.
+ prefix: undefined, // default undefined. Text drawn before the number.
+ suffix: undefined, // default undefined. Text drawn after the number.
+ },
+}
```
`min` and `max` guard **entry**, not the stored data. A row that already holds
an out-of-range value still draws it; the range only stops the user typing a new
one.
-### Date
-
-```ts
-import { createDateCell } from "@matthewhsu1/datagrid";
+### `dg:date`
-export const placedAtCell = createDateCell({
- kind: "placedAt", // REQUIRED. Unique across the grid.
- nullable: true, // default false. true lets the user confirm an empty date.
-});
+```ts no-check
+{
+ type: "dg:date",
+ options: {
+ nullable: true, // default false. true lets the user confirm an empty date.
+ withTime: false, // default false. true keeps the time part.
+ },
+}
```
-Values are stored as canonical UTC ISO strings. Whether a column keeps the time
-part is **not** set here — it is `withTime` on the column (section 6), because
-one date cell can serve both a date column and a datetime column.
+Values are stored as canonical UTC ISO strings. `withTime` is per column, which
+is why it lives here rather than on the column itself.
The editor holds the edit: a click on a day changes nothing until the user
presses OK. Cancel and Escape discard.
-### Enum
-
-```ts
-import { createEnumCell } from "@matthewhsu1/datagrid";
-
-export const statusCell = createEnumCell({
- kind: "status", // REQUIRED. Unique across the grid.
- nullable: false, // default false. true adds a "clear" item to the Select.
- options: [
- // REQUIRED. The full set of selectable values.
- { value: 0, label: "Draft" }, // color defaults by value.
- { value: 1, label: "Paid", color: "green" }, // color: any RADIX_BADGE_SCALES entry.
- { value: 2, label: "Void", color: "red" },
- ],
-});
+### `dg:enum`
+
+```ts no-check
+{
+ type: "dg:enum",
+ options: {
+ nullable: false, // default false. true adds a "clear" item to the Select.
+ choices: [
+ // REQUIRED. The full set of selectable values.
+ { value: 0, label: "Draft" }, // color defaults by value.
+ { value: 1, label: "Paid", color: "green" }, // color: any RADIX_BADGE_SCALES entry.
+ { value: 2, label: "Void", color: "red" },
+ ],
+ },
+}
```
`value` must be a **number**. An unlisted value draws as an empty cell.
An omitted `color` falls back to `BADGE_SEQUENCE[value % 10]`, so colours stay
-stable as you add options at the end. A `color` outside `RADIX_BADGE_SCALES`
+stable as you add choices at the end. A `color` outside `RADIX_BADGE_SCALES`
resolves to nothing and warns; adding a new scale means adding its token CSS to
`src/theme/radixStyles.ts` **and** the scale to `RADIX_BADGE_SCALES`.
The usable scales are: `iris`, `tomato`, `amber`, `grass`, `cyan`, `plum`,
`orange`, `jade`, `crimson`, `indigo`, `red`, `green`.
-### Phone
+Grouping by a `dg:enum` column colours the group headers from these choices on
+its own — see section 8.
-```ts
-import { createPhoneCell } from "@matthewhsu1/datagrid";
+### `dg:phone`
-export const phoneCell = createPhoneCell({
- kind: "phone", // REQUIRED. Unique across the grid.
- nullable: true, // default false. true lets the user clear the cell.
- defaultCountry: "US", // default "US". ISO 3166-1 alpha-2, for un-prefixed input.
-});
+```ts no-check
+{
+ type: "dg:phone",
+ options: {
+ nullable: true, // default false. true lets the user clear the cell.
+ defaultCountry: "US", // default "US". ISO 3166-1 alpha-2, for un-prefixed input.
+ },
+}
```
Values are stored canonically as E.164 (`+14155552671`). Any non-empty value
@@ -235,16 +257,31 @@ must parse as a valid number; `nullable` decides only whether empty is allowed.
### A cell kind the package does not ship
+Build the renderer with `createCustomCell`, then hand the engine a `CellTypeDef`
+on `descriptor.cells` (section 7).
+
```tsx
-import { createCustomCell, drawEmptyDash, makeCustomCell } from "@matthewhsu1/datagrid";
+import {
+ createCustomCell,
+ drawEmptyDash,
+ makeCustomCell,
+ type CellContext,
+ type CellTypeDef,
+} from "@matthewhsu1/datagrid";
+
+/** This cell's own settings, read off the column's `options`. */
+interface RatingOptions {
+ max: number;
+}
interface RatingData {
kind: "rating"; // REQUIRED. Literal type; routes glide's isMatch.
value: number | null;
readOnly?: boolean;
+ options: RatingOptions;
}
-export const ratingRenderer = createCustomCell({
+const ratingRenderer = createCustomCell({
kind: "rating", // REQUIRED. Must equal cell.data.kind at runtime.
draw: (args, data) => {
// REQUIRED. Canvas only — no DOM reaches a cell.
@@ -257,6 +294,7 @@ export const ratingRenderer = createCustomCell({
// REQUIRED. Real DOM — use Radix here.
onChange({ ...value, value: Number(e.target.value) })}
/>
@@ -268,84 +306,70 @@ export const ratingRenderer = createCustomCell({
},
});
-export const makeRatingCell = (value: number | null, allowOverlay = true) =>
- makeCustomCell({ kind: "rating", value }, String(value ?? ""), allowOverlay);
-// ^ data ^ copyData (what Ctrl+C yields) ^ default true
+export const ratingCellDef: CellTypeDef = {
+ type: "rating", // REQUIRED. What a column's `type` names.
+ kind: "rating", // default undefined. Needed only to route `validate`.
+ renderer: ratingRenderer as unknown as CellTypeDef["renderer"],
+ // ^ default undefined. Custom cells need it. See the note below on the cast.
+ make: (raw: unknown, ctx: CellContext) =>
+ makeCustomCell(
+ {
+ kind: "rating",
+ value: raw == null ? null : Number(raw),
+ options: ctx.options as RatingOptions,
+ ...(ctx.editable ? {} : { readOnly: true }),
+ },
+ String(raw ?? ""), // copyData — what Ctrl+C yields
+ ctx.editable,
+ ),
+ validate: (cell) => (cell.data as unknown as RatingData).value !== 0,
+ // ^ default undefined. Gates commit and paste. Omit and the value commits as typed.
+};
```
Cells are painted on a ``, so `draw` gets no DOM components. The
**editor** is real DOM, and it should be built from `@radix-ui/themes` like the
built-in ones.
-### The registry
+**The renderer cast is required, and it is safe.** `CellTypeDef.renderer` is
+unparameterized, while `createCustomCell` types its renderer to that cell's own
+payload. Under `strictFunctionTypes` the two are incomparable in **either**
+direction, so no annotation removes the cast. Runtime routing is by
+`cell.data.kind` through the renderer's own `isMatch`, which the cast does not
+touch.
-One place maps a column's `type` to its maker. Build it once.
+`type` is what a column names; `kind` is what a rendered cell carries. They are
+allowed to differ. A def whose `type` matches a built-in **replaces** that
+built-in for that grid, which is how you change a cell the engine draws without
+forking the package.
+
+A column pointing at it must say `custom: true`:
```ts
-import { createCellRegistry, type CellContext, type CellTypeDef } from "@matthewhsu1/datagrid";
-import type { CustomRenderer } from "@glideapps/glide-data-grid";
-import { placedAtCell, referenceCell, statusCell, totalCell } from "./shared";
-
-export const orderCells = createCellRegistry(
- [
- {
- type: "text", // REQUIRED. Matches ColumnDef.type.
- kind: "reference", // default undefined. Needed only to route `validate`.
- make: (raw: unknown, ctx: CellContext) =>
- referenceCell.makeCell(raw == null ? null : String(raw), ctx.editable),
- // ^ REQUIRED. ctx is { editable, withTime } for the column being drawn.
- renderer: referenceCell.renderer, // default undefined. Custom cells need it.
- validate: referenceCell.validate, // default undefined. Gates commit and paste.
- },
- {
- type: "currency",
- kind: "total",
- make: (raw: unknown, ctx: CellContext) =>
- totalCell.makeCell(raw == null ? null : Number(raw), ctx.editable),
- renderer: totalCell.renderer,
- validate: totalCell.validate,
- },
- {
- type: "date",
- kind: "placedAt",
- // `withTime` comes from the COLUMN, which is why make receives ctx.
- make: (raw: unknown, ctx: CellContext) =>
- placedAtCell.makeCell(raw == null ? null : String(raw), ctx.withTime, ctx.editable),
- renderer: placedAtCell.renderer,
- validate: placedAtCell.validate,
- },
- {
- type: "status",
- make: (raw: unknown, ctx: CellContext) =>
- statusCell.makeCell(raw == null ? null : Number(raw), ctx.editable),
- renderer: statusCell.renderer, // enum cells expose no `validate`.
- },
- ].map(
- // The two casts below are load-bearing. See the note under this block.
- (d): CellTypeDef => ({
- ...d,
- renderer: d.renderer as unknown as CustomRenderer,
- validate: d.validate as unknown as CellTypeDef["validate"],
- }),
- ),
-);
+import type { ColumnDef } from "@matthewhsu1/datagrid";
+
+export const ratingColumn: ColumnDef = {
+ field: "rating",
+ title: "Rating",
+ defaultWidth: 120,
+ editable: true,
+ type: "rating", // REQUIRED. Matches your CellTypeDef.type.
+ custom: true, // REQUIRED for a type this package does not ship.
+ options: { max: 5 }, // whatever your cell reads; `unknown` to the engine.
+};
```
-`type` is what a column names; `kind` is what a rendered cell carries. They are
-allowed to differ, and often should: two columns can share the `date` type while
-each holds its own cell kind.
-
-**The two casts are required, and they are safe.** `CellTypeDef.renderer` and
-`CellTypeDef.validate` are unparameterized, while a maker's `renderer` and
-`validate` are typed to that cell's own payload. Under `strictFunctionTypes` the
-two are incomparable in **either** direction, so no annotation removes the cast.
-Casting those two fields only — rather than the whole def through `unknown` —
-keeps the structural check on `type`, `kind`, and `make`. Runtime routing is by
-`cell.data.kind` through each renderer's `isMatch`, which the cast does not
-touch.
+**That one word is what makes every other column check.** Without it, `type`
+would have to accept any string, and TypeScript would then accept a mistyped
+`type`, a mistyped key inside a built-in's `options`, and a `dg:enum` with no
+choices — all silently. With `custom` as the marker, each of those is an error:
-Omit `validate` and nothing is gated: the value commits as typed. `kind` is only
-read to route `validate`, so an entry with neither needs neither.
+```ts no-check
+{ type: "dg:numbr" } // ✗ not a cell type, and not custom
+{ type: "dg:number", options: { currncy } } // ✗ `currncy` is not an option
+{ type: "dg:enum" } // ✗ dg:enum must say what it holds
+{ type: "dg:text", options: { withTime } } // ✗ withTime belongs to dg:date
+```
---
@@ -362,23 +386,46 @@ export const COLUMN_DEFS: Record = {
title: "Reference", // REQUIRED. Header text.
defaultWidth: 160, // REQUIRED. Pixels, before the user resizes.
editable: false, // REQUIRED. Whether a cell here accepts an edit.
- type: "text", // REQUIRED. Must match a cell registry entry.
+ type: "dg:text", // REQUIRED. Names the cell. See section 5.
sortable: true, // default true. false hides this column's sort menu.
+ options: { required: true }, // this cell type's own settings.
+ },
+ total: {
+ field: "total",
+ title: "Total",
+ defaultWidth: 120,
+ editable: true,
+ type: "dg:number",
+ options: { format: "currency", currency: "USD" },
},
- total: { field: "total", title: "Total", defaultWidth: 120, editable: true, type: "currency" },
placedAt: {
field: "placedAt",
title: "Placed",
defaultWidth: 180,
editable: true,
- type: "date",
- withTime: true, // default false. Date cells only: keep the time part.
+ type: "dg:date",
+ options: { nullable: true, withTime: true },
+ },
+ customer: {
+ field: "customer",
+ title: "Customer",
+ defaultWidth: 200,
+ editable: true,
+ type: "text", // one of glide's own kinds, drawn by glide
},
};
-export const DEFAULT_ORDER = ["reference", "total", "placedAt"];
+export const DEFAULT_ORDER = ["reference", "total", "placedAt", "customer"];
```
+The five fields above `type` are read by the **grid** — the header, the layout,
+the sort menu, the edit path. `options` is read only by the cell the `type`
+names, which is why a setting like `withTime` lives there.
+
+`type` and `options` are checked **together**: pick `dg:enum` and TypeScript
+insists on its `choices`; pick `dg:date` and only date options are accepted. A
+type this package does not ship needs `custom: true` beside it — see section 5.
+
Set `sortable: false` for any column the **server** cannot order by — one your
client derives, or one with no index behind it. The sort is sent to the server,
so an arrow on such a column is an arrow onto an error.
@@ -391,35 +438,55 @@ The static description of one grid. Written by the host, never by the engine.
```ts
import type { GridDescriptor } from "@matthewhsu1/datagrid";
-import { localStorageColumnsAdapter } from "@matthewhsu1/datagrid";
-import { COLUMN_DEFS, DEFAULT_ORDER, orderCells } from "./shared";
+import { COLUMN_DEFS, DEFAULT_ORDER } from "./shared";
import { fetchCount, fetchRow, fetchRows, subscribe, updateRow, type OrderRow } from "./shared";
-const columns = localStorageColumnsAdapter("orders:columns");
-// ^ (storageKey, storage = localStorage). Swap in a REST adapter of the same shape.
-
export const orderDescriptor: GridDescriptor = {
name: "orders", // REQUIRED. Store namespace + storage-key prefix. Unique, stable.
rowKey: (row) => row.id, // REQUIRED. Row identity. NEVER derive it from array position.
columns: {
defs: COLUMN_DEFS, // REQUIRED. All columns, keyed by field.
defaultOrder: DEFAULT_ORDER, // REQUIRED. Left-to-right order before the user reorders.
+ persist: true, // default true. false forgets the user's layout on reload.
+ adapter: undefined, // default undefined. Your own storage; see below.
},
- cells: orderCells, // REQUIRED. Looked up by ColumnDef.type.
+ cells: undefined, // default undefined. Cell types BEYOND the built-in ones.
pageSize: 100, // default 100. Rows per loaded page.
api: {
- fetchRows, // REQUIRED. One ordered slice. See the note below.
- fetchCount, // REQUIRED. Total under the current view.
- fetchRow, // REQUIRED. One row by id, for an id-only push.
- updateRow, // REQUIRED. Persist one cell edit. { ok: false } rolls back.
- subscribe, // default undefined. Live feed; returns an unsubscribe.
- loadColumns: columns.loadColumns, // default undefined. Omit to always start from defaults.
- saveColumns: columns.saveColumns, // default undefined. Needs startListening — section 9.
+ fetchRows, // REQUIRED. One ordered slice. See the note below.
+ fetchCount, // REQUIRED. Total under the current view.
+ fetchRow, // REQUIRED. One row by id, for an id-only push.
+ updateRow, // REQUIRED. Persist one cell edit. { ok: false } rolls back.
+ subscribe, // default undefined. Live feed; returns an unsubscribe.
},
// grouping: omitted here. See section 8.
};
```
+**Column layout is remembered by default.** With `persist` and `adapter` both
+left out, the engine writes the user's order, widths, and hidden columns to Web
+Storage under `datagrid:${name}:columns`. Nothing to wire, and no middleware.
+
+Keep them somewhere else with an adapter of your own:
+
+```ts
+import type { ColumnsAdapter, ColumnsState } from "@matthewhsu1/datagrid";
+
+export const serverColumns: ColumnsAdapter = {
+ async loadColumns(): Promise {
+ const res = await fetch("/api/grid-layout/orders");
+ return res.ok ? ((await res.json()) as ColumnsState) : null;
+ },
+ async saveColumns(state: ColumnsState): Promise {
+ await fetch("/api/grid-layout/orders", { method: "PUT", body: JSON.stringify(state) });
+ },
+};
+```
+
+Both calls are `async` whatever is behind them, so the engine has one thing to
+await. Saves are debounced 500 ms after the user stops, so a column drag is one
+write and not one per pixel.
+
`fetchRows` asks for a **slice of an order**, never for a numbered page. The
`sort` it receives is a `SortSpec` (`{ field, direction, nulls }`) or `null`.
@@ -450,6 +517,7 @@ export const byRegion: GridGrouping = {
order: (g) => REGIONS.indexOf(g), // REQUIRED. MUST agree with `field` ascending.
label: (g) => `${g} orders`, // REQUIRED. Header text for a group.
color: () => "iris", // default undefined. Header colour, a Radix scale.
+ // ^ OMIT IT when grouping by a dg:enum column — see below.
header: (g) => {g} , // default undefined. Your nodes, right of the name.
headerHeight: 40, // default 40. Pixels. Nothing measures what you drew.
};
@@ -475,6 +543,11 @@ window, and the client cannot repair it.
before React draws into it. A taller header is clipped; a shorter one leaves a
gap. Set it to fit the tallest thing `header` can return.
+**Group by a `dg:enum` column and the colours are automatic.** The engine holds
+that column's `choices`, so it colours each header's name to match the badges
+under it and `color` can be left out entirely. Set `color` to override that, or
+to colour groups on a column that is not an enum.
+
`header` is called during render, once per visible group. Keep it pure and
cheap; it is not the place to start a fetch. The same node is used twice: in the
header above the group, and in the banner naming the group you are scrolled
@@ -488,21 +561,14 @@ The runtime built from one descriptor. Create it **once, at module scope**.
```ts
import { createGridInstance } from "@matthewhsu1/datagrid";
-import { createListenerMiddleware } from "@reduxjs/toolkit";
import { orderDescriptor } from "./shared";
-export const listenerMiddleware = createListenerMiddleware();
-
-export const orderGrid = createGridInstance(orderDescriptor, {
- startListening: listenerMiddleware.startListening,
- // ^ default undefined. REQUIRED whenever descriptor.api.saveColumns is set.
-});
+export const orderGrid = createGridInstance(orderDescriptor);
```
-Column persistence runs as a listener effect. The engine ships no middleware of
-its own, so it registers on yours. Set `saveColumns` without passing
-`startListening` and `createGridInstance` **throws** at wiring time, rather than
-silently never saving.
+The descriptor is the only argument. Column persistence runs from the mounted
+grid, not from middleware, so there is no listener to hand over and nothing to
+`.concat` into your store.
**One mounted grid per instance.** The instance holds single slots for the row
store, the pending store of a hold, and the collapse carry point. Two grids
@@ -513,23 +579,23 @@ the same rows needs a second `createGridInstance`.
## 10. The store
-Mount the instance's reducer under the descriptor's `name`, and concat your
-listener middleware.
+Mount the instance's reducer under the descriptor's `name`.
```ts
import { appearanceReducer } from "@matthewhsu1/datagrid";
import { configureStore } from "@reduxjs/toolkit";
-import { listenerMiddleware, orderGrid } from "./shared";
+import { orderGrid } from "./shared";
export const store = configureStore({
reducer: {
appearance: appearanceReducer,
orders: orderGrid.reducer, // the key MUST equal descriptor.name
},
- middleware: (getDefault) => getDefault().prepend(listenerMiddleware.middleware),
});
```
+No middleware of ours goes here. The engine ships none and needs none.
+
The reducer holds only client-owned state: group collapse, sort, column order
and widths, hidden columns, row selection, and the last rejected edit. **No
server row is ever in Redux** — those live in the engine's own row store.
@@ -568,7 +634,7 @@ export function AppRoot({ children }: { children: React.ReactNode }) {
## 12. Rendering
-`` takes exactly one prop.
+`` needs one prop.
```tsx
import { DataGrid } from "@matthewhsu1/datagrid";
@@ -583,6 +649,52 @@ Everything else — sort menus, the column picker, group headers, the edit banne
— is drawn by the grid from the descriptor. Give it a sized parent; it fills the
space it is given.
+### Drawing the column header yourself
+
+Three optional props open the header to glide's own API. What you pass beats
+what the engine draws, which beats glide's default.
+
+```tsx
+import { DataGrid, type GridDrawHeader } from "@matthewhsu1/datagrid";
+import type { SpriteMap } from "@glideapps/glide-data-grid";
+import { orderGrid } from "./shared";
+
+const drawHeader: GridDrawHeader = (args, drawDefault) => {
+ // Everything glide passes is here, plus the engine's own sort state.
+ const sort = args.sort;
+
+ if (sort === null || sort.field !== args.column.id) return drawDefault();
+
+ const { ctx, rect, theme } = args;
+ ctx.fillStyle = theme.accentColor;
+ ctx.fillText(`${args.column.title} ${sort.dir === "asc" ? "▲" : "▼"}`, rect.x + 8, rect.y + 20);
+};
+
+const headerIcons: SpriteMap = {
+ flag: (props) => ` `,
+};
+
+export function OrdersPage() {
+ return (
+
+ );
+}
+```
+
+`drawHeader` is **total**: the sort chevron and the menu arrow go with it.
+`args.sort` and `args.sortable` are there so you can draw them back, and
+`drawDefault()` paints the engine's header if you only want to add to it. The
+callback is grid-wide, exactly as glide's is — branch on `args.column.id`.
+
+`headerIcons` is **merged**, because it is a dictionary. Naming one sprite
+replaces that entry and leaves the rest, including the `sortAsc` and `sortDesc`
+chevrons the sort indicator refers to by name.
+
---
## 13. Also exported
diff --git a/docs/examples/shared.ts b/docs/examples/shared.ts
index 325cbca..066c0ec 100644
--- a/docs/examples/shared.ts
+++ b/docs/examples/shared.ts
@@ -9,12 +9,7 @@
*
* Nothing here ships. `package.json`'s `files` field publishes `dist` only.
*/
-import type {
- ColumnsState,
- FetchRowsParams,
- RowChange,
- UpdateRowParams,
-} from "@matthewhsu1/datagrid";
+import type { FetchRowsParams, RowChange, UpdateRowParams } from "@matthewhsu1/datagrid";
/** The row the documentation's example grid draws. */
export interface OrderRow {
@@ -55,139 +50,85 @@ export function subscribe(_handler: (change: RowChange) => void): () =>
return () => {};
}
-export async function loadColumns(): Promise {
- return null;
-}
-
-export async function saveColumns(_state: ColumnsState): Promise {}
-
// ─── What the later blocks import ────────────────────────────────────────────
//
-// A block in section 7 needs the columns and cells that sections 5 and 6 built;
-// a block in section 10 needs the instance from section 9. Rather than make
-// every block redeclare its predecessors, each earlier section's result is
-// assembled once here. The blocks below are the SAME code as the doc's, so a
-// change to either side that breaks the other fails `npm run check:docs`.
+// A block in section 7 needs the columns that section 5 built; a block in
+// section 10 needs the instance from section 9. Rather than make every block
+// redeclare its predecessors, each earlier section's result is assembled once
+// here. The blocks below are the SAME code as the doc's, so a change to either
+// side that breaks the other fails `npm run check:docs`.
import { QueryClient } from "@tanstack/react-query";
-import { configureStore, createListenerMiddleware } from "@reduxjs/toolkit";
-import type { CustomRenderer } from "@glideapps/glide-data-grid";
+import { configureStore } from "@reduxjs/toolkit";
import {
appearanceReducer,
- createCellRegistry,
- createDateCell,
- createEnumCell,
createGridInstance,
- createNumberCell,
- createPhoneCell,
- createTextCell,
- localStorageColumnsAdapter,
- type CellContext,
- type CellTypeDef,
type ColumnDef,
type GridDescriptor,
} from "@matthewhsu1/datagrid";
-export const referenceCell = createTextCell({ kind: "reference", validation: { required: true } });
-export const totalCell = createNumberCell({ kind: "total", format: "currency", currency: "USD" });
-export const placedAtCell = createDateCell({ kind: "placedAt", nullable: true });
-export const statusCell = createEnumCell({
- kind: "status",
- options: [
- { value: 0, label: "Draft" },
- { value: 1, label: "Paid", color: "green" },
- { value: 2, label: "Void", color: "red" },
- ],
-});
-export const phoneCell = createPhoneCell({ kind: "phone", nullable: true });
-
-export const orderCells = createCellRegistry(
- [
- {
- type: "text",
- kind: "reference",
- make: (raw: unknown, ctx: CellContext) =>
- referenceCell.makeCell(raw == null ? null : String(raw), ctx.editable),
- renderer: referenceCell.renderer,
- validate: referenceCell.validate,
- },
- {
- type: "currency",
- kind: "total",
- make: (raw: unknown, ctx: CellContext) =>
- totalCell.makeCell(raw == null ? null : Number(raw), ctx.editable),
- renderer: totalCell.renderer,
- validate: totalCell.validate,
- },
- {
- type: "date",
- kind: "placedAt",
- make: (raw: unknown, ctx: CellContext) =>
- placedAtCell.makeCell(raw == null ? null : String(raw), ctx.withTime, ctx.editable),
- renderer: placedAtCell.renderer,
- validate: placedAtCell.validate,
- },
- {
- type: "status",
- make: (raw: unknown, ctx: CellContext) =>
- statusCell.makeCell(raw == null ? null : Number(raw), ctx.editable),
- renderer: statusCell.renderer,
- },
- ].map((d): CellTypeDef => ({
- ...d,
- renderer: d.renderer as unknown as CustomRenderer,
- validate: d.validate as unknown as CellTypeDef["validate"],
- })),
-);
-
export const COLUMN_DEFS: Record = {
reference: {
field: "reference",
title: "Reference",
defaultWidth: 160,
editable: false,
- type: "text",
+ type: "dg:text",
+ options: { required: true },
+ },
+ total: {
+ field: "total",
+ title: "Total",
+ defaultWidth: 120,
+ editable: true,
+ type: "dg:number",
+ options: { format: "currency", currency: "USD" },
},
- total: { field: "total", title: "Total", defaultWidth: 120, editable: true, type: "currency" },
placedAt: {
field: "placedAt",
title: "Placed",
defaultWidth: 180,
editable: true,
- type: "date",
- withTime: true,
+ type: "dg:date",
+ options: { nullable: true, withTime: true },
+ },
+ status: {
+ field: "status",
+ title: "Status",
+ defaultWidth: 120,
+ editable: true,
+ type: "dg:enum",
+ options: {
+ choices: [
+ { value: 0, label: "Draft" },
+ { value: 1, label: "Paid", color: "green" },
+ { value: 2, label: "Void", color: "red" },
+ ],
+ },
+ },
+ phone: {
+ field: "phone",
+ title: "Phone",
+ defaultWidth: 160,
+ editable: true,
+ type: "dg:phone",
+ options: { nullable: true },
},
};
-export const DEFAULT_ORDER = ["reference", "total", "placedAt"];
-
-const columnsAdapter = localStorageColumnsAdapter("orders:columns");
+export const DEFAULT_ORDER = ["reference", "total", "placedAt", "status", "phone"];
export const orderDescriptor: GridDescriptor = {
name: "orders",
rowKey: (row) => row.id,
columns: { defs: COLUMN_DEFS, defaultOrder: DEFAULT_ORDER },
- cells: orderCells,
- api: {
- fetchRows,
- fetchCount,
- fetchRow,
- updateRow,
- subscribe,
- loadColumns: columnsAdapter.loadColumns,
- saveColumns: columnsAdapter.saveColumns,
- },
+ api: { fetchRows, fetchCount, fetchRow, updateRow, subscribe },
};
-export const listenerMiddleware = createListenerMiddleware();
-
-export const orderGrid = createGridInstance(orderDescriptor, {
- startListening: listenerMiddleware.startListening,
-});
+export const orderGrid = createGridInstance(orderDescriptor);
export const store = configureStore({
reducer: { appearance: appearanceReducer, orders: orderGrid.reducer },
- middleware: (getDefault) => getDefault().prepend(listenerMiddleware.middleware),
});
export const queryClient = new QueryClient();
diff --git a/package.json b/package.json
index 055f66a..d3d1064 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@matthewhsu1/datagrid",
- "version": "0.2.0",
+ "version": "0.3.0",
"description": "A virtualised, server-paged data grid engine for React — windowed paging, a page cache, optimistic edits, cross-client row sync, grouping, and typed cell editors, over glide-data-grid.",
"license": "MIT",
"author": "MatthewHsu1 ",
@@ -58,12 +58,12 @@
},
"peerDependencies": {
"@glideapps/glide-data-grid": "6.0.4-alpha24",
- "@radix-ui/themes": "^3.3.0",
- "@reduxjs/toolkit": "^2.12.0",
- "@tanstack/react-query": "^5.101.2",
- "react": "^19.2.0",
- "react-dom": "^19.2.0",
- "react-redux": "^9.3.0"
+ "@radix-ui/themes": "^3.0.0",
+ "@reduxjs/toolkit": "^2.0.0",
+ "@tanstack/react-query": "^5.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-redux": "^9.0.0"
},
"dependencies": {
"@tanstack/react-pacer": "^0.22.1",
diff --git a/src/components/ui/datePicker.test.tsx b/src/components/ui/datePicker.test.tsx
index e210adc..0a538c4 100644
--- a/src/components/ui/datePicker.test.tsx
+++ b/src/components/ui/datePicker.test.tsx
@@ -253,9 +253,7 @@ describe("DatePicker: range mode", () => {
describe("DatePicker: range-datetime mode", () => {
it("shows a time input for each end", () => {
- mount(
- ,
- );
+ mount( );
expect(screen.getByLabelText("Start time")).toBeInTheDocument();
expect(screen.getByLabelText("End time")).toBeInTheDocument();
diff --git a/src/components/ui/datePicker.tsx b/src/components/ui/datePicker.tsx
index ec957f3..d6f00f0 100644
--- a/src/components/ui/datePicker.tsx
+++ b/src/components/ui/datePicker.tsx
@@ -1,11 +1,7 @@
import { Button, Flex, Text } from "@radix-ui/themes";
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react";
import { useId, useState, type KeyboardEvent } from "react";
-import {
- DayPicker,
- type ChevronProps,
- type DateRange as CalendarRange,
-} from "react-day-picker";
+import { DayPicker, type ChevronProps, type DateRange as CalendarRange } from "react-day-picker";
import { PatternFormat } from "react-number-format";
import { dateToMask, maskToDate } from "../../lib/date/dateMask";
import { composeIso, isoToCalendarDate, isoToTimeInput } from "../../lib/date/dateUtils";
@@ -186,10 +182,7 @@ export function DatePicker(props: DatePickerProps) {
const components = { Chevron };
return (
-
+
screen.getByRole("button", { name: /date|1\/1\/2000|6\/1\/
describe("DatePickerPopover", () => {
it("labels the trigger with the placeholder when there is no value", () => {
mount(
- ,
+ ,
);
expect(screen.getByRole("button", { name: "Pick a date" })).toBeInTheDocument();
diff --git a/src/components/ui/dayPickerTheme.ts b/src/components/ui/dayPickerTheme.ts
index d920304..5771985 100644
--- a/src/components/ui/dayPickerTheme.ts
+++ b/src/components/ui/dayPickerTheme.ts
@@ -74,8 +74,7 @@ const BASE: Partial = {
dropdowns: "dg:flex dg:items-center dg:gap-1",
dropdown_root:
- "dg:relative dg:rounded-md dg:px-1.5 dg:py-0.5 " +
- "dg:hover:bg-[var(--gray-3,#f0f0f3)]",
+ "dg:relative dg:rounded-md dg:px-1.5 dg:py-0.5 " + "dg:hover:bg-[var(--gray-3,#f0f0f3)]",
dropdown: "dg:absolute dg:inset-0 dg:cursor-pointer dg:opacity-0",
month_grid: "dg:border-collapse",
@@ -88,8 +87,7 @@ const BASE: Partial = {
day_button: DAY_BUTTON,
// Property-disjoint from selection: a ring, never a fill or a colour.
- today:
- "dg:[&>button]:ring-1 dg:[&>button]:ring-inset dg:[&>button]:ring-[var(--gray-8,#c7cbd1)]",
+ today: "dg:[&>button]:ring-1 dg:[&>button]:ring-inset dg:[&>button]:ring-[var(--gray-8,#c7cbd1)]",
outside: "dg:[&>button]:text-[var(--gray-8,#c7cbd1)]",
disabled: "dg:opacity-30",
hidden: "dg:invisible",
diff --git a/src/datagrid.src.css b/src/datagrid.src.css
index fe4dc29..7ed7683 100644
--- a/src/datagrid.src.css
+++ b/src/datagrid.src.css
@@ -47,4 +47,3 @@
* else (the demo's Vite root, for instance) does not.
*/
@source "./**/*.ts";
-
diff --git a/src/features/dataGrid/ColumnHeaderMenu.test.tsx b/src/features/dataGrid/ColumnHeaderMenu.test.tsx
index 00fb7dc..fd09130 100644
--- a/src/features/dataGrid/ColumnHeaderMenu.test.tsx
+++ b/src/features/dataGrid/ColumnHeaderMenu.test.tsx
@@ -13,10 +13,10 @@ function renderMenu(props: Partial[0]> = {})
return render(
{}}
- onClose={() => {}}
+ target={TARGET}
+ sort={null}
+ onSort={() => {}}
+ onClose={() => {}}
{...props}
/>
,
diff --git a/src/features/dataGrid/ColumnPicker.test.tsx b/src/features/dataGrid/ColumnPicker.test.tsx
index 1c99194..1b86e65 100644
--- a/src/features/dataGrid/ColumnPicker.test.tsx
+++ b/src/features/dataGrid/ColumnPicker.test.tsx
@@ -16,9 +16,15 @@ interface Row {
}
const DEFS: Record = {
- id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "int" },
- name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "text" },
- region: { field: "region", title: "Region", defaultWidth: 120, editable: false, type: "region" },
+ id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "dg:number" },
+ name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "dg:text" },
+ region: {
+ field: "region",
+ title: "Region",
+ defaultWidth: 120,
+ editable: false,
+ type: "dg:number",
+ },
};
function harness({ grouped }: { grouped: boolean }) {
@@ -40,7 +46,6 @@ function harness({ grouped }: { grouped: boolean }) {
}
: undefined,
api: server.api,
- cells: { customRenderers: [], validateCell: () => true, make: () => undefined },
} as unknown as GridDescriptor;
const instance = createGridInstance(descriptor);
diff --git a/src/features/dataGrid/DataGrid.tsx b/src/features/dataGrid/DataGrid.tsx
index feadd0a..eefdfbc 100644
--- a/src/features/dataGrid/DataGrid.tsx
+++ b/src/features/dataGrid/DataGrid.tsx
@@ -1,7 +1,12 @@
// src/features/dataGrid/DataGrid.tsx
-import { DataEditor, type DataEditorRef, type Rectangle } from "@glideapps/glide-data-grid";
+import {
+ DataEditor,
+ type DataEditorRef,
+ type Rectangle,
+ type SpriteMap,
+} from "@glideapps/glide-data-grid";
import "@glideapps/glide-data-grid/dist/index.css";
-import { useCallback, useLayoutEffect, useRef } from "react";
+import { useCallback, useLayoutEffect, useMemo, useRef } from "react";
import { useSelector } from "react-redux";
import { useGridTheme } from "../../theme/useGridTheme";
import { specFromGridSort } from "./data/sortSpec";
@@ -11,9 +16,17 @@ import type { DisplayModel } from "./displayModel";
import { GroupHeaderLayer } from "./GroupHeaderLayer";
import { COLUMN_HEADER_HEIGHT } from "./rowHeights";
import { ColumnPicker } from "./ColumnPicker";
+import { groupColumnColor } from "./groupColumnColor";
+import {
+ mergeHeaderIcons,
+ withSortArgs,
+ type GridDrawHeader,
+ type GridDrawHeaderArgs,
+} from "./headerDoors";
+import { sortablePredicate } from "./sortable";
+import { useColumnPersistence } from "./hooks/useColumnPersistence";
import { useColumnSortMenu } from "./hooks/useColumnSortMenu";
import { GridHeaderBar } from "./GridHeaderBar";
-import { sortHeaderIcons } from "./headerIcons";
import { useCellRenderer } from "./hooks/useCellRenderer";
import { useDisplayModel } from "./hooks/useDisplayModel";
import { useGridColumns } from "./hooks/useGridColumns";
@@ -45,11 +58,41 @@ const ROW_MARKER_WIDTH = 40;
*/
const SCROLLBAR_GUTTER = 18;
+export type { GridDrawHeader, GridDrawHeaderArgs };
+
+export interface DataGridProps {
+ /** The grid to draw, from `createGridInstance`. */
+ instance: GridInstance;
+
+ /**
+ * Draws the column header instead of the engine. See `GridDrawHeader`.
+ */
+ drawHeader?: GridDrawHeader;
+
+ /**
+ * Named header sprites, MERGED over the engine's own.
+ *
+ * A dictionary, unlike `drawHeader`: naming one icon replaces that entry and
+ * leaves the rest — including the engine's `sortAsc` and `sortDesc` chevrons,
+ * which the sort indicator refers to by name.
+ */
+ headerIcons?: SpriteMap;
+
+ /**
+ * Height of the column-header row, in pixels. Defaults to glide's 36.
+ *
+ * It reaches the group-header layer as well as the canvas: every group header
+ * is placed by measuring down from this line.
+ */
+ headerHeight?: number;
+}
+
export function DataGrid({
instance,
-}: {
- instance: GridInstance;
-}) {
+ drawHeader,
+ headerIcons: hostHeaderIcons,
+ headerHeight = COLUMN_HEADER_HEIGHT,
+}: DataGridProps) {
// Before glide can paint, and therefore long before the first editor opens.
// A missing portal is silent: the grid renders, and editing simply does
// nothing. See `ensurePortal.ts`.
@@ -107,6 +150,12 @@ export function DataGrid groupColumnColor(grouping, instance.descriptor.columns.defs),
+ [grouping, instance],
+ );
const {
banner,
@@ -134,6 +196,7 @@ export function DataGrid mergeHeaderIcons(hostHeaderIcons), [hostHeaderIcons]);
+
+ const isSortable = useMemo(() => sortablePredicate(instance.descriptor.columns.defs), [instance]);
+
+ const onDrawHeader = useMemo(
+ () => withSortArgs(drawHeader, sort, isSortable),
+ [drawHeader, sort, isSortable],
+ );
+
const dismissError = useCallback(
() => dispatch(instance.actions.editErrorCleared()),
[dispatch, instance],
@@ -207,13 +279,14 @@ export function DataGrid instance.descriptor.cells.validateCell(newValue)}
+ customRenderers={instance.cells.customRenderers}
+ headerIcons={headerIcons}
+ drawHeader={onDrawHeader}
+ validateCell={(_cell, newValue) => instance.cells.validateCell(newValue)}
columns={columns}
rows={model.rowCount}
rowHeight={rowHeight}
- headerHeight={COLUMN_HEADER_HEIGHT}
+ headerHeight={headerHeight}
getCellContent={getCellContent}
getCellsForSelection={true}
onCellEdited={onCellEdited}
diff --git a/src/features/dataGrid/columnDef.types.test.ts b/src/features/dataGrid/columnDef.types.test.ts
new file mode 100644
index 0000000..8a55ef3
--- /dev/null
+++ b/src/features/dataGrid/columnDef.types.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+import type { ColumnDef } from "./types";
+
+/**
+ * What `ColumnDef` accepts and refuses, checked by the COMPILER.
+ *
+ * Every `@ts-expect-error` below IS the assertion: `tsc` fails the build if the
+ * declaration under it stops being an error, so a branch that quietly goes
+ * slack shows up here and nowhere else. `npm run typecheck` runs these; the
+ * `it` block exists so the file reports alongside the rest of the suite.
+ *
+ * The directive sits above the whole declaration rather than above the offending
+ * key, because a union mismatch is reported where the object is assigned.
+ */
+
+const base = { field: "x", title: "X", defaultWidth: 100, editable: true } as const;
+
+// ─── built-in types are accepted, with their own options ─────────────────────
+
+export const text: ColumnDef = { ...base, type: "dg:text", options: { maxLength: 5 } };
+export const number: ColumnDef = { ...base, type: "dg:number", options: { currency: "USD" } };
+export const date: ColumnDef = { ...base, type: "dg:date", options: { withTime: true } };
+export const phone: ColumnDef = { ...base, type: "dg:phone", options: { nullable: true } };
+export const enumCol: ColumnDef = {
+ ...base,
+ type: "dg:enum",
+ options: { choices: [{ value: 0, label: "Draft" }] },
+};
+
+/** Options are optional on every built-in but `dg:enum`. */
+export const bare: ColumnDef = { ...base, type: "dg:text" };
+
+/** Glide's own kinds need no options at all. */
+export const glide: ColumnDef = { ...base, type: "boolean" };
+
+/** A host's own cell type is accepted once it says it is one. */
+export const customColumn: ColumnDef = {
+ ...base,
+ type: "invoice-status",
+ custom: true,
+ options: { anything: "the host's cell knows" },
+};
+
+// ─── a mistyped key INSIDE options is an error ───────────────────────────────
+
+// @ts-expect-error `currncy` is not a NumberCellOptions key
+export const typoInOptions: ColumnDef = { ...base, type: "dg:number", options: { currncy: "USD" } };
+
+// ─── a mistyped `type` is an error ───────────────────────────────────────────
+
+// @ts-expect-error "dg:numbr" is not a cell type, and is not marked custom
+export const typoInType: ColumnDef = { ...base, type: "dg:numbr" };
+
+// ─── a required option cannot be left out ────────────────────────────────────
+
+// @ts-expect-error `dg:enum` must say what it can hold
+export const enumWithoutChoices: ColumnDef = { ...base, type: "dg:enum" };
+
+// ─── a setting belonging to another cell type is an error ────────────────────
+
+// @ts-expect-error `withTime` belongs to dg:date, not dg:text
+export const wrongCellsOption: ColumnDef = {
+ ...base,
+ type: "dg:text",
+ options: { withTime: true },
+};
+
+// ─── a key that belongs to no branch at all is an error ──────────────────────
+
+// @ts-expect-error `withTime` moved into options; it is not a column field
+export const strayKey: ColumnDef = { ...base, type: "dg:date", withTime: true };
+
+// ─── glide's kinds take no options ───────────────────────────────────────────
+
+// @ts-expect-error glide draws these; there is nothing of ours to configure
+export const glideWithOptions: ColumnDef = { ...base, type: "text", options: { maxLength: 5 } };
+
+// ─── a host type must declare itself ─────────────────────────────────────────
+
+// @ts-expect-error without `custom: true` the type must be one this package knows
+export const customWithoutMarker: ColumnDef = { ...base, type: "invoice-status" };
+
+describe("ColumnDef", () => {
+ it("is checked by tsc, not by assertions here", () => {
+ // The file compiling at all IS the test. This keeps it in the suite report.
+ expect(text.type).toBe("dg:text");
+ expect(customColumn.custom).toBe(true);
+ });
+});
diff --git a/src/features/dataGrid/data/sync/useRowSync.test.tsx b/src/features/dataGrid/data/sync/useRowSync.test.tsx
index 84cd432..c32973b 100644
--- a/src/features/dataGrid/data/sync/useRowSync.test.tsx
+++ b/src/features/dataGrid/data/sync/useRowSync.test.tsx
@@ -44,7 +44,6 @@ function harness(store: RowStore = loadedStore()) {
rowKey: (r: Row) => r.id,
columns: { defs: {}, defaultOrder: [] },
api: server.api,
- cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true },
} as unknown as GridDescriptor;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
diff --git a/src/features/dataGrid/gridComposition.test.tsx b/src/features/dataGrid/gridComposition.test.tsx
index d698fa8..7ba40df 100644
--- a/src/features/dataGrid/gridComposition.test.tsx
+++ b/src/features/dataGrid/gridComposition.test.tsx
@@ -41,12 +41,6 @@ const GROUP_SIZE = 60;
*/
const VISIBLE_FIELDS = ["name", "g"];
-function textCell(_type: string, raw: unknown): GridCell {
- const text = String(raw ?? "");
-
- return { kind: GridCellKind.Text, data: text, displayData: text, allowOverlay: true };
-}
-
function cellText(cell: GridCell): string {
if (cell.kind === GridCellKind.Text) {
return cell.displayData;
@@ -188,7 +182,6 @@ function makeHarness(total: number, { grouped = false }: HarnessOptions = {}) {
? { field: "g", of: (r: Row) => r.g, order: (g: number) => g, label: String }
: undefined,
api: { ...server.api, updateRow, fetchRows },
- cells: { makeCell: textCell, customRenderers: [], validateCell: () => true },
} as unknown as GridDescriptor;
const queryClient = new QueryClient({
diff --git a/src/features/dataGrid/gridStatusWiring.test.tsx b/src/features/dataGrid/gridStatusWiring.test.tsx
index 12892dc..0b6444a 100644
--- a/src/features/dataGrid/gridStatusWiring.test.tsx
+++ b/src/features/dataGrid/gridStatusWiring.test.tsx
@@ -58,7 +58,6 @@ function makeHarness(fetchRows: ReturnType) {
updateRow: async () => ({ ok: false }),
subscribe: () => () => {},
},
- cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true },
} as unknown as GridDescriptor;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -211,11 +210,6 @@ function makeEditHarness() {
fetchRow: async () => null,
updateRow,
},
- cells: {
- makeCell: () => ({}) as never,
- customRenderers: [],
- validateCell: () => true,
- },
} as unknown as GridDescriptor;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
diff --git a/src/features/dataGrid/groupColumnColor.test.ts b/src/features/dataGrid/groupColumnColor.test.ts
new file mode 100644
index 0000000..31f8546
--- /dev/null
+++ b/src/features/dataGrid/groupColumnColor.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { radixColorByIndex } from "../../lib/grid/radixBadgePalette";
+import { groupColumnColor } from "./groupColumnColor";
+import type { ColumnDef, GridGrouping } from "./types";
+
+interface Row {
+ region: number;
+}
+
+const enumColumn: ColumnDef = {
+ field: "region",
+ title: "Region",
+ defaultWidth: 100,
+ editable: false,
+ type: "dg:enum",
+ options: {
+ choices: [
+ { value: 0, label: "APAC", color: "cyan" },
+ { value: 1, label: "EMEA" },
+ ],
+ },
+};
+
+const textColumn: ColumnDef = {
+ field: "sector",
+ title: "Sector",
+ defaultWidth: 100,
+ editable: false,
+ type: "dg:text",
+};
+
+const grouping = (over: Partial> = {}): GridGrouping => ({
+ field: "region",
+ of: (r) => r.region,
+ order: (g) => g,
+ label: String,
+ ...over,
+});
+
+describe("groupColumnColor", () => {
+ it("colours a group from the enum column it groups by", () => {
+ const color = groupColumnColor(grouping(), { region: enumColumn });
+
+ expect(color?.(0)).toBe("cyan");
+ });
+
+ it("falls back to the palette for a choice with no colour of its own", () => {
+ const color = groupColumnColor(grouping(), { region: enumColumn });
+
+ expect(color?.(1)).toBe(radixColorByIndex(1));
+ });
+
+ it("lets the descriptor's own color win", () => {
+ const color = groupColumnColor(grouping({ color: () => "red" }), { region: enumColumn });
+
+ expect(color?.(0)).toBe("red");
+ });
+
+ it("answers nothing when the grouped column is not an enum", () => {
+ expect(groupColumnColor(grouping({ field: "sector" }), { sector: textColumn })).toBeUndefined();
+ });
+
+ it("answers nothing when the grouped field is not a column at all", () => {
+ expect(groupColumnColor(grouping({ field: "ghost" }), { region: enumColumn })).toBeUndefined();
+ });
+
+ it("answers nothing for a flat grid", () => {
+ expect(groupColumnColor(undefined, { region: enumColumn })).toBeUndefined();
+ });
+
+ it("answers nothing for a group value that cannot index the choices", () => {
+ const color = groupColumnColor(grouping() as unknown as GridGrouping, {
+ region: enumColumn,
+ });
+
+ expect(color?.("APAC")).toBeUndefined();
+ });
+});
diff --git a/src/features/dataGrid/groupColumnColor.ts b/src/features/dataGrid/groupColumnColor.ts
new file mode 100644
index 0000000..a9ad19f
--- /dev/null
+++ b/src/features/dataGrid/groupColumnColor.ts
@@ -0,0 +1,47 @@
+import { ENUM_CELL_TYPE } from "../../lib/grid/enumCell";
+import { enumColorOf, type EnumCellOptions } from "../../lib/grid/enumChoices";
+import type { RadixColor } from "../../lib/grid/radixBadgePalette";
+import type { ColumnDef, GridGrouping } from "./types";
+
+/**
+ * How a group's name is coloured, resolved from the descriptor alone.
+ *
+ * Grouping by an enum column and colouring its header the same as the badges
+ * under it is the ordinary case, and the engine already holds both halves: the
+ * grouped field, and that column's choices. Making the host wire the two
+ * together was busywork with one right answer, so the engine answers it.
+ *
+ * `grouping.color` still wins where it is set. Grouping by anything but a
+ * `dg:enum` column answers undefined, which falls the header back to the
+ * theme's header text colour — the right answer for a group with no colour of
+ * its own, not an error.
+ *
+ * A group value that is not a number cannot index an enum's choices, so it
+ * answers undefined too rather than guessing.
+ */
+export function groupColumnColor(
+ grouping: GridGrouping | undefined,
+ defs: Record,
+): ((group: TGroup) => RadixColor | undefined) | undefined {
+ if (grouping === undefined) {
+ return undefined;
+ }
+
+ if (grouping.color !== undefined) {
+ return grouping.color;
+ }
+
+ const def = defs[grouping.field];
+
+ if (def === undefined || def.type !== ENUM_CELL_TYPE) {
+ return undefined;
+ }
+
+ const choices = (def.options as EnumCellOptions | undefined)?.choices;
+
+ if (choices === undefined) {
+ return undefined;
+ }
+
+ return (group) => (typeof group === "number" ? enumColorOf(choices, group) : undefined);
+}
diff --git a/src/features/dataGrid/headerDoors.test.ts b/src/features/dataGrid/headerDoors.test.ts
new file mode 100644
index 0000000..8462d47
--- /dev/null
+++ b/src/features/dataGrid/headerDoors.test.ts
@@ -0,0 +1,102 @@
+import type { DrawHeaderCallback } from "@glideapps/glide-data-grid";
+import { describe, expect, it, vi } from "vitest";
+import { mergeHeaderIcons, withSortArgs, type GridDrawHeaderArgs } from "./headerDoors";
+import { SORT_ASC_ICON, SORT_DESC_ICON } from "./headerIcons";
+import type { GridSort } from "./types";
+
+const glideArgs = (id: string) =>
+ ({ column: { id, title: id } }) as unknown as Parameters[0];
+
+describe("mergeHeaderIcons", () => {
+ it("keeps the engine's sort chevrons when the host names none", () => {
+ const merged = mergeHeaderIcons(undefined);
+
+ expect(Object.keys(merged)).toEqual([SORT_ASC_ICON, SORT_DESC_ICON]);
+ });
+
+ it("adds the host's icon without taking the chevrons with it", () => {
+ const mine = () => " ";
+ const merged = mergeHeaderIcons({ mine });
+
+ expect(merged.mine).toBe(mine);
+ expect(merged[SORT_ASC_ICON]).toBeDefined();
+ expect(merged[SORT_DESC_ICON]).toBeDefined();
+ });
+
+ it("lets the host replace one entry by name", () => {
+ const mine = () => " ";
+ const merged = mergeHeaderIcons({ [SORT_ASC_ICON]: mine });
+
+ expect(merged[SORT_ASC_ICON]).toBe(mine);
+ expect(merged[SORT_DESC_ICON]).not.toBe(mine);
+ });
+});
+
+describe("withSortArgs", () => {
+ const sort: GridSort = { field: "price", dir: "asc" };
+ const always = () => true;
+
+ it("hands back nothing when the host draws no header, so glide keeps its own", () => {
+ expect(withSortArgs(undefined, sort, always)).toBeUndefined();
+ });
+
+ it("puts the grid's sort on the args", () => {
+ const seen: GridDrawHeaderArgs[] = [];
+ const wrapped = withSortArgs((args) => void seen.push(args), sort, always)!;
+
+ wrapped(glideArgs("price"), () => {});
+
+ expect(seen[0].sort).toEqual(sort);
+ });
+
+ it("reports the sort even on a column it is not about, so the host can compare", () => {
+ const seen: GridDrawHeaderArgs[] = [];
+ const wrapped = withSortArgs((args) => void seen.push(args), sort, always)!;
+
+ wrapped(glideArgs("name"), () => {});
+
+ expect(seen[0].sort).toEqual(sort);
+ expect(seen[0].column.id).toBe("name");
+ });
+
+ it("reports natural order as null", () => {
+ const seen: GridDrawHeaderArgs[] = [];
+ const wrapped = withSortArgs((args) => void seen.push(args), null, always)!;
+
+ wrapped(glideArgs("price"), () => {});
+
+ expect(seen[0].sort).toBeNull();
+ });
+
+ it("says per column whether a sort menu is offered", () => {
+ const seen: GridDrawHeaderArgs[] = [];
+ const wrapped = withSortArgs(
+ (args) => void seen.push(args),
+ null,
+ (f) => f === "price",
+ )!;
+
+ wrapped(glideArgs("price"), () => {});
+ wrapped(glideArgs("value"), () => {});
+
+ expect(seen.map((a) => a.sortable)).toEqual([true, false]);
+ });
+
+ it("keeps every arg glide passed, so a glide drawHeader works unchanged", () => {
+ const seen: GridDrawHeaderArgs[] = [];
+ const wrapped = withSortArgs((args) => void seen.push(args), null, always)!;
+
+ wrapped(glideArgs("price"), () => {});
+
+ expect(seen[0].column.title).toBe("price");
+ });
+
+ it("passes glide's own drawDefault straight through", () => {
+ const drawDefault = vi.fn();
+ const wrapped = withSortArgs((_args, d) => d(), null, always)!;
+
+ wrapped(glideArgs("price"), drawDefault);
+
+ expect(drawDefault).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/features/dataGrid/headerDoors.ts b/src/features/dataGrid/headerDoors.ts
new file mode 100644
index 0000000..40bc797
--- /dev/null
+++ b/src/features/dataGrid/headerDoors.ts
@@ -0,0 +1,64 @@
+import type { DrawHeaderCallback, SpriteMap } from "@glideapps/glide-data-grid";
+import { sortHeaderIcons } from "./headerIcons";
+import type { GridSort } from "./types";
+
+/**
+ * Glide's own header draw args, plus the two things only this engine knows.
+ *
+ * The shape is glide's, deliberately: a `drawHeader` copied out of glide's own
+ * docs works here unchanged. The engine's extras ride ON the args object rather
+ * than arriving as a third parameter, which would have broken that on sight.
+ */
+export type GridDrawHeaderArgs = Parameters[0] & {
+ /**
+ * The grid's current ordering, or null for natural order. Grid-wide, not
+ * per-column: compare `sort.field` with `args.column.id` to find out whether
+ * it is THIS header's.
+ */
+ sort: GridSort | null;
+
+ /** Whether this column offers a sort menu at all. */
+ sortable: boolean;
+};
+
+/**
+ * A replacement for the engine's column header.
+ *
+ * The override is TOTAL: nothing the engine draws survives it, sort chevron and
+ * menu arrow included. Call `drawDefault()` to paint the engine's header first
+ * and add to it, or ignore it and draw the whole thing.
+ */
+export type GridDrawHeader = (args: GridDrawHeaderArgs, drawDefault: () => void) => void;
+
+/**
+ * The engine's header sprites with the host's laid over them.
+ *
+ * MERGED, not replaced. `headerIcons` is a dictionary and the sort indicator
+ * refers to `sortAsc`/`sortDesc` by NAME, so a host adding one icon of their own
+ * must not take those with it. `drawHeader` is total for the opposite reason:
+ * there is only one header, and half of one is not a header.
+ */
+export function mergeHeaderIcons(host: SpriteMap | undefined): SpriteMap {
+ return { ...sortHeaderIcons, ...host };
+}
+
+/**
+ * Glide's callback, wrapped only to put the sort state the engine owns onto the
+ * args it hands over.
+ *
+ * Without it a total override could never redraw the chevron it just replaced:
+ * glide knows nothing about sorting, and the engine's indicator is drawn through
+ * the header it was handed.
+ */
+export function withSortArgs(
+ drawHeader: GridDrawHeader | undefined,
+ sort: GridSort | null,
+ isSortable: (field: string) => boolean,
+): DrawHeaderCallback | undefined {
+ if (drawHeader === undefined) {
+ return undefined;
+ }
+
+ return (args, drawDefault) =>
+ drawHeader({ ...args, sort, sortable: isSortable(args.column.id ?? "") }, drawDefault);
+}
diff --git a/src/features/dataGrid/hooks/useCellRenderer.test.tsx b/src/features/dataGrid/hooks/useCellRenderer.test.tsx
index c483aed..371f6a4 100644
--- a/src/features/dataGrid/hooks/useCellRenderer.test.tsx
+++ b/src/features/dataGrid/hooks/useCellRenderer.test.tsx
@@ -104,15 +104,18 @@ function makeHarness({
},
api: { updateRow },
...(grouping ? { grouping } : {}),
- cells: {
- makeCell: (_type: string, raw: unknown): GridCell => ({
- kind: GridCellKind.Text,
- data: String(raw),
- displayData: String(raw),
- allowOverlay: true,
- ...(cellTheme ? { themeOverride: cellTheme } : {}),
- }),
- },
+ },
+ // On the INSTANCE, not the descriptor: the registry is resolved once by
+ // `createGridInstance` (built-ins plus the descriptor's own) and the
+ // renderer reads it from there.
+ cells: {
+ makeCell: (_type: string, raw: unknown): GridCell => ({
+ kind: GridCellKind.Text,
+ data: String(raw),
+ displayData: String(raw),
+ allowOverlay: true,
+ ...(cellTheme ? { themeOverride: cellTheme } : {}),
+ }),
},
actions: edits.actions,
} as unknown as GridInstance;
diff --git a/src/features/dataGrid/hooks/useCellRenderer.ts b/src/features/dataGrid/hooks/useCellRenderer.ts
index 383c839..1296f4c 100644
--- a/src/features/dataGrid/hooks/useCellRenderer.ts
+++ b/src/features/dataGrid/hooks/useCellRenderer.ts
@@ -244,16 +244,27 @@ export function useCellRenderer)[field];
- const built = descriptor.cells.makeCell(def.type, raw, {
+ const built = instance.cells.makeCell(def.type, raw, {
editable: def.editable,
- withTime: def.withTime ?? false,
+ options: def.options,
});
const content = withHighlight(`${cell.dataIndex}:${field}`, built);
return isPending(descriptor.rowKey(row)) ? withPendingTint(content) : content;
},
- [model, visibleFields, columnCount, theme, rowAt, isPending, withHighlight, descriptor, defs],
+ [
+ model,
+ visibleFields,
+ columnCount,
+ theme,
+ rowAt,
+ isPending,
+ withHighlight,
+ instance,
+ descriptor,
+ defs,
+ ],
);
const onCellEdited = useCallback(
diff --git a/src/features/dataGrid/hooks/useColumnPersistence.test.tsx b/src/features/dataGrid/hooks/useColumnPersistence.test.tsx
new file mode 100644
index 0000000..98e89d2
--- /dev/null
+++ b/src/features/dataGrid/hooks/useColumnPersistence.test.tsx
@@ -0,0 +1,184 @@
+import { configureStore } from "@reduxjs/toolkit";
+import { renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { Provider } from "react-redux";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createGridInstance } from "../store/createGridInstance";
+import type { ColumnsAdapter, ColumnsState, GridDescriptor } from "../types";
+import { useColumnPersistence } from "./useColumnPersistence";
+
+interface Row {
+ id: number;
+}
+
+const SAVE_DEBOUNCE_MS = 500;
+
+function descriptorFor(
+ name: string,
+ columns: Partial["columns"]> = {},
+): GridDescriptor {
+ return {
+ name,
+ rowKey: (r) => r.id,
+ columns: {
+ defs: {
+ id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "dg:number" },
+ name: { field: "name", title: "Name", defaultWidth: 120, editable: true, type: "dg:text" },
+ },
+ defaultOrder: ["id", "name"],
+ ...columns,
+ },
+ api: {
+ fetchRows: async () => [],
+ fetchCount: async () => 0,
+ fetchRow: async () => null,
+ updateRow: async () => ({ ok: true }),
+ },
+ };
+}
+
+function mount(descriptor: GridDescriptor) {
+ const instance = createGridInstance(descriptor);
+ const store = configureStore({
+ reducer: { [descriptor.name]: instance.reducer },
+ middleware: (getDefault) => getDefault({ serializableCheck: false }),
+ });
+
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ const view = renderHook(() => useColumnPersistence(instance, instance.columnsAdapter), {
+ wrapper,
+ });
+
+ const columns = () =>
+ (store.getState() as Record)[descriptor.name].columns;
+
+ return { instance, store, view, columns };
+}
+
+function fakeAdapter(stored: ColumnsState | null = null) {
+ const saveColumns = vi.fn(async () => {});
+ const loadColumns = vi.fn(async () => stored);
+
+ return {
+ adapter: { loadColumns, saveColumns } satisfies ColumnsAdapter,
+ loadColumns,
+ saveColumns,
+ };
+}
+
+describe("useColumnPersistence", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("restores a saved layout on mount", async () => {
+ const { adapter } = fakeAdapter({ order: ["name", "id"], widths: { id: 200 }, hidden: ["id"] });
+ const { columns } = mount(descriptorFor("restores", { adapter }));
+
+ await waitFor(() => expect(columns().order).toEqual(["name", "id"]));
+ expect(columns().widths).toEqual({ id: 200 });
+ expect(columns().hidden).toEqual(["id"]);
+ });
+
+ it("writes the layout back when the user changes it", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ const { adapter, saveColumns } = fakeAdapter();
+ const { instance, store } = mount(descriptorFor("writesBack", { adapter }));
+
+ await vi.waitFor(() => expect(adapter.loadColumns).toHaveBeenCalled());
+
+ store.dispatch(instance.actions.resizeColumn({ field: "id", width: 321 }));
+ await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50);
+
+ expect(saveColumns).toHaveBeenCalledWith(expect.objectContaining({ widths: { id: 321 } }));
+ });
+
+ it("does not bounce the layout it just loaded straight back out", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ const stored = { order: ["name", "id"], widths: {}, hidden: [] };
+ const { adapter, saveColumns } = fakeAdapter(stored);
+
+ mount(descriptorFor("noEcho", { adapter }));
+
+ await vi.waitFor(() => expect(adapter.loadColumns).toHaveBeenCalled());
+ await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50);
+
+ expect(saveColumns).not.toHaveBeenCalled();
+ });
+
+ it("waits for the load before writing, so a save cannot overtake it", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ let release: (v: ColumnsState | null) => void = () => {};
+ const saveColumns = vi.fn(async () => {});
+ const adapter: ColumnsAdapter = {
+ loadColumns: () => new Promise((r) => (release = r)),
+ saveColumns,
+ };
+
+ const { instance, store } = mount(descriptorFor("ordered", { adapter }));
+
+ store.dispatch(instance.actions.resizeColumn({ field: "id", width: 50 }));
+ await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50);
+
+ expect(saveColumns).not.toHaveBeenCalled();
+
+ release(null);
+ });
+
+ it("persists to Web Storage with no wiring at all", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ const { instance, store } = mount(descriptorFor("byDefault"));
+
+ store.dispatch(instance.actions.resizeColumn({ field: "id", width: 210 }));
+
+ await vi.waitFor(async () => {
+ await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50);
+ expect(localStorage.getItem("datagrid:byDefault:columns")).not.toBeNull();
+ });
+
+ const raw = localStorage.getItem("datagrid:byDefault:columns");
+ expect(JSON.parse(raw!).widths).toEqual({ id: 210 });
+ });
+
+ it("writes nothing when the descriptor opts out", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ const { instance, store } = mount(descriptorFor("optedOut", { persist: false }));
+
+ store.dispatch(instance.actions.resizeColumn({ field: "id", width: 210 }));
+ await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50);
+ await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50);
+
+ expect(localStorage.getItem("datagrid:optedOut:columns")).toBeNull();
+ });
+
+ it("debounces a drag into one write, not one per pixel", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ const { adapter, saveColumns } = fakeAdapter();
+ const { instance, store } = mount(descriptorFor("debounced", { adapter }));
+
+ await vi.waitFor(() => expect(adapter.loadColumns).toHaveBeenCalled());
+
+ for (const width of [100, 120, 140, 160]) {
+ store.dispatch(instance.actions.resizeColumn({ field: "id", width }));
+ await vi.advanceTimersByTimeAsync(50);
+ }
+
+ await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50);
+
+ expect(saveColumns).toHaveBeenCalledTimes(1);
+ expect(saveColumns).toHaveBeenCalledWith(expect.objectContaining({ widths: { id: 160 } }));
+ });
+});
diff --git a/src/features/dataGrid/hooks/useColumnPersistence.ts b/src/features/dataGrid/hooks/useColumnPersistence.ts
new file mode 100644
index 0000000..638a004
--- /dev/null
+++ b/src/features/dataGrid/hooks/useColumnPersistence.ts
@@ -0,0 +1,107 @@
+// src/features/dataGrid/hooks/useColumnPersistence.ts
+import { useEffect, useRef, useState } from "react";
+import { useSelector } from "react-redux";
+import type { ColumnsAdapter, ColumnsState, GridInstance } from "../types";
+import { useGridDispatch } from "../useGridDispatch";
+
+/**
+ * How long the layout must sit still before it is written.
+ *
+ * A column drag fires a resize per frame. Without this, a custom adapter
+ * backed by a server call would send one request per pixel.
+ */
+const SAVE_DEBOUNCE_MS = 500;
+
+/**
+ * Restores the user's column layout on mount, then writes it back whenever they
+ * change it.
+ *
+ * This runs in the COMPONENT rather than on the host's listener middleware,
+ * which is what lets `createGridInstance` take a descriptor and nothing else. A
+ * grid instance is built at module scope and has no store to subscribe to;
+ * a mounted grid has one through React-Redux's context, and it is also the only
+ * thing that knows the grid is on screen at all.
+ *
+ * A null adapter means the grid persists nothing (`columns.persist: false`), and
+ * every branch below then no-ops rather than being skipped by the caller.
+ */
+export function useColumnPersistence(
+ instance: GridInstance,
+ adapter: ColumnsAdapter | null,
+): void {
+ const dispatch = useGridDispatch();
+
+ const columns = useSelector((s: unknown) => instance.selectRoot(s).columns);
+
+ /**
+ * The layout as it currently stands in storage, as far as this mount knows.
+ *
+ * Seeded from the load so the hydrate itself does not bounce straight back
+ * out as a save — which for a server-backed adapter would be a write on every
+ * single mount, of the bytes it had just read.
+ */
+ const lastWritten = useRef(null);
+
+ /**
+ * Whether the load has answered. Nothing may be written before it does: a
+ * save that overtook the load would persist the DEFAULT layout over the
+ * user's saved one, and the load would then restore what the save had just
+ * destroyed.
+ *
+ * STATE, not a ref. A user who drags a column before the load lands changes
+ * the layout once and never again, so a ref would skip that save and no later
+ * render would come back for it. As state it re-runs the save effect the
+ * moment hydration finishes, and the waiting change is written then.
+ */
+ const [hydrated, setHydrated] = useState(false);
+
+ const adapterRef = useRef(adapter);
+ adapterRef.current = adapter;
+
+ useEffect(() => {
+ const current = adapterRef.current;
+
+ if (current === null) {
+ setHydrated(true);
+ return;
+ }
+
+ let cancelled = false;
+
+ void (async () => {
+ const loaded = await current.loadColumns();
+
+ if (cancelled) return;
+
+ if (loaded) {
+ lastWritten.current = JSON.stringify(loaded);
+ dispatch(instance.actions.setColumns(loaded as unknown as ColumnsState));
+ }
+
+ setHydrated(true);
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [dispatch, instance]);
+
+ useEffect(() => {
+ if (!hydrated) return;
+
+ const current = adapterRef.current;
+
+ if (current === null) return;
+
+ const next = JSON.stringify(columns);
+
+ if (next === lastWritten.current) return;
+
+ const timer = setTimeout(() => {
+ lastWritten.current = next;
+ void current.saveColumns(columns);
+ }, SAVE_DEBOUNCE_MS);
+
+ return () => clearTimeout(timer);
+ }, [columns, hydrated]);
+}
diff --git a/src/features/dataGrid/hooks/useColumnSortMenu.test.tsx b/src/features/dataGrid/hooks/useColumnSortMenu.test.tsx
index 36ae3d7..2acab5b 100644
--- a/src/features/dataGrid/hooks/useColumnSortMenu.test.tsx
+++ b/src/features/dataGrid/hooks/useColumnSortMenu.test.tsx
@@ -37,7 +37,6 @@ function harness() {
defaultOrder: FIELDS,
},
api: { updateRow: async () => ({ ok: true }) },
- cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true },
} as unknown as GridDescriptor;
const instance = createGridInstance(descriptor);
@@ -77,8 +76,7 @@ function harness() {
const openArrow = (field: string) =>
userEvent.click(screen.getByRole("button", { name: `arrow ${field}` }));
-const pick = async (name: RegExp) =>
- userEvent.click(await screen.findByRole("menuitem", { name }));
+const pick = async (name: RegExp) => userEvent.click(await screen.findByRole("menuitem", { name }));
describe("useColumnSortMenu", () => {
it("opens the menu on the column whose arrow was pressed", async () => {
diff --git a/src/features/dataGrid/hooks/useColumnSortMenu.tsx b/src/features/dataGrid/hooks/useColumnSortMenu.tsx
index 3c81fd2..0a6a9d0 100644
--- a/src/features/dataGrid/hooks/useColumnSortMenu.tsx
+++ b/src/features/dataGrid/hooks/useColumnSortMenu.tsx
@@ -34,10 +34,7 @@ export function useColumnSortMenu<
const [target, setTarget] = useState(null);
- const sortable = useMemo(
- () => sortablePredicate(defs),
- [defs],
- );
+ const sortable = useMemo(() => sortablePredicate(defs), [defs]);
const onHeaderMenuClick = useCallback(
(colIndex: number, bounds: Rectangle) => {
@@ -69,9 +66,7 @@ export function useColumnSortMenu<
[dispatch, instance, sort],
);
- const menu = (
-
- );
+ const menu = ;
return { onHeaderMenuClick, menu };
}
diff --git a/src/features/dataGrid/hooks/useDisplayModel.test.tsx b/src/features/dataGrid/hooks/useDisplayModel.test.tsx
index a7e806d..b9ac717 100644
--- a/src/features/dataGrid/hooks/useDisplayModel.test.tsx
+++ b/src/features/dataGrid/hooks/useDisplayModel.test.tsx
@@ -50,7 +50,6 @@ function makeHarness() {
fetchRow: async () => null,
updateRow: async () => ({ ok: true }),
},
- cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true },
} as unknown as GridDescriptor;
const instance = createGridInstance(descriptor);
diff --git a/src/features/dataGrid/hooks/useGridColumns.test.tsx b/src/features/dataGrid/hooks/useGridColumns.test.tsx
index 04883a5..06a1bf1 100644
--- a/src/features/dataGrid/hooks/useGridColumns.test.tsx
+++ b/src/features/dataGrid/hooks/useGridColumns.test.tsx
@@ -38,7 +38,6 @@ function makeHarness(overrides: { unsortable?: string } = {}) {
api: {
updateRow: async () => ({ ok: true }),
},
- cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true },
} as unknown as GridDescriptor;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
diff --git a/src/features/dataGrid/hooks/useGridColumns.ts b/src/features/dataGrid/hooks/useGridColumns.ts
index b061550..daa0c3d 100644
--- a/src/features/dataGrid/hooks/useGridColumns.ts
+++ b/src/features/dataGrid/hooks/useGridColumns.ts
@@ -1,6 +1,6 @@
// src/features/dataGrid/hooks/useGridColumns.ts
import { type GridColumn, GridColumnMenuIcon } from "@glideapps/glide-data-grid";
-import { useCallback, useEffect, useMemo } from "react";
+import { useCallback, useMemo } from "react";
import { useSelector } from "react-redux";
import { SORT_ASC_ICON, SORT_DESC_ICON } from "../headerIcons";
import { sortablePredicate } from "../sortable";
@@ -23,16 +23,9 @@ export function useGridColumns instance.selectRoot(s).groups.sort);
- useEffect(() => {
- dispatch(instance.thunks.loadColumns());
- }, [dispatch, instance]);
-
const visibleFields = useMemo(() => order.filter((f) => !hidden.includes(f)), [order, hidden]);
- const sortable = useMemo(
- () => sortablePredicate(defs),
- [defs],
- );
+ const sortable = useMemo(() => sortablePredicate(defs), [defs]);
const columns: GridColumn[] = useMemo(
() =>
diff --git a/src/features/dataGrid/hooks/useGridData.test.tsx b/src/features/dataGrid/hooks/useGridData.test.tsx
index 7d14828..40a325c 100644
--- a/src/features/dataGrid/hooks/useGridData.test.tsx
+++ b/src/features/dataGrid/hooks/useGridData.test.tsx
@@ -89,7 +89,6 @@ function makeHarness(total: number, grouped = false) {
? { field: "g", of: (r: Row) => r.g, order: (g: number) => g, label: String }
: undefined,
api: { ...server.api, fetchRows, fetchCount },
- cells: { makeCell: () => ({}), customRenderers: [], validateCell: () => true },
} as unknown as GridDescriptor;
const queryClient = new QueryClient({
diff --git a/src/features/dataGrid/hooks/useGroupBanner.ts b/src/features/dataGrid/hooks/useGroupBanner.ts
index e32e88d..dad1016 100644
--- a/src/features/dataGrid/hooks/useGroupBanner.ts
+++ b/src/features/dataGrid/hooks/useGroupBanner.ts
@@ -2,6 +2,7 @@
import { useCallback, useMemo } from "react";
import { groupBanner, type GroupBanner } from "../groupBanner";
import { groupHeaderTextColor } from "../groupHeaderColor";
+import type { RadixColor } from "../../../lib/grid/radixBadgePalette";
import type { GridGrouping } from "../types";
interface Args {
@@ -16,6 +17,13 @@ interface Args {
/** Colour for a group that names no scale of its own — the theme's header text. */
fallbackColor: string | undefined;
+ /**
+ * The Radix scale a group's name is written in, already resolved from the
+ * descriptor — `grouping.color` where the host set one, otherwise the grouped
+ * enum column's own choices. See `groupColumnColor`.
+ */
+ colorOf: ((group: TGroup) => RadixColor | undefined) | undefined;
+
/** From `useDisplayModel`. THE ONLY dispatcher of a collapse. */
toggleGroup: (group: TGroup) => void;
@@ -49,12 +57,13 @@ export function useGroupBanner({
group,
collapsedGroups,
fallbackColor,
+ colorOf,
toggleGroup,
revealAfterCollapse,
}: Args): Result {
const groupColor = useCallback(
- (of: TGroup) => groupHeaderTextColor(grouping?.color?.(of), fallbackColor),
- [grouping, fallbackColor],
+ (of: TGroup) => groupHeaderTextColor(colorOf?.(of), fallbackColor),
+ [colorOf, fallbackColor],
);
const collapseFromBanner = useCallback(
diff --git a/src/features/dataGrid/hooks/useGroupHeaders.test.tsx b/src/features/dataGrid/hooks/useGroupHeaders.test.tsx
index 44f83fb..09d5d4c 100644
--- a/src/features/dataGrid/hooks/useGroupHeaders.test.tsx
+++ b/src/features/dataGrid/hooks/useGroupHeaders.test.tsx
@@ -86,7 +86,13 @@ function mount(initialProps: Props, grouping: GridGrouping | undefi
const view = renderHook(
({ m, collapsed }: Props) =>
- useGroupHeaders({ model: m, collapsedGroups: collapsed, grouping, gridRef }),
+ useGroupHeaders({
+ model: m,
+ collapsedGroups: collapsed,
+ grouping,
+ gridRef,
+ columnHeaderHeight: 36,
+ }),
{ initialProps },
);
@@ -112,6 +118,7 @@ function mountFlat(model: DisplayModel) {
collapsedGroups: [],
grouping: undefined,
gridRef,
+ columnHeaderHeight: 36,
}),
);
diff --git a/src/features/dataGrid/hooks/useGroupHeaders.ts b/src/features/dataGrid/hooks/useGroupHeaders.ts
index 8ee8139..484f320 100644
--- a/src/features/dataGrid/hooks/useGroupHeaders.ts
+++ b/src/features/dataGrid/hooks/useGroupHeaders.ts
@@ -10,12 +10,7 @@ import {
} from "../displayModel";
import { headerPlacements } from "../headerPlacements";
import type { GroupHeaderLayerHandle, HeaderPlacement } from "../GroupHeaderLayer";
-import {
- COLUMN_HEADER_HEIGHT,
- DEFAULT_GROUP_HEADER_HEIGHT,
- DEFAULT_ROW_HEIGHT,
- rowHeightFor,
-} from "../rowHeights";
+import { DEFAULT_GROUP_HEADER_HEIGHT, DEFAULT_ROW_HEIGHT, rowHeightFor } from "../rowHeights";
import type { GridGrouping } from "../types";
interface Args {
@@ -37,6 +32,16 @@ interface Args {
/** The grid itself, for putting a folded group's header back under the eye. */
gridRef: React.RefObject;
+
+ /**
+ * The height of the grid's own column-header row.
+ *
+ * Every hole is measured DOWN from this line, so it has to be the same number
+ * `` was given. A host raising the header through
+ * `` therefore has to move this too, which is why it
+ * arrives as an argument rather than being read from the constant.
+ */
+ columnHeaderHeight: number;
}
interface Result {
@@ -92,6 +97,7 @@ export function useGroupHeaders({
collapsedGroups,
grouping,
gridRef,
+ columnHeaderHeight,
}: Args): Result {
const layerRef = useRef(null);
@@ -170,13 +176,13 @@ export function useGroupHeaders({
placements.current = headerPlacements(next, {
firstRow: rect.y,
translateY,
- headerHeight: COLUMN_HEADER_HEIGHT,
+ headerHeight: columnHeaderHeight,
heightOf: rowHeight,
});
layerRef.current?.place(placements.current);
},
- [rowHeight],
+ [rowHeight, columnHeaderHeight],
);
const trackRegion = useCallback(
diff --git a/src/features/dataGrid/hooks/useRowPages.test.tsx b/src/features/dataGrid/hooks/useRowPages.test.tsx
index 9e17db1..c275eb3 100644
--- a/src/features/dataGrid/hooks/useRowPages.test.tsx
+++ b/src/features/dataGrid/hooks/useRowPages.test.tsx
@@ -44,7 +44,6 @@ function makeHarness(total: number) {
pageSize: PAGE,
columns: { defs: {}, defaultOrder: [] },
api: { ...server.api, fetchRows },
- cells: { makeCell: () => ({}), customRenderers: [], validateCell: () => true },
} as unknown as GridDescriptor;
const queryClient = new QueryClient({
diff --git a/src/features/dataGrid/pickerColumns.test.ts b/src/features/dataGrid/pickerColumns.test.ts
index 8627b90..0987062 100644
--- a/src/features/dataGrid/pickerColumns.test.ts
+++ b/src/features/dataGrid/pickerColumns.test.ts
@@ -3,9 +3,15 @@ import { pickerColumns } from "./pickerColumns";
import type { ColumnDef } from "./types";
const DEFS: Record = {
- id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "int" },
- name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "text" },
- region: { field: "region", title: "Region", defaultWidth: 120, editable: false, type: "region" },
+ id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "dg:number" },
+ name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "dg:text" },
+ region: {
+ field: "region",
+ title: "Region",
+ defaultWidth: 120,
+ editable: false,
+ type: "dg:number",
+ },
};
const ORDER = ["id", "name", "region"];
diff --git a/src/features/dataGrid/sortable.test.ts b/src/features/dataGrid/sortable.test.ts
index 26d3ccb..0ffc293 100644
--- a/src/features/dataGrid/sortable.test.ts
+++ b/src/features/dataGrid/sortable.test.ts
@@ -3,14 +3,14 @@ import { sortablePredicate } from "./sortable";
import type { ColumnDef } from "./types";
const DEFS: Record = {
- id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "int" },
- name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "text" },
+ id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "dg:number" },
+ name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "dg:text" },
value: {
field: "value",
title: "Value",
defaultWidth: 140,
editable: false,
- type: "currency",
+ type: "dg:number",
sortable: false,
},
};
diff --git a/src/features/dataGrid/sortable.ts b/src/features/dataGrid/sortable.ts
index 99e698c..8a2a4b2 100644
--- a/src/features/dataGrid/sortable.ts
+++ b/src/features/dataGrid/sortable.ts
@@ -13,8 +13,6 @@ import type { ColumnDef } from "./types";
* column that the server genuinely cannot order by says so itself, on its own
* `ColumnDef`.
*/
-export function sortablePredicate(
- defs: Record,
-): (field: string) => boolean {
+export function sortablePredicate(defs: Record): (field: string) => boolean {
return (field: string) => defs[field] !== undefined && defs[field].sortable !== false;
}
diff --git a/src/features/dataGrid/store/localStorageColumnsAdapter.test.ts b/src/features/dataGrid/store/columnsAdapter.test.ts
similarity index 95%
rename from src/features/dataGrid/store/localStorageColumnsAdapter.test.ts
rename to src/features/dataGrid/store/columnsAdapter.test.ts
index 0081d7a..64ba437 100644
--- a/src/features/dataGrid/store/localStorageColumnsAdapter.test.ts
+++ b/src/features/dataGrid/store/columnsAdapter.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ColumnsState } from "../types";
-import { localStorageColumnsAdapter } from "./localStorageColumnsAdapter";
+import { localStorageColumnsAdapter } from "./columnsAdapter";
// Fake storage backed by a Map
function createFakeStorage(): Pick {
diff --git a/src/features/dataGrid/store/columnsAdapter.ts b/src/features/dataGrid/store/columnsAdapter.ts
new file mode 100644
index 0000000..7ed53c2
--- /dev/null
+++ b/src/features/dataGrid/store/columnsAdapter.ts
@@ -0,0 +1,78 @@
+import type { ColumnsAdapter, ColumnsState } from "../types";
+
+/**
+ * Where a grid's layout is kept when the descriptor names nowhere else.
+ *
+ * `descriptor.name` is already documented as stable and unique — it is the
+ * grid's Redux mount point — so it is the one string that can key the storage
+ * without the host having to invent a second one.
+ */
+export function defaultColumnsStorageKey(name: string): string {
+ return `datagrid:${name}:columns`;
+}
+
+/**
+ * Builds a `ColumnsAdapter` backed by Web Storage. This is the home of
+ * localStorage knowledge — the slice, the persistence hook, and the engine
+ * never touch it directly.
+ *
+ * It is the engine's default rather than something a host wires up: a column
+ * layout the user rearranged and then lost on reload is a bug in every app, so
+ * remembering it is the behaviour a grid should have before anyone asks.
+ * `descriptor.columns.adapter` replaces it; `persist: false` turns it off.
+ *
+ * Both calls are async to match `ColumnsAdapter`, though Web Storage is not:
+ * one shape means the save path has one thing to await.
+ */
+export function localStorageColumnsAdapter(
+ storageKey: string,
+ storage: Pick | undefined = globalThis.localStorage,
+): ColumnsAdapter {
+ return {
+ async loadColumns() {
+ try {
+ const raw = storage?.getItem(storageKey);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as Partial;
+ // Only hydrate fields that are present & well-formed; missing → caller keeps defaults.
+ if (!parsed || typeof parsed !== "object") return null;
+ return {
+ order: Array.isArray(parsed.order) && parsed.order.length ? parsed.order : [],
+ widths: parsed.widths ?? {},
+ hidden: parsed.hidden ?? [],
+ };
+ } catch {
+ return null;
+ }
+ },
+ async saveColumns(state) {
+ try {
+ storage?.setItem(storageKey, JSON.stringify(state));
+ } catch {
+ /* ignore quota/unavailable */
+ }
+ },
+ };
+}
+
+/**
+ * The adapter one grid actually runs on, or null when it persists nothing.
+ *
+ * Reading the descriptor in ONE place is what keeps "on by default" honest:
+ * every caller — the load on mount and the save after a change — asks here, so
+ * neither can end up persisting to somewhere the other does not.
+ *
+ * A `storage` of undefined (server-side rendering, a browser with site data
+ * blocked) yields an adapter that quietly loads nothing and saves nothing,
+ * rather than throwing on a grid that only wanted to draw.
+ */
+export function resolveColumnsAdapter(
+ name: string,
+ columns: { persist?: boolean; adapter?: ColumnsAdapter },
+): ColumnsAdapter | null {
+ if (columns.persist === false) {
+ return null;
+ }
+
+ return columns.adapter ?? localStorageColumnsAdapter(defaultColumnsStorageKey(name));
+}
diff --git a/src/features/dataGrid/store/columnsSlice.test.ts b/src/features/dataGrid/store/columnsSlice.test.ts
index 1bfb6cb..3818d98 100644
--- a/src/features/dataGrid/store/columnsSlice.test.ts
+++ b/src/features/dataGrid/store/columnsSlice.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { createColumnsSlice } from "./columnsSlice";
-import type { ColumnsState } from "../types";
const make = () => createColumnsSlice("test", { defaultOrder: ["a", "b", "c"] });
@@ -52,41 +51,6 @@ describe("createColumnsSlice", () => {
});
});
- describe("loadColumns thunk", () => {
- const drain = async (
- thunk: ReturnType["loadColumns"]>,
- ) => {
- const dispatched: unknown[] = [];
- await thunk((a: unknown) => dispatched.push(a));
- return dispatched;
- };
-
- it("dispatches setColumns with the loaded layout", async () => {
- const loaded: ColumnsState = { order: ["c", "a", "b"], widths: { a: 12 }, hidden: ["b"] };
- const { loadColumns } = createColumnsSlice("test", {
- defaultOrder: ["a", "b", "c"],
- load: async () => loaded,
- });
- const dispatched = await drain(loadColumns());
- expect(dispatched).toEqual([
- expect.objectContaining({ type: "test/columns/setColumns", payload: loaded }),
- ]);
- });
-
- it("no-ops when no load hook is provided", async () => {
- const { loadColumns } = make();
- expect(await drain(loadColumns())).toEqual([]);
- });
-
- it("no-ops when load resolves to null", async () => {
- const { loadColumns } = createColumnsSlice("test", {
- defaultOrder: ["a"],
- load: async () => null,
- });
- expect(await drain(loadColumns())).toEqual([]);
- });
- });
-
it("resetColumns puts every column back the way the descriptor described it", () => {
const { reducer, actions } = make();
let s = reducer(undefined, { type: "@@init" });
diff --git a/src/features/dataGrid/store/columnsSlice.ts b/src/features/dataGrid/store/columnsSlice.ts
index 7e2f218..6369a76 100644
--- a/src/features/dataGrid/store/columnsSlice.ts
+++ b/src/features/dataGrid/store/columnsSlice.ts
@@ -1,11 +1,8 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { ColumnsState } from "../types";
-export function createColumnsSlice(
- name: string,
- opts: { defaultOrder: string[]; load?: () => Promise },
-) {
- const { defaultOrder, load } = opts;
+export function createColumnsSlice(name: string, opts: { defaultOrder: string[] }) {
+ const { defaultOrder } = opts;
const initialState: ColumnsState = { order: [...defaultOrder], widths: {}, hidden: [] };
const slice = createSlice({
@@ -51,16 +48,5 @@ export function createColumnsSlice(
},
});
- // loadColumns thunk — hydrate from descriptor.api.loadColumns (if any) on mount.
- // Single-slice: only dispatches this slice's setColumns, so it lives with the slice.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const loadColumns = () => async (dispatch: any) => {
- if (!load) return;
-
- const loaded = await load();
-
- if (loaded) dispatch(slice.actions.setColumns(loaded));
- };
-
- return { reducer: slice.reducer, actions: slice.actions, loadColumns };
+ return { reducer: slice.reducer, actions: slice.actions };
}
diff --git a/src/features/dataGrid/store/createGridInstance.test.ts b/src/features/dataGrid/store/createGridInstance.test.ts
index 9c59421..ed76e71 100644
--- a/src/features/dataGrid/store/createGridInstance.test.ts
+++ b/src/features/dataGrid/store/createGridInstance.test.ts
@@ -1,54 +1,91 @@
-// src/features/dataGrid/store/createGridInstance.test.ts
import { describe, expect, it } from "vitest";
import { createGridInstance } from "./createGridInstance";
-import type { GridDescriptor } from "../types";
+import { defaultColumnsStorageKey } from "./columnsAdapter";
+import type { ColumnsAdapter, GridDescriptor } from "../types";
interface Row {
id: number;
}
-const descriptor = {
- name: "demo",
- rowKey: (r: Row) => r.id,
- columns: {
- defs: { id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "number" } },
- defaultOrder: ["id"],
- },
- api: {
- fetchRows: async () => [],
- fetchCount: async () => 0,
- fetchRow: async () => null,
- updateRow: async () => ({ ok: true }),
- },
- cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true },
-} as unknown as GridDescriptor;
-
-const makeInstance = () => createGridInstance(descriptor);
+
+function makeDescriptor(
+ name: string,
+ columns: Partial["columns"]> = {},
+): GridDescriptor {
+ return {
+ name,
+ rowKey: (r) => r.id,
+ columns: {
+ defs: {
+ id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "dg:number" },
+ },
+ defaultOrder: ["id"],
+ ...columns,
+ },
+ api: {
+ fetchRows: async () => [],
+ fetchCount: async () => 0,
+ fetchRow: async () => null,
+ updateRow: async () => ({ ok: true }),
+ },
+ };
+}
describe("createGridInstance", () => {
- it("reduces only client-owned state — rows live in the row store", () => {
- const inst = makeInstance();
+ it("takes the descriptor and nothing else", () => {
+ const inst = createGridInstance(makeDescriptor("plain"));
+
+ expect(typeof inst.reducer).toBe("function");
+ expect(typeof inst.selectRoot).toBe("function");
+ });
+
+ it("mounts its state under the descriptor name", () => {
+ const inst = createGridInstance(makeDescriptor("mounted"));
const state = inst.reducer(undefined, { type: "@@init" });
- expect(Object.keys(state).sort()).toEqual(["columns", "edits", "groups", "selection"]);
+
+ expect(inst.selectRoot({ mounted: state })).toBe(state);
+ expect(state.columns.order).toEqual(["id"]);
});
- it("selectRoot reads the namespaced slice from a full store state", () => {
- const inst = makeInstance();
- const sub = inst.reducer(undefined, { type: "@@init" });
- expect(inst.selectRoot({ demo: sub }).columns.order).toEqual(["id"]);
+ it("persists columns by default, with no wiring at all", () => {
+ const inst = createGridInstance(makeDescriptor("byDefault"));
+
+ expect(inst.columnsAdapter).not.toBeNull();
});
- it("exposes an effects teardown and a loadColumns thunk", () => {
- const inst = makeInstance();
- expect(typeof inst.stopEffects).toBe("function");
- expect(typeof inst.thunks.loadColumns).toBe("function");
- inst.stopEffects();
+ it("persists nothing when the descriptor says so", () => {
+ const inst = createGridInstance(makeDescriptor("optedOut", { persist: false }));
+
+ expect(inst.columnsAdapter).toBeNull();
});
- it("starts with an empty store cell, so a push before any grid mounts is dropped", () => {
- // The instance is created at module scope, long before a grid mounts, and
- // `useGridData` is what fills this cell. `data/sync/useRowSync.ts` relies on
- // the null: a push that arrives first has nothing on screen to correct.
- const inst = makeInstance();
- expect(inst.storeRef.current).toBeNull();
+ it("uses the host's adapter over its own", () => {
+ const adapter: ColumnsAdapter = {
+ loadColumns: async () => null,
+ saveColumns: async () => {},
+ };
+ const inst = createGridInstance(makeDescriptor("hostOwned", { adapter }));
+
+ expect(inst.columnsAdapter).toBe(adapter);
+ });
+
+ it("ignores an adapter on a grid that persists nothing", () => {
+ const adapter: ColumnsAdapter = {
+ loadColumns: async () => null,
+ saveColumns: async () => {},
+ };
+ const inst = createGridInstance(makeDescriptor("off", { persist: false, adapter }));
+
+ expect(inst.columnsAdapter).toBeNull();
+ });
+
+ it("keys its default storage off the grid's name", () => {
+ expect(defaultColumnsStorageKey("orders")).toBe("datagrid:orders:columns");
+ });
+
+ it("knows every built-in cell without being handed one", () => {
+ const inst = createGridInstance(makeDescriptor("cells"));
+
+ expect(inst.cells.makeCell("dg:number", 1, { editable: true, options: {} })).toBeDefined();
+ expect(inst.cells.makeCell("text", "hi", { editable: true, options: undefined })).toBeDefined();
});
});
diff --git a/src/features/dataGrid/store/createGridInstance.ts b/src/features/dataGrid/store/createGridInstance.ts
index 5cce4a5..32d18fb 100644
--- a/src/features/dataGrid/store/createGridInstance.ts
+++ b/src/features/dataGrid/store/createGridInstance.ts
@@ -1,10 +1,10 @@
// src/features/dataGrid/store/createGridInstance.ts
import { combineReducers } from "@reduxjs/toolkit";
import type { RowStore } from "../data/rowStore";
-import type { GridDescriptor, GridInstance, GridInstanceOptions, GridSliceState } from "../types";
+import type { GridDescriptor, GridInstance, GridSliceState } from "../types";
+import { resolveCellRegistry } from "../../../lib/grid/cellRegistry";
+import { resolveColumnsAdapter } from "./columnsAdapter";
import { createColumnsSlice } from "./columnsSlice";
-import { registerEffects } from "./effects";
-import type { GridEffectContext } from "./effects/types";
import { createEditsSlice } from "./editsSlice";
import { createGroupsSlice } from "./groupsSlice";
import { createSelectionSlice } from "./selectionSlice";
@@ -24,29 +24,26 @@ import { createSelectionSlice } from "./selectionSlice";
* long-lived subscriber (data/sync/useRowSync.ts) reach the current store
* without re-subscribing.
*
- * `options.startListening` is the host app's listener middleware registration.
- * The engine owns no middleware of its own, so a grid that persists its column
- * layout has to be handed one.
+ * The descriptor is the ONLY argument. Column persistence used to need the host
+ * app's listener middleware passed in beside it; it now runs from the mounted
+ * grid instead (`hooks/useColumnPersistence.ts`), which is the only place that
+ * both reaches a store and knows the grid is on screen.
*/
export function createGridInstance<
TRow extends object,
TGroup,
TKey extends string | number = number,
->(
- descriptor: GridDescriptor,
- options: GridInstanceOptions = {},
-): GridInstance {
+>(descriptor: GridDescriptor): GridInstance {
const name = descriptor.name;
- // Fail at wiring time rather than at the first column resize. A missing
- // listener means saveColumns is never called, and a layout that silently
- // stops persisting is close to impossible to trace back to this line.
- if (descriptor.api.saveColumns && !options.startListening) {
- throw new Error(
- `createGridInstance("${name}"): descriptor.api.saveColumns is set, so the grid needs the ` +
- `host app's listener middleware. Pass { startListening: listenerMiddleware.startListening }.`,
- );
- }
+ // Resolved once, here, so the load on mount and every save afterwards cannot
+ // disagree about where this grid's layout lives.
+ const columnsAdapter = resolveColumnsAdapter(name, descriptor.columns);
+
+ // The built-in cell types with the descriptor's own laid over them, built
+ // once. A registry rebuilt per render would hand glide a new renderer array
+ // every time, and glide re-creates every editor when that array changes.
+ const cells = resolveCellRegistry(descriptor.cells);
// Published by `useGridData` when a grid mounts. It stays null until then,
// and a push that arrives first is dropped rather than written to a store no
@@ -65,10 +62,7 @@ export function createGridInstance<
const groups = createGroupsSlice(name);
- const columns = createColumnsSlice(name, {
- defaultOrder: descriptor.columns.defaultOrder,
- load: descriptor.api.loadColumns,
- });
+ const columns = createColumnsSlice(name, { defaultOrder: descriptor.columns.defaultOrder });
const selection = createSelectionSlice(name);
@@ -83,26 +77,6 @@ export function createGridInstance<
const selectRoot = (s: unknown) => (s as Record>)[name];
- // Register every GridEffect for this instance on the shared grid listener.
- const ctx: GridEffectContext = {
- name,
- descriptor,
- selectRoot,
- actions: {
- columns: columns.actions,
- groups: groups.actions,
- selection: selection.actions,
- edits: edits.actions,
- },
- };
-
- // No listener and no saveColumns (the guard above rules out the other pair):
- // there is nothing for an effect to do, so register nothing and hand back a
- // stopEffects that is honestly a no-op.
- const stopEffects = options.startListening
- ? registerEffects(ctx, options.startListening)
- : () => {};
-
// A push that moves positions cannot patch a page-indexed cache in place, so
// it invalidates every loaded page instead. Bumping a number rather than
// clearing the store is what lets `useGridData` load the replacement behind
@@ -149,10 +123,7 @@ export function createGridInstance<
subscribeDataGeneration,
getDataGeneration,
bumpDataGeneration,
- stopEffects,
- thunks: {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- loadColumns: columns.loadColumns as any,
- },
+ columnsAdapter,
+ cells,
};
}
diff --git a/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts b/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts
deleted file mode 100644
index b2bdf0d..0000000
--- a/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { GridEffect } from "./types";
-
-const SAVE_DEBOUNCE_MS = 500;
-
-/** Persist column layout via descriptor.api.saveColumns, debounced. No-ops when
- * the grid declares no saveColumns. */
-export const columnsPersistenceEffect: GridEffect = (ctx, startListening) => {
- const save = ctx.descriptor.api.saveColumns;
-
- if (!save) return;
-
- const { moveColumn, resetColumns, resizeColumn, toggleColumn } = ctx.actions.columns;
-
- // `resetColumns` is watched for the same reason as the other three: it
- // CHANGES the layout. Leaving it out would let a reset look like it worked
- // and then hand the thrown-away layout straight back on the next load.
- const watched = [moveColumn.type, resetColumns.type, resizeColumn.type, toggleColumn.type];
-
- startListening({
- predicate: (action: { type: string }) => watched.includes(action.type),
- effect: async (
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- _action: any,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- api: any,
- ) => {
- api.cancelActiveListeners(); // debounce: cancel any queued save
- await api.delay(SAVE_DEBOUNCE_MS); // resolves only if not superseded
- await save(ctx.selectRoot(api.getState()).columns);
- },
- });
-};
diff --git a/src/features/dataGrid/store/effects/index.test.ts b/src/features/dataGrid/store/effects/index.test.ts
deleted file mode 100644
index 342a5f4..0000000
--- a/src/features/dataGrid/store/effects/index.test.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { configureStore, createListenerMiddleware, type Reducer } from "@reduxjs/toolkit";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { createGridInstance } from "../createGridInstance";
-import type { GridDescriptor, GridSliceState } from "../../types";
-
-// Stands in for the host app's listener middleware. The engine ships none of
-// its own, so every test that exercises an effect has to supply one — exactly
-// as a consuming app does.
-const appListener = createListenerMiddleware();
-
-interface Row {
- id: number;
-}
-
-function makeDescriptor(name: string, saveColumns: (columns: unknown) => Promise) {
- return {
- name,
- rowKey: (r: Row) => r.id,
- columns: {
- defs: { id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "number" } },
- defaultOrder: ["id"],
- },
- api: {
- updateRow: async () => ({ ok: true }),
- saveColumns,
- },
- cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true },
- } as unknown as GridDescriptor;
-}
-
-function makeStore(name: string, reducer: Reducer>) {
- return configureStore({
- reducer: { [name]: reducer },
- middleware: (getDefault) =>
- getDefault({ serializableCheck: false }).concat(appListener.middleware),
- });
-}
-
-describe("grid effects on the host app's listener", () => {
- beforeEach(() => vi.useFakeTimers());
- afterEach(() => vi.useRealTimers());
-
- it("runs a grid's effects through the app listener, with no per-grid middleware", async () => {
- const save = vi.fn().mockResolvedValue(undefined);
- const inst = createGridInstance(makeDescriptor("sharedDemo", save), {
- startListening: appListener.startListening,
- });
- const store = makeStore("sharedDemo", inst.reducer);
-
- store.dispatch(inst.actions.resizeColumn({ field: "id", width: 200 }));
- await vi.advanceTimersByTimeAsync(600);
-
- expect(save).toHaveBeenCalledWith(expect.objectContaining({ widths: { id: 200 } }));
-
- inst.stopEffects();
- });
-
- it("stopEffects removes the grid's listeners from the app listener", async () => {
- const save = vi.fn().mockResolvedValue(undefined);
- const inst = createGridInstance(makeDescriptor("stoppedDemo", save), {
- startListening: appListener.startListening,
- });
- const store = makeStore("stoppedDemo", inst.reducer);
-
- inst.stopEffects();
- store.dispatch(inst.actions.resizeColumn({ field: "id", width: 200 }));
- await vi.advanceTimersByTimeAsync(600);
-
- expect(save).not.toHaveBeenCalled();
- });
-
- it("refuses to build a persisting grid without a listener, instead of never saving", () => {
- const save = vi.fn().mockResolvedValue(undefined);
-
- expect(() => createGridInstance(makeDescriptor("unwiredDemo", save))).toThrow(
- /needs the host app's listener middleware/,
- );
- });
-
- it("builds a grid that persists nothing without needing a listener at all", () => {
- const descriptor = makeDescriptor("readOnlyDemo", undefined as never);
- delete (descriptor as { api: { saveColumns?: unknown } }).api.saveColumns;
-
- const inst = createGridInstance(descriptor);
-
- expect(typeof inst.stopEffects).toBe("function");
- inst.stopEffects();
- });
-
- it("persists a reset, so a layout the user threw away does not come back on reload", async () => {
- const save = vi.fn().mockResolvedValue(undefined);
- const inst = createGridInstance(makeDescriptor("resetDemo", save), {
- startListening: appListener.startListening,
- });
- const store = makeStore("resetDemo", inst.reducer);
-
- store.dispatch(inst.actions.resizeColumn({ field: "id", width: 200 }));
- await vi.advanceTimersByTimeAsync(600);
- save.mockClear();
-
- store.dispatch(inst.actions.resetColumns());
- await vi.advanceTimersByTimeAsync(600);
-
- expect(save).toHaveBeenCalledWith({ order: ["id"], widths: {}, hidden: [] });
-
- inst.stopEffects();
- });
-});
diff --git a/src/features/dataGrid/store/effects/index.ts b/src/features/dataGrid/store/effects/index.ts
deleted file mode 100644
index 32a18de..0000000
--- a/src/features/dataGrid/store/effects/index.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { GridEffect, GridEffectContext } from "./types";
-import type { StartListening } from "../../types";
-import { columnsPersistenceEffect } from "./columnsPersistenceEffect";
-
-/** All action-driven side-effects a grid runs. Add new effects here. */
-export const gridEffects: GridEffect[] = [columnsPersistenceEffect];
-
-/**
- * Register every gridEffect for one grid instance against the HOST app's
- * listener middleware. Keeps the effect-wiring concern owned by effects/, the
- * way each slice owns its reducer/actions.
- *
- * The listener arrives from the caller rather than from a module import: this
- * package cannot reach the app's store, and an app that already runs one shared
- * listener should not gain a second one just because it mounted a grid.
- *
- * Returns a function that removes this instance's listeners again. The store
- * never needs it — grids live as long as the app — but it keeps tests that
- * build throwaway instances from leaking listeners into later tests.
- */
-export function registerEffects(
- ctx: GridEffectContext,
- startAppListening: StartListening,
-): () => void {
- const unsubscribers: Array<() => void> = [];
-
- // GridEffect returns void so one effect may register several listeners; wrap
- // startListening to collect every unsubscribe it hands back.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const startListening = (options: any) => {
- const unsubscribe = startAppListening(options);
- unsubscribers.push(unsubscribe);
- return unsubscribe;
- };
-
- for (const effect of gridEffects) effect(ctx, startListening);
-
- return () => {
- for (const unsubscribe of unsubscribers) unsubscribe();
- unsubscribers.length = 0;
- };
-}
diff --git a/src/features/dataGrid/store/effects/types.ts b/src/features/dataGrid/store/effects/types.ts
deleted file mode 100644
index 67f8702..0000000
--- a/src/features/dataGrid/store/effects/types.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import type { GridDescriptor, GridSliceState } from "../../types";
-
-/**
- * The per-slice action creators an effect may react to / dispatch. Typed loosely
- * (engine-factory layer), but carrying the `.type` tag RTK attaches to every action
- * creator, so effects can match by action type without unsafe casts.
- */
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type Actions = Record { type: string }) & { type: string }>;
-
-/**
- * Everything an effect needs to reach one grid: its identity, its descriptor,
- * its state, and its action creators.
- */
-export interface GridEffectContext {
- /**
- * Store namespace of this grid. Same value as `descriptor.name`.
- */
- name: string;
-
- /**
- * The grid's static description, including the server calls.
- */
- descriptor: GridDescriptor;
-
- /**
- * Reads this grid's state out of the app store.
- */
- selectRoot: (s: unknown) => GridSliceState;
-
- /**
- * Action creators, one group per slice.
- */
- actions: {
- /**
- * Column order, width, and visibility actions.
- */
- columns: Actions;
-
- /**
- * Group discovery, collapse, and sort actions.
- */
- groups: Actions;
-
- /**
- * Row selection actions.
- */
- selection: Actions;
-
- /**
- * Edit-error actions.
- */
- edits: Actions;
- };
-}
-
-/**
- * An effect registers one (or more) listeners via `startListening`, or no-ops
- * (e.g. when its descriptor api hook is absent). Returning void keeps the
- * signature simple and lets one effect add several listeners if needed.
- */
-export type GridEffect = (
- /**
- * The grid the effect attaches to.
- */
- ctx: GridEffectContext,
-
- /**
- * RTK's listener registration function. Its real type is heavily generic, so
- * we type it `any` here (engine factory layer — localized `any` is already
- * sanctioned). Effects themselves stay readable. Keep the `any` in effects/.
- */
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- startListening: any,
-) => void;
diff --git a/src/features/dataGrid/store/localStorageColumnsAdapter.ts b/src/features/dataGrid/store/localStorageColumnsAdapter.ts
deleted file mode 100644
index fe2fb47..0000000
--- a/src/features/dataGrid/store/localStorageColumnsAdapter.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { ColumnsState } from "../types";
-
-/**
- * Builds `loadColumns`/`saveColumns` backed by Web Storage. This is the home of
- * localStorage knowledge — the slice and engine never touch it directly. A future
- * grid can supply a REST-backed adapter with the same shape (descriptor.api.*).
- */
-export function localStorageColumnsAdapter(
- storageKey: string,
- storage: Pick = localStorage,
-): {
- loadColumns: () => Promise;
- saveColumns: (s: ColumnsState) => Promise;
-} {
- return {
- async loadColumns() {
- try {
- const raw = storage.getItem(storageKey);
- if (!raw) return null;
- const parsed = JSON.parse(raw) as Partial;
- // Only hydrate fields that are present & well-formed; missing → caller keeps defaults.
- if (!parsed || typeof parsed !== "object") return null;
- return {
- order: Array.isArray(parsed.order) && parsed.order.length ? parsed.order : [],
- widths: parsed.widths ?? {},
- hidden: parsed.hidden ?? [],
- };
- } catch {
- return null;
- }
- },
- async saveColumns(state) {
- try {
- storage.setItem(storageKey, JSON.stringify(state));
- } catch {
- /* ignore quota/unavailable */
- }
- },
- };
-}
diff --git a/src/features/dataGrid/types.ts b/src/features/dataGrid/types.ts
index be68ee6..b6a14a4 100644
--- a/src/features/dataGrid/types.ts
+++ b/src/features/dataGrid/types.ts
@@ -1,14 +1,23 @@
import type { ReactNode } from "react";
import type { Reducer } from "@reduxjs/toolkit";
-import type { CellRegistry } from "../../lib/grid/cellRegistry";
+import type { CellRegistry, CellTypeDef } from "../../lib/grid/cellRegistry";
+import type { DateCellOptions } from "../../lib/grid/dateCell";
+import type { EnumCellOptions } from "../../lib/grid/enumChoices";
+import type { NumberCellOptions } from "../../lib/grid/numberCell";
+import type { PhoneCellOptions } from "../../lib/grid/phoneCell";
import type { RadixColor } from "../../lib/grid/radixBadgePalette";
+import type { TextCellOptions } from "../../lib/grid/textCell";
import type { RowStore } from "./data/rowStore";
import type { SortSpec } from "./data/sortSpec";
/**
- * Generic column definition.
+ * What every column says, whatever it draws.
+ *
+ * Everything here is read by the GRID itself — the header, the layout, the sort
+ * menu, the edit path. A setting only one cell type understands does not belong
+ * here; it belongs on that column's `options`.
*/
-export interface ColumnDef {
+interface ColumnDefBase {
/**
* Row property this column reads. Also the column's id in every state slice.
*/
@@ -26,6 +35,9 @@ export interface ColumnDef {
/**
* Whether a cell in this column accepts an edit.
+ *
+ * Read by the grid, not just by the cell: it gates the edit overlay and the
+ * save path as well as whether an editor opens.
*/
editable: boolean;
@@ -38,16 +50,72 @@ export interface ColumnDef {
* such a column is an arrow onto an error.
*/
sortable?: boolean;
+}
+
+/**
+ * A column definition: what the column is, and how its cells behave.
+ *
+ * `type` names the cell, exactly the way glide names its own. The five this
+ * package draws are prefixed `dg:`; glide's own kinds are reachable under
+ * glide's own names, unprefixed. The prefix is what keeps the two sets apart
+ * forever, so glide may add kinds without ever colliding with ours.
+ *
+ * `options` is that cell type's own settings, and each branch below types its
+ * own. A column naming a cell the HOST registered through `descriptor.cells`
+ * says so with `custom: true`, and its `options` is then `unknown`.
+ *
+ * That one word is what makes every other branch check. An open `type: string`
+ * branch would swallow everything: a mistyped `type`, a mistyped key inside a
+ * built-in column's `options`, even a `dg:enum` with no choices at all would
+ * satisfy it and raise nothing. With `custom` as the discriminant, TypeScript
+ * has to pick a real branch, so all three are errors — and the cost is one key
+ * on the columns that genuinely are custom.
+ */
+export type ColumnDef =
+ | (ColumnDefBase & { type: "dg:text"; custom?: false; options?: TextCellOptions })
+ | (ColumnDefBase & { type: "dg:number"; custom?: false; options?: NumberCellOptions })
+ | (ColumnDefBase & { type: "dg:date"; custom?: false; options?: DateCellOptions })
+ | (ColumnDefBase & { type: "dg:enum"; custom?: false; options: EnumCellOptions })
+ | (ColumnDefBase & { type: "dg:phone"; custom?: false; options?: PhoneCellOptions })
+ | (ColumnDefBase & {
+ /** One of glide's own cell kinds, drawn by glide. These take no options. */
+ type: "text" | "number" | "boolean" | "uri" | "markdown" | "image" | "bubble" | "drilldown";
+ custom?: false;
+ options?: undefined;
+ })
+ | (ColumnDefBase & {
+ /** A cell type the host registered through `descriptor.cells`. */
+ type: string;
+
+ /**
+ * REQUIRED for a host-registered cell type, and the only thing that marks
+ * one. Without it a `type` this package does not know is an error naming
+ * the ones it does — which is what catches `"dg:numbr"`.
+ */
+ custom: true;
+
+ /** Whatever that cell type reads. Only it knows the shape. */
+ options?: unknown;
+ });
+/**
+ * Reads and writes the user's column layout.
+ *
+ * Both calls are async, whatever the storage behind them: the engine's own
+ * default writes to Web Storage and resolves immediately, and a host that keeps
+ * layouts on a server returns a real request. One shape, so the engine's save
+ * path has one thing to await.
+ */
+export interface ColumnsAdapter {
/**
- * Cell kind. Must match a cell registry entry.
+ * Reads the saved layout, or null when there is none to restore.
*/
- type: string;
+ loadColumns: () => Promise;
/**
- * Date cells only: keep the time part instead of a floating calendar date.
+ * Persists the layout. Called debounced, after the user stops changing it.
*/
- withTime?: boolean;
+ saveColumns: (state: ColumnsState) => Promise;
}
/**
@@ -176,17 +244,18 @@ export interface GridGrouping {
/**
* Text color of a group's header row, named as a Radix scale.
*
- * The point is to make the header agree with the column it groups by: when
- * that column is an enum cell, return `enumCell.colorOf(g)` and the header
- * text lands on the same scale as the badge in the cell and the badge in the
- * editor's dropdown. The engine resolves the scale to a concrete color
- * through the same CSS vars the badges read, so it follows the appearance
- * without being told about it.
+ * OMIT IT when grouping by a `dg:enum` column: the engine already holds that
+ * column's choices, so it colours the header from them and the name lands on
+ * the same scale as the badge in the cell and the badge in the editor's
+ * dropdown. Setting this overrides that.
*
- * Answering `undefined` — for the whole grid by omitting this, or for one
- * group by returning it — falls the header back to the theme's header text
- * color. That is the right answer for a group with no color of its own; it is
- * not an error.
+ * The engine resolves the scale to a concrete color through the same CSS vars
+ * the badges read, so it follows the appearance without being told about it.
+ *
+ * Answering `undefined` — for one group by returning it, or for a grid that
+ * groups by a column with no colours of its own — falls the header back to
+ * the theme's header text color. That is the right answer for a group with no
+ * color of its own; it is not an error.
*/
color?: (g: TGroup) => RadixColor | undefined;
@@ -237,7 +306,8 @@ export interface GridDescriptor TKey;
/**
- * Column set and its default presentation.
+ * Column set, its default presentation, and how the user's changes to it are
+ * remembered.
*/
columns: {
/**
@@ -249,6 +319,24 @@ export interface GridDescriptor) => Promise<{ ok: boolean; row?: TRow }>;
-
- /**
- * Reads the user's saved column layout. Omit it to always start from the
- * defaults.
- */
- loadColumns?: () => Promise;
-
- /**
- * Persists the user's column layout.
- */
- saveColumns?: (state: ColumnsState) => Promise;
};
/**
- * Cell renderers and editors, looked up by `ColumnDef.type`.
+ * Cell types this grid understands ON TOP of the built-in ones.
+ *
+ * Omit it entirely for a grid built from `dg:` cells and glide's own kinds —
+ * which is most grids. A def whose `type` matches a built-in replaces that
+ * built-in for this grid, which is how a host changes a cell we draw without
+ * forking the package.
*/
- cells: CellRegistry;
+ cells?: CellTypeDef[];
/**
* Row-window page size. Defaults to 100.
@@ -449,42 +531,11 @@ export interface GridSliceState {
edits: EditState;
}
-/**
- * RTK's `startListening`, as a grid needs it.
- *
- * The engine registers its action-driven side-effects on the HOST app's
- * listener middleware rather than owning one of its own: a listener already
- * discriminates by action type, so a middleware per library buys nothing and
- * forces every consumer to `.concat` another entry. Pass
- * `listenerMiddleware.startListening` (or a `.withTypes<...>()` wrapper of it).
- *
- * The real RTK type is heavily generic and its state parameter belongs to the
- * host, not to us, so this stays deliberately loose — the same localized `any`
- * the rest of the engine-factory layer uses.
- */
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type StartListening = (options: any) => () => void;
-
-/**
- * Host wiring handed to `createGridInstance`.
- */
-export interface GridInstanceOptions {
- /**
- * The host app's listener registration function.
- *
- * Required whenever the descriptor sets `api.saveColumns`, because column
- * persistence runs as a listener effect; `createGridInstance` throws when it
- * is missing rather than silently never saving. Omit it for a grid that
- * persists nothing.
- */
- startListening?: StartListening;
-}
-
/**
* Everything a grid needs at runtime, created once at module scope per grid:
* the combined reducer (mount under `descriptor.name`) for client-owned state,
- * a root selector, all slice action creators, the thunks, and the cell that
- * holds the mounted grid's row store. Hooks and take this.
+ * a root selector, all slice action creators, the columns adapter, and the cell
+ * that holds the mounted grid's row store. Hooks and take this.
*/
export interface GridInstance {
/**
@@ -586,18 +637,20 @@ export interface GridInstance void;
/**
- * Removes this instance's effects from the shared grid listener.
+ * Where this grid's column layout is read from and written to, or null when
+ * it persists nothing.
+ *
+ * Resolved once, from `descriptor.columns`, so the load on mount and every
+ * save afterwards cannot disagree about where the layout lives.
*/
- stopEffects: () => void;
+ columnsAdapter: ColumnsAdapter | null;
/**
- * Async actions that need the descriptor.
+ * Every cell type this grid can draw: the built-ins with the descriptor's own
+ * laid over them, resolved once.
+ *
+ * Built here rather than in the component because glide re-creates every
+ * editor whenever the `customRenderers` array it was handed changes identity.
*/
- thunks: {
- /**
- * Restores the saved column layout through `api.loadColumns`.
- */
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- loadColumns: () => any;
- };
+ cells: CellRegistry;
}
diff --git a/src/index.ts b/src/index.ts
index 421938b..b3d7823 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,8 +4,8 @@
* `package.json` exposes only `.`, `./testing`, `./radix-styles`, and the
* stylesheet, so nothing below this file is reachable from a consuming app.
* That is deliberate: everything NOT re-exported here — the row store, the
- * display model, the slices, every hook but the ones named below — is free to
- * be renamed, moved, or rewritten in a patch release.
+ * display model, the slices, the cell registry, every hook but the ones named
+ * below — is free to be renamed, moved, or rewritten in a patch release.
*
* Add an export here only when a consuming app genuinely cannot do its job
* without it. Every symbol added is a promise.
@@ -13,14 +13,20 @@
// ------------------------------------------------------------- the grid --
-export { DataGrid } from "./features/dataGrid/DataGrid";
+export {
+ DataGrid,
+ type DataGridProps,
+ type GridDrawHeader,
+ type GridDrawHeaderArgs,
+} from "./features/dataGrid/DataGrid";
+
export { createGridInstance } from "./features/dataGrid/store/createGridInstance";
-export { localStorageColumnsAdapter } from "./features/dataGrid/store/localStorageColumnsAdapter";
// -------------------------------------------------- describing one grid --
export type {
ColumnDef,
+ ColumnsAdapter,
ColumnsState,
EditError,
EditState,
@@ -28,13 +34,11 @@ export type {
GridDescriptor,
GridGrouping,
GridInstance,
- GridInstanceOptions,
GridSliceState,
GridSort,
GroupsState,
RowChange,
SelectionState,
- StartListening,
UpdateRowParams,
} from "./features/dataGrid/types";
@@ -50,29 +54,29 @@ export { rowsKeyPrefix } from "./features/dataGrid/hooks/useRowPages";
// ---------------------------------------------------------------- cells --
-export {
- createCellRegistry,
- type CellContext,
- type CellRegistry,
- type CellTypeDef,
-} from "./lib/grid/cellRegistry";
+/**
+ * A column names its cell by `type`, the way glide does.
+ *
+ * The five this package draws are prefixed `dg:` — `dg:text`, `dg:number`,
+ * `dg:date`, `dg:enum`, `dg:phone` — and each takes its settings on the
+ * column's own `options`. Glide's kinds are reachable under glide's own names,
+ * unprefixed: `text`, `number`, `boolean`, `uri`, `markdown`, `image`,
+ * `bubble`, `drilldown`.
+ *
+ * Nothing has to be registered for any of them. The options types below are
+ * exported because a host writes them into a `ColumnDef`, not because a host
+ * ever builds a cell.
+ */
+export type { TextCellOptions } from "./lib/grid/textCell";
+export type { NumberCellOptions } from "./lib/grid/numberCell";
+export type { DateCellOptions } from "./lib/grid/dateCell";
+export type { EnumCellOptions, EnumOption } from "./lib/grid/enumChoices";
+export type { PhoneCellOptions } from "./lib/grid/phoneCell";
-export { createTextCell, type TextCell, type TextCellData } from "./lib/grid/createTextCell";
-export {
- createNumberCell,
- type NumberCell,
- type NumberCellData,
-} from "./lib/grid/createNumberCell";
-export { createDateCell, type DateCell, type DateCellData } from "./lib/grid/createDateCell";
-export {
- createEnumCell,
- type EnumCell,
- type EnumCellData,
- type EnumOption,
-} from "./lib/grid/createEnumCell";
-export { createPhoneCell, type PhoneCell, type PhoneCellData } from "./lib/grid/createPhoneCell";
-
-/** For a cell kind this package does not ship. */
+export type { NumberFormat } from "./lib/number/numberUtils";
+export type { TextValidationOptions } from "./lib/text/textValidation";
+
+/** For a cell kind this package does not ship. Register it on `descriptor.cells`. */
export {
createCustomCell,
drawEmptyDash,
@@ -82,6 +86,8 @@ export {
type EditorProps,
} from "./lib/grid/createCustomCell";
+export type { CellContext, CellTypeDef } from "./lib/grid/cellRegistry";
+
export { drawSoftBadge, type ResolvedBadgeColors } from "./lib/grid/softBadge";
export { BADGE_SEQUENCE, radixColorByIndex, type RadixColor } from "./lib/grid/radixBadgePalette";
@@ -99,10 +105,7 @@ export {
type DateRange,
} from "./components/ui/datePicker";
-export {
- DatePickerPopover,
- type DatePickerPopoverProps,
-} from "./components/ui/datePickerPopover";
+export { DatePickerPopover, type DatePickerPopoverProps } from "./components/ui/datePickerPopover";
// ---------------------------------------------------------------- theme --
diff --git a/src/lib/date/dateMask.ts b/src/lib/date/dateMask.ts
index 56184ba..55e69a3 100644
--- a/src/lib/date/dateMask.ts
+++ b/src/lib/date/dateMask.ts
@@ -27,11 +27,7 @@ export function maskToDate(digits: string): Date | undefined {
// Rejects both out-of-range parts and overflow days (Feb 30 → Mar 2), since
// the Date constructor silently rolls those forward.
- if (
- date.getFullYear() !== year ||
- date.getMonth() !== month - 1 ||
- date.getDate() !== day
- ) {
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return undefined;
}
diff --git a/src/lib/grid/cellRegistry.test.ts b/src/lib/grid/cellRegistry.test.ts
index ed381ac..fbe7a32 100644
--- a/src/lib/grid/cellRegistry.test.ts
+++ b/src/lib/grid/cellRegistry.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import { GridCellKind, type CustomCell, type GridCell } from "@glideapps/glide-data-grid";
-import { createCellRegistry, type CellTypeDef } from "./cellRegistry";
+import { createCellRegistry, resolveCellRegistry, type CellTypeDef } from "./cellRegistry";
+import { ENUM_CELL_TYPE } from "./enumCell";
+import { TEXT_CELL_TYPE } from "./textCell";
-const textCell = (s: string): GridCell => ({
+const plainText = (s: string): GridCell => ({
kind: GridCellKind.Text,
data: s,
displayData: s,
@@ -10,7 +12,7 @@ const textCell = (s: string): GridCell => ({
});
const defs: CellTypeDef[] = [
- { type: "text", make: (raw) => textCell(String(raw ?? "")) },
+ { type: "plain", make: (raw) => plainText(String(raw ?? "")) },
{
type: "phone",
kind: "phone",
@@ -30,12 +32,12 @@ const defs: CellTypeDef[] = [
},
];
+const ctx = { editable: false, options: undefined };
+
describe("createCellRegistry", () => {
it("makeCell dispatches by column type", () => {
const r = createCellRegistry(defs);
- expect(r.makeCell("text", "hi", { editable: false, withTime: false })).toMatchObject({
- data: "hi",
- });
+ expect(r.makeCell("plain", "hi", ctx)).toMatchObject({ data: "hi" });
});
it("collects only defined renderers", () => {
@@ -45,34 +47,71 @@ describe("createCellRegistry", () => {
it("validateCell routes custom cells by kind and passes everything else", () => {
const r = createCellRegistry(defs);
- const good = {
- kind: GridCellKind.Custom,
- data: { kind: "phone", value: "ok" },
- copyData: "",
- allowOverlay: true,
- } as unknown as CustomCell<{ kind: string }>;
- const bad = {
- kind: GridCellKind.Custom,
- data: { kind: "phone", value: "bad" },
- copyData: "",
- allowOverlay: true,
- } as unknown as CustomCell<{ kind: string }>;
- const unknown = {
- kind: GridCellKind.Custom,
- data: { kind: "mystery" },
- copyData: "",
- allowOverlay: true,
- } as unknown as CustomCell<{ kind: string }>;
- expect(r.validateCell(good)).toBe(true);
- expect(r.validateCell(bad)).toBe(false);
- expect(r.validateCell(unknown)).toBe(true); // no validator → valid
- expect(r.validateCell(textCell("x"))).toBe(true); // non-custom → valid
+ const custom = (value: unknown, kind = "phone") =>
+ ({
+ kind: GridCellKind.Custom,
+ data: { kind, value },
+ copyData: "",
+ allowOverlay: true,
+ }) as unknown as CustomCell<{ kind: string }>;
+
+ expect(r.validateCell(custom("ok"))).toBe(true);
+ expect(r.validateCell(custom("bad"))).toBe(false);
+ expect(r.validateCell(custom("x", "mystery"))).toBe(true); // no validator → valid
+ expect(r.validateCell(plainText("x"))).toBe(true); // non-custom → valid
});
it("throws on an unknown column type (fail fast, not a blank cell)", () => {
const r = createCellRegistry(defs);
- expect(() => r.makeCell("nope", 1, { editable: false, withTime: false })).toThrow(
- /unknown cell type/i,
- );
+ expect(() => r.makeCell("nope", 1, ctx)).toThrow(/unknown cell type/i);
+ });
+});
+
+describe("resolveCellRegistry", () => {
+ it("knows every dg: cell without being handed one", () => {
+ const r = resolveCellRegistry();
+ const cell = r.makeCell(TEXT_CELL_TYPE, "hi", { editable: true, options: {} });
+ expect((cell as unknown as { data: { value: string } }).data.value).toBe("hi");
+ });
+
+ it("knows glide's own kinds under glide's own names", () => {
+ const r = resolveCellRegistry();
+ expect(r.makeCell("text", "hi", ctx).kind).toBe(GridCellKind.Text);
+ expect(r.makeCell("boolean", true, ctx).kind).toBe(GridCellKind.Boolean);
+ });
+
+ it("registers one renderer per dg: cell and none for glide's kinds", () => {
+ // Five drawn by this package; glide draws its own, so they add nothing here.
+ expect(resolveCellRegistry().customRenderers).toHaveLength(5);
+ });
+
+ it("lets a host's def of the same type win over the built-in", () => {
+ const mine: CellTypeDef = { type: ENUM_CELL_TYPE, make: () => plainText("mine") };
+ const r = resolveCellRegistry([mine]);
+
+ expect(r.makeCell(ENUM_CELL_TYPE, 1, ctx)).toMatchObject({ data: "mine" });
+ });
+
+ it("adds a host's own type alongside the built-ins", () => {
+ const mine: CellTypeDef = { type: "invoice-status", make: () => plainText("custom") };
+ const r = resolveCellRegistry([mine]);
+
+ expect(r.makeCell("invoice-status", 1, ctx)).toMatchObject({ data: "custom" });
+ expect(r.makeCell("text", "still here", ctx).kind).toBe(GridCellKind.Text);
+ });
+
+ it("hands the column's options to the maker", () => {
+ const seen: unknown[] = [];
+ const mine: CellTypeDef = {
+ type: "spy",
+ make: (_raw, c) => {
+ seen.push(c.options);
+ return plainText("");
+ },
+ };
+
+ resolveCellRegistry([mine]).makeCell("spy", 1, { editable: true, options: { a: 1 } });
+
+ expect(seen).toEqual([{ a: 1 }]);
});
});
diff --git a/src/lib/grid/cellRegistry.ts b/src/lib/grid/cellRegistry.ts
index ed690da..69edcb3 100644
--- a/src/lib/grid/cellRegistry.ts
+++ b/src/lib/grid/cellRegistry.ts
@@ -4,17 +4,29 @@ import {
type CustomRenderer,
type GridCell,
} from "@glideapps/glide-data-grid";
+import { dateCellDef } from "./dateCell";
+import { enumCellDef } from "./enumCell";
+import { glideCellDefs } from "./glideCells";
+import { numberCellDef } from "./numberCell";
+import { phoneCellDef } from "./phoneCell";
+import { textCellDef } from "./textCell";
-/** Per-column-cell context the maker needs beyond the raw value. */
+/**
+ * What a cell maker knows about the column it is drawing, beyond the raw value.
+ *
+ * `options` is the column's own `options` object, typed `unknown` because only
+ * the cell type it belongs to knows its shape. A built-in cell casts it to its
+ * own options interface; a custom cell casts it to whatever that cell defined.
+ */
export interface CellContext {
editable: boolean;
- withTime: boolean;
+ options: unknown;
}
/**
* One cell type. `type` keys the column→cell dispatch; `kind` (for custom cells)
- * is the runtime `cell.data.kind` used to route validation. Built-in cells omit
- * `kind`, `renderer`, and `validate`.
+ * is the runtime `cell.data.kind` used to route validation. A def wrapping one
+ * of glide's own kinds omits `kind`, `renderer`, and `validate`.
*/
export interface CellTypeDef {
type: string;
@@ -30,11 +42,23 @@ export interface CellRegistry {
validateCell: (cell: GridCell) => boolean;
}
+/**
+ * Every cell type a grid understands without being told: the five this package
+ * draws, prefixed `dg:`, plus glide's own kinds under glide's own names.
+ */
+export const builtinCellDefs: CellTypeDef[] = [
+ textCellDef,
+ numberCellDef,
+ dateCellDef,
+ enumCellDef,
+ phoneCellDef,
+ ...glideCellDefs,
+];
+
/**
* Build a registry from cell-type defs. Derives the three things a grid needs
* from one source of truth: a type→maker dispatch, the custom-renderer list, and
- * a kind→validator router. Adding a cell type means adding one def — no edits to
- * the column dispatch, the renderer array, or a validation if-chain.
+ * a kind→validator router.
*/
export function createCellRegistry(defs: CellTypeDef[]): CellRegistry {
const byType = new Map(defs.map((d) => [d.type, d]));
@@ -51,7 +75,7 @@ export function createCellRegistry(defs: CellTypeDef[]): CellRegistry {
const def = byType.get(type);
if (!def) {
- throw new Error(`createCellRegistry: unknown cell type "${type}"`);
+ throw new Error(`Unknown cell type "${type}"`);
}
return def.make(raw, ctx);
@@ -73,3 +97,21 @@ export function createCellRegistry(defs: CellTypeDef[]): CellRegistry {
},
};
}
+
+/**
+ * The registry one grid runs on: the built-ins, with the descriptor's own cell
+ * types laid over them.
+ *
+ * A host def whose `type` matches a built-in WINS. That is deliberate and is the
+ * only way a host can change how, say, `dg:date` draws without forking the
+ * package — and it is the same order the rest of the engine reads in: the
+ * consumer's answer beats ours, ours beats glide's default.
+ */
+export function resolveCellRegistry(custom: readonly CellTypeDef[] = []): CellRegistry {
+ const byType = new Map();
+
+ for (const def of builtinCellDefs) byType.set(def.type, def);
+ for (const def of custom) byType.set(def.type, def);
+
+ return createCellRegistry([...byType.values()]);
+}
diff --git a/src/lib/grid/createDateCell.test.ts b/src/lib/grid/createDateCell.test.ts
deleted file mode 100644
index f7c32bf..0000000
--- a/src/lib/grid/createDateCell.test.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { CustomCell } from "@glideapps/glide-data-grid";
-import { createDateCell, type DateCellData } from "./createDateCell";
-
-const date = createDateCell({ kind: "date", nullable: true });
-
-describe("createDateCell.makeCell", () => {
- it("stores the ISO value, withTime flag, and ISO copyData", () => {
- const cell = date.makeCell("2026-06-20T00:00:00Z", true);
- expect(cell.data.kind).toBe("date");
- expect(cell.data.value).toBe("2026-06-20T00:00:00Z");
- expect(cell.data.withTime).toBe(true);
- expect(cell.copyData).toBe("2026-06-20T00:00:00Z");
- });
-
- it("renders a null value as empty copyData", () => {
- const cell = date.makeCell(null, false);
- expect(cell.data.value).toBeNull();
- expect(cell.data.withTime).toBe(false);
- expect(cell.copyData).toBe("");
- });
-
- it("disables the overlay when allowOverlay is false", () => {
- const cell = date.makeCell("2026-06-20T00:00:00Z", false, false);
- expect(cell.allowOverlay).toBe(false);
- });
-
- it("allows the overlay by default", () => {
- const cell = date.makeCell("2026-06-20T00:00:00Z", false);
- expect(cell.allowOverlay).toBe(true);
- });
-
- it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => {
- expect(date.makeCell(null, false, false).data.readOnly).toBe(true);
- });
-
- it("leaves an editable cell unflagged so an empty value stays blank", () => {
- expect(date.makeCell(null, false).data.readOnly).toBeUndefined();
- });
-});
-
-describe("createDateCell.validate", () => {
- const mk = (value: string | null): CustomCell => date.makeCell(value, false);
-
- it("accepts a valid ISO value", () => {
- expect(date.validate(mk("2026-06-20T00:00:00Z"))).toBe(true);
- });
-
- it("accepts empty because the cell is nullable", () => {
- expect(date.validate(mk(null))).toBe(true);
- });
-
- it("rejects a malformed non-empty value", () => {
- expect(date.validate(mk("garbage"))).toBe(false);
- });
-});
-
-describe("createDateCell.renderer", () => {
- it("matches cells of its kind", () => {
- const cell = date.makeCell("2026-06-20T00:00:00Z", false);
- expect(date.renderer.isMatch(cell)).toBe(true);
- });
-});
-
-describe("createDateCell.onPaste", () => {
- const baseData: DateCellData = { kind: "date", value: null, withTime: false };
-
- it("pins a pasted ISO date to UTC midnight for a date-only cell", () => {
- expect(date.renderer.onPaste?.("2026-06-20", baseData)).toEqual({
- kind: "date",
- value: "2026-06-20T00:00:00.000Z",
- withTime: false,
- });
- });
-
- it("rejects an unparseable paste (returns undefined)", () => {
- expect(date.renderer.onPaste?.("garbage", baseData)).toBeUndefined();
- });
-
- it("accepts an empty paste as null because the cell is nullable", () => {
- expect(date.renderer.onPaste?.(" ", baseData)).toEqual({
- kind: "date",
- value: null,
- withTime: false,
- });
- });
-
- it("preserves the time component when the cell is withTime", () => {
- expect(
- date.renderer.onPaste?.("2026-06-20T14:30:00Z", { ...baseData, withTime: true }),
- ).toEqual({
- kind: "date",
- value: "2026-06-20T14:30:00.000Z",
- withTime: true,
- });
- });
-});
-
-function fakeCtx() {
- const calls: string[] = [];
- const ctx = {
- font: "",
- fillStyle: "" as string,
- textBaseline: "alphabetic" as CanvasTextBaseline,
- fillText: (t: string) => {
- calls.push(t);
- },
- };
- return { ctx: ctx as unknown as CanvasRenderingContext2D, calls };
-}
-
-const theme = {
- textDark: "#111",
- textLight: "#999",
- baseFontFull: "13px sans-serif",
- cellHorizontalPadding: 8,
-};
-const rect = { x: 0, y: 0, width: 120, height: 34 };
-const drawArgs = (ctx: CanvasRenderingContext2D) =>
- ({ ctx, rect, theme }) as unknown as Parameters[0];
-
-describe("createDateCell draw", () => {
- it("draws an em dash for a read-only empty cell", () => {
- const { ctx, calls } = fakeCtx();
- date.renderer.draw(drawArgs(ctx), date.makeCell(null, false, false));
- expect(calls).toEqual(["—"]);
- });
-
- it("draws nothing for an editable empty cell", () => {
- const { ctx, calls } = fakeCtx();
- date.renderer.draw(drawArgs(ctx), date.makeCell(null, false));
- expect(calls).toEqual([]);
- });
-
- it("draws the localized date for a non-empty cell", () => {
- const { ctx, calls } = fakeCtx();
- date.renderer.draw(drawArgs(ctx), date.makeCell("2026-06-20T00:00:00Z", false, false));
- expect(calls).toEqual(["6/20/2026"]);
- });
-});
diff --git a/src/lib/grid/createDateCell.tsx b/src/lib/grid/createDateCell.tsx
deleted file mode 100644
index 003df05..0000000
--- a/src/lib/grid/createDateCell.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid";
-import { Theme } from "@radix-ui/themes";
-import { DatePicker } from "../../components/ui/datePicker";
-import { gridRadixTheme } from "../../theme/radixTheme";
-import { formatDateDisplay, isValidDateValue, parseDatePaste } from "../date/dateUtils";
-import {
- createCustomCell,
- drawEmptyDash,
- makeCustomCell,
- type EditorProps,
-} from "./createCustomCell";
-
-/** Cell payload. `value` is a canonical UTC ISO string, or null for an unset cell. */
-export interface DateCellData {
- kind: string;
- value: string | null;
- withTime: boolean;
- readOnly?: boolean;
-}
-
-export interface DateCell {
- renderer: CustomRenderer>;
- makeCell: (
- value: string | null,
- withTime: boolean,
- allowOverlay?: boolean,
- ) => CustomCell;
- validate: (cell: CustomCell) => boolean;
-}
-
-/**
- * Build a reusable date cell: a canvas text `draw` (localized date, or date+time
- * when the cell's `withTime` is set) plus a `DatePicker` overlay editor. Stores
- * canonical UTC ISO strings.
- *
- * The editor HOLDS the edit: nothing reaches the cell until the user presses OK,
- * and Cancel or Escape discards it. A click on a day is no longer a commit.
- *
- * Call once at module scope (not inside a React render): the editor component
- * identity is tied to this call, so re-creating it per render would remount it.
- */
-export function createDateCell({
- kind,
- nullable = false,
-}: {
- kind: string;
- nullable?: boolean;
-}): DateCell {
- const Editor = ({ value, onFinishedEditing }: EditorProps) => (
-
- onFinishedEditing({ ...value, value: next })}
- onCancel={() => onFinishedEditing(undefined)}
- />
-
- );
-
- const renderer = createCustomCell({
- kind,
- draw: (args, data) => {
- if (data.value == null || data.value === "") {
- if (data.readOnly) drawEmptyDash(args);
- return;
- }
-
- const { ctx, rect, theme } = args;
- ctx.fillStyle = theme.textDark;
- ctx.font = theme.baseFontFull;
- ctx.textBaseline = "middle";
- ctx.fillText(
- formatDateDisplay(data.value, data.withTime),
- rect.x + theme.cellHorizontalPadding,
- rect.y + rect.height / 2,
- );
- },
- editor: Editor,
- onPaste: (val, data) => {
- const parsed = parseDatePaste(val, data.withTime);
-
- if (parsed === undefined) return undefined;
- if (parsed === null && !nullable) return undefined;
-
- return { ...data, value: parsed };
- },
- });
-
- const makeCell = (
- value: string | null,
- withTime: boolean,
- allowOverlay = true,
- ): CustomCell =>
- makeCustomCell(
- { kind, value, withTime, ...(allowOverlay ? {} : { readOnly: true }) },
- value ?? "",
- allowOverlay,
- );
-
- const validate = (cell: CustomCell): boolean =>
- isValidDateValue(cell.data.value, nullable);
-
- return { renderer, makeCell, validate };
-}
diff --git a/src/lib/grid/createEnumCell.test.ts b/src/lib/grid/createEnumCell.test.ts
deleted file mode 100644
index 6eb9d93..0000000
--- a/src/lib/grid/createEnumCell.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { GridCellKind } from "@glideapps/glide-data-grid";
-import { describe, expect, it } from "vitest";
-import { createEnumCell } from "./createEnumCell";
-import { radixColorByIndex } from "./radixBadgePalette";
-
-const cell = createEnumCell({
- kind: "enum-test",
- options: [
- { value: 1, label: "One" },
- { value: 2, label: "Two", color: "red" },
- ],
-});
-
-const nullableCell = createEnumCell({
- kind: "enum-nullable-test",
- nullable: true,
- options: [
- { value: 1, label: "One" },
- { value: 2, label: "Two", color: "red" },
- ],
-});
-
-describe("createEnumCell", () => {
- it("makeCell builds a custom cell with kind, value, and label copyData", () => {
- const c = cell.makeCell(1);
- expect(c.kind).toBe(GridCellKind.Custom);
- expect(c.data).toEqual({ kind: "enum-test", value: 1 });
- expect(c.copyData).toBe("One");
- });
-
- it("colorOf uses the index default when no override is given", () => {
- expect(cell.colorOf(1)).toBe(radixColorByIndex(1));
- });
-
- it("colorOf prefers a per-value override over the index default", () => {
- expect(cell.colorOf(2)).toBe("red");
- });
-
- it("the renderer matches its own cell kind and not others", () => {
- const match = cell.renderer.isMatch(cell.makeCell(1));
- expect(match).toBe(true);
- });
-
- it("makeCell(null) builds a cleared cell with null value and empty copyData", () => {
- const c = nullableCell.makeCell(null);
- expect(c.data).toEqual({ kind: "enum-nullable-test", value: null });
- expect(c.copyData).toBe("");
- });
-});
-
-describe("createEnumCell.makeCell allowOverlay", () => {
- it("disables the overlay (no Select) when allowOverlay is false", () => {
- expect(cell.makeCell(1, false).allowOverlay).toBe(false);
- });
-
- it("allows the overlay by default", () => {
- expect(cell.makeCell(1).allowOverlay).toBe(true);
- });
-
- it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => {
- expect(nullableCell.makeCell(null, false).data.readOnly).toBe(true);
- });
-
- it("leaves an editable cell unflagged so an empty value stays blank", () => {
- expect(cell.makeCell(1).data.readOnly).toBeUndefined();
- });
-});
-
-function fakeCtx() {
- const calls: string[] = [];
- const ctx = {
- font: "",
- fillStyle: "" as string,
- textBaseline: "alphabetic" as CanvasTextBaseline,
- fillText: (t: string) => {
- calls.push(t);
- },
- };
- return { ctx: ctx as unknown as CanvasRenderingContext2D, calls };
-}
-
-const theme = {
- textDark: "#111",
- textLight: "#999",
- baseFontFull: "13px sans-serif",
- cellHorizontalPadding: 8,
-};
-const rect = { x: 0, y: 0, width: 160, height: 34 };
-const drawArgs = (ctx: CanvasRenderingContext2D) =>
- ({ ctx, rect, theme }) as unknown as Parameters[0];
-
-describe("createEnumCell draw", () => {
- it("draws an em dash for a read-only empty cell", () => {
- const { ctx, calls } = fakeCtx();
- nullableCell.renderer.draw(drawArgs(ctx), nullableCell.makeCell(null, false));
- expect(calls).toEqual(["—"]);
- });
-
- it("draws nothing for an editable empty cell", () => {
- const { ctx, calls } = fakeCtx();
- nullableCell.renderer.draw(drawArgs(ctx), nullableCell.makeCell(null));
- expect(calls).toEqual([]);
- });
-});
diff --git a/src/lib/grid/createEnumCell.tsx b/src/lib/grid/createEnumCell.tsx
deleted file mode 100644
index 513320b..0000000
--- a/src/lib/grid/createEnumCell.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid";
-import { Select, Text, Theme } from "@radix-ui/themes";
-import { gridRadixTheme } from "../../theme/radixTheme";
-import {
- createCustomCell,
- drawEmptyDash,
- makeCustomCell,
- type EditorProps,
-} from "./createCustomCell";
-import { radixColorByIndex, type RadixColor } from "./radixBadgePalette";
-import { drawSoftBadge } from "./softBadge";
-import { SoftBadge } from "./softBadgeView";
-
-/** One selectable enum value: numeric value, label, and an optional color override. */
-export interface EnumOption {
- value: number;
- label: string;
- color?: RadixColor;
-}
-
-/** Cell payload stored in the grid. `kind` routes the renderer; `value` is the
- * enum, or `null` for an unset (cleared) cell on a nullable column. */
-export interface EnumCellData {
- kind: string;
- value: number | null;
- readOnly?: boolean;
-}
-
-interface ResolvedOption {
- label: string;
- color: RadixColor;
-}
-
-export interface EnumCell {
- renderer: CustomRenderer>;
- /**
- * Build a cell for `value`. Pass `allowOverlay: false` for a read-only column:
- * the badge still renders, the `Select` editor never opens, and an empty value
- * draws as "—" instead of blank.
- */
- makeCell: (value: number | null, allowOverlay?: boolean) => CustomCell;
- /** Resolved color for a value (override or index default). Exposed for tests/reuse. */
- colorOf: (value: number) => RadixColor;
-}
-
-/** Sentinel item value for the "clear" option in a nullable Select. Radix
- * reserves the empty string for items, so a non-empty sentinel is required. */
-const NONE_VALUE = "__none__";
-
-/**
- * Build a reusable enum cell: a canvas soft-badge `draw` plus a Radix `Select`
- * editor. Colors default to radixColorByIndex(value), overridable per option.
- * Each enum column must pass a unique `kind` so glide's isMatch routes correctly.
- *
- * Call this once at module scope (not inside a React render): the editor component
- * identity is tied to this call, so re-creating it per render would remount editors.
- */
-export function createEnumCell({
- kind,
- options,
- nullable = false,
-}: {
- kind: string;
- options: EnumOption[];
- nullable?: boolean;
-}): EnumCell {
- const byValue = new Map();
-
- for (const opt of options) {
- byValue.set(opt.value, { label: opt.label, color: opt.color ?? radixColorByIndex(opt.value) });
- }
-
- const lookup = (value: number): ResolvedOption =>
- byValue.get(value) ?? { label: String(value), color: radixColorByIndex(value) };
-
- const Editor = ({ value, onFinishedEditing }: EditorProps) => {
- const handleValueChange = (next: string) => {
- onFinishedEditing({ ...value, value: next === NONE_VALUE ? null : Number(next) });
- };
-
- return (
-
-
-
- {/*
- The dropdown is PORTALLED out of the cell overlay, so glide sees a
- press on an option as a click outside the editor. Its outside-click
- handler runs on `pointerdown`, in the capture phase, which is before
- the Select decides anything on `pointerup` — so the overlay closed
- and the choice was thrown away. Only the keyboard worked, because it
- never presses a pointer.
-
- `click-outside-ignore` is glide's own escape hatch for exactly this:
- it walks up from whatever was pressed and stops if it finds the
- class. See `internal/click-outside-container`.
- */}
-
- {nullable && (
-
- None
-
- )}
- {options.map((opt) => (
-
-
-
- ))}
-
-
-
- );
- };
-
- const renderer = createCustomCell({
- kind,
- draw: (args, data) => {
- if (data.value == null) {
- if (data.readOnly) drawEmptyDash(args);
- return;
- }
-
- const { label, color } = lookup(data.value);
- // Pass the grid's base font so drawSoftBadge measures with the same font it
- // renders (glide's FullTheme exposes the composed font as `baseFontFull`).
- drawSoftBadge(args.ctx, args.rect, color, label, args.theme.baseFontFull);
- },
- editor: Editor,
- });
-
- const makeCell = (value: number | null, allowOverlay = true): CustomCell =>
- makeCustomCell(
- { kind, value, ...(allowOverlay ? {} : { readOnly: true }) },
- value == null ? "" : lookup(value).label,
- allowOverlay,
- );
-
- return { renderer, makeCell, colorOf: (value) => lookup(value).color };
-}
diff --git a/src/lib/grid/createNumberCell.test.ts b/src/lib/grid/createNumberCell.test.ts
deleted file mode 100644
index c52f73b..0000000
--- a/src/lib/grid/createNumberCell.test.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { GridCellKind } from "@glideapps/glide-data-grid";
-import { describe, expect, it } from "vitest";
-import { createNumberCell } from "./createNumberCell";
-
-const cell = createNumberCell({ kind: "num-test", min: 0, max: 100 });
-const nullableCell = createNumberCell({
- kind: "num-nullable-test",
- nullable: true,
- min: 0,
- max: 100,
-});
-
-describe("createNumberCell", () => {
- it("makeCell builds a custom cell with kind, numeric value, and string copyData", () => {
- const c = cell.makeCell(42.5);
- expect(c.kind).toBe(GridCellKind.Custom);
- expect(c.data).toEqual({ kind: "num-test", value: 42.5 });
- expect(c.copyData).toBe("42.5");
- });
-
- it("makeCell(null) builds a cleared cell with null value and empty copyData", () => {
- const c = cell.makeCell(null);
- expect(c.data).toEqual({ kind: "num-test", value: null });
- expect(c.copyData).toBe("");
- });
-
- it("the renderer matches its own cell kind and not a foreign one", () => {
- expect(cell.renderer.isMatch(cell.makeCell(1))).toBe(true);
- expect(cell.renderer.isMatch(nullableCell.makeCell(1))).toBe(false);
- });
-
- it("validate blocks empty when not nullable, allows it when nullable", () => {
- expect(cell.validate(cell.makeCell(null))).toBe(false);
- expect(nullableCell.validate(nullableCell.makeCell(null))).toBe(true);
- });
-
- it("validate accepts in-range and rejects out-of-range values", () => {
- expect(cell.validate(cell.makeCell(50))).toBe(true);
- expect(cell.validate(cell.makeCell(500))).toBe(false);
- });
-
- it("onPaste parses a separated string and rejects junk / out-of-range", () => {
- const onPaste = cell.renderer.onPaste!;
- const data = cell.makeCell(null).data;
- expect(onPaste("50", data)).toEqual({ kind: "num-test", value: 50 });
- expect(onPaste("garbage", data)).toBeUndefined();
- expect(onPaste("500", data)).toBeUndefined();
- });
-
- it("onPaste rejects empty when not nullable but clears when nullable", () => {
- expect(cell.renderer.onPaste!(" ", cell.makeCell(1).data)).toBeUndefined();
- expect(nullableCell.renderer.onPaste!(" ", nullableCell.makeCell(1).data)).toEqual({
- kind: "num-nullable-test",
- value: null,
- });
- });
-});
-
-describe("createNumberCell.makeCell allowOverlay", () => {
- it("disables the overlay when allowOverlay is false", () => {
- expect(cell.makeCell(1, false).allowOverlay).toBe(false);
- });
-
- it("allows the overlay by default", () => {
- expect(cell.makeCell(1).allowOverlay).toBe(true);
- });
-
- it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => {
- expect(cell.makeCell(1, false).data.readOnly).toBe(true);
- });
-
- it("leaves an editable cell unflagged so an empty value stays blank", () => {
- expect(cell.makeCell(1).data.readOnly).toBeUndefined();
- });
-});
-
-function fakeCtx() {
- const calls: string[] = [];
- const ctx = {
- font: "",
- fillStyle: "" as string,
- textBaseline: "alphabetic" as CanvasTextBaseline,
- fillText: (t: string) => {
- calls.push(t);
- },
- };
- return { ctx: ctx as unknown as CanvasRenderingContext2D, calls, raw: ctx };
-}
-
-const theme = {
- textDark: "#111",
- textLight: "#999",
- baseFontFull: "13px sans-serif",
- cellHorizontalPadding: 8,
-};
-const rect = { x: 0, y: 0, width: 120, height: 34 };
-const drawArgs = (ctx: CanvasRenderingContext2D) =>
- ({ ctx, rect, theme }) as unknown as Parameters[0];
-
-describe("createNumberCell draw", () => {
- it("draws an em dash for a read-only empty cell", () => {
- const { ctx, calls, raw } = fakeCtx();
- cell.renderer.draw(drawArgs(ctx), cell.makeCell(null, false));
- expect(calls).toEqual(["—"]);
- expect(raw.fillStyle).toBe("#999");
- });
-
- it("draws nothing for an editable empty cell", () => {
- const { ctx, calls } = fakeCtx();
- cell.renderer.draw(drawArgs(ctx), cell.makeCell(null));
- expect(calls).toEqual([]);
- });
-
- it("draws the formatted value for a non-empty cell", () => {
- const { ctx, calls, raw } = fakeCtx();
- cell.renderer.draw(drawArgs(ctx), cell.makeCell(42.5, false));
- expect(calls).toEqual(["42.5"]);
- expect(raw.fillStyle).toBe("#111");
- });
-});
diff --git a/src/lib/grid/createNumberCell.tsx b/src/lib/grid/createNumberCell.tsx
deleted file mode 100644
index 35341b6..0000000
--- a/src/lib/grid/createNumberCell.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid";
-import { Theme } from "@radix-ui/themes";
-import { NumericInput } from "../../components/ui/numericInput";
-import { gridRadixTheme } from "../../theme/radixTheme";
-import {
- formatNumberDisplay,
- isValueInRange,
- parseNumberPaste,
- type NumberFormat,
-} from "../number/numberUtils";
-import {
- createCustomCell,
- drawEmptyDash,
- makeCustomCell,
- type EditorProps,
-} from "./createCustomCell";
-
-/** Cell payload. `value` is a JS number, or null for an unset cell. */
-export interface NumberCellData {
- kind: string;
- value: number | null;
- readOnly?: boolean;
-}
-
-export interface NumberCell {
- renderer: CustomRenderer>;
- makeCell: (value: number | null, allowOverlay?: boolean) => CustomCell;
- validate: (cell: CustomCell) => boolean;
-}
-
-/**
- * Build a reusable numeric cell: a canvas text `draw` (formatted per options)
- * plus a `` editor. Out-of-range entry is blocked in the editor;
- * `validate` and `onPaste` gate commits and pastes. Empty is allowed only when
- * `nullable`.
- *
- * Call once at module scope (not inside a React render): the editor component
- * identity is tied to this call, so re-creating it per render would remount it.
- */
-export function createNumberCell({
- kind,
- nullable = false,
- format,
- decimalScale,
- currency,
- min,
- max,
- thousandSeparator,
- prefix,
- suffix,
-}: {
- kind: string;
- nullable?: boolean;
- format?: NumberFormat;
- decimalScale?: number;
- currency?: string;
- min?: number;
- max?: number;
- thousandSeparator?: boolean;
- prefix?: string;
- suffix?: string;
-}): NumberCell {
- const fmt = { format, decimalScale, currency, min, max, thousandSeparator, prefix, suffix };
-
- const Editor = ({ value, onChange }: EditorProps) => (
-
-
- onChange({ ...value, value: next })}
- autoFocus
- />
-
-
- );
-
- const renderer = createCustomCell({
- kind,
- draw: (args, data) => {
- if (data.value == null) {
- if (data.readOnly) drawEmptyDash(args);
- return;
- }
-
- const { ctx, rect, theme } = args;
- ctx.fillStyle = theme.textDark;
- ctx.font = theme.baseFontFull;
- ctx.textBaseline = "middle";
- ctx.fillText(
- formatNumberDisplay(data.value, fmt),
- rect.x + theme.cellHorizontalPadding,
- rect.y + rect.height / 2,
- );
- },
- editor: Editor,
- onPaste: (val, data) => {
- const parsed = parseNumberPaste(val, { min, max, nullable });
-
- if (parsed === undefined) return undefined;
-
- return { ...data, value: parsed };
- },
- });
-
- const makeCell = (value: number | null, allowOverlay = true): CustomCell =>
- makeCustomCell(
- { kind, value, ...(allowOverlay ? {} : { readOnly: true }) },
- value == null ? "" : String(value),
- allowOverlay,
- );
-
- const validate = (cell: CustomCell): boolean => {
- const v = cell.data.value;
-
- if (v == null) return nullable;
-
- return isValueInRange(v, min, max);
- };
-
- return { renderer, makeCell, validate };
-}
diff --git a/src/lib/grid/createPhoneCell.test.ts b/src/lib/grid/createPhoneCell.test.ts
deleted file mode 100644
index f85e43a..0000000
--- a/src/lib/grid/createPhoneCell.test.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { GridCellKind } from "@glideapps/glide-data-grid";
-import { describe, expect, it } from "vitest";
-import { createPhoneCell } from "./createPhoneCell";
-
-const cell = createPhoneCell({ kind: "phone-test" });
-const nullableCell = createPhoneCell({ kind: "phone-nullable-test", nullable: true });
-
-describe("createPhoneCell", () => {
- it("makeCell builds a custom cell with kind, E.164 value, and E.164 copyData", () => {
- const c = cell.makeCell("+14155552671");
- expect(c.kind).toBe(GridCellKind.Custom);
- expect(c.data).toEqual({ kind: "phone-test", value: "+14155552671" });
- expect(c.copyData).toBe("+14155552671");
- });
-
- it("makeCell(null) builds a cleared cell with null value and empty copyData", () => {
- const c = cell.makeCell(null);
- expect(c.data).toEqual({ kind: "phone-test", value: null });
- expect(c.copyData).toBe("");
- });
-
- it("the renderer matches its own cell kind and not a foreign one", () => {
- expect(cell.renderer.isMatch(cell.makeCell("+14155552671"))).toBe(true);
- expect(cell.renderer.isMatch(nullableCell.makeCell("+14155552671"))).toBe(false);
- });
-
- it("validate blocks empty when not nullable, allows it when nullable", () => {
- expect(cell.validate(cell.makeCell(null))).toBe(false);
- expect(nullableCell.validate(nullableCell.makeCell(null))).toBe(true);
- });
-
- it("validate accepts a valid number and rejects a malformed one", () => {
- expect(cell.validate(cell.makeCell("+14155552671"))).toBe(true);
- expect(cell.validate(cell.makeCell("+1234"))).toBe(false);
- });
-
- it("onPaste parses a national string to E.164 and rejects junk", () => {
- const onPaste = cell.renderer.onPaste!;
- const data = cell.makeCell(null).data;
- expect(onPaste("(415) 555-2671", data)).toEqual({ kind: "phone-test", value: "+14155552671" });
- expect(onPaste("garbage", data)).toBeUndefined();
- });
-
- it("onPaste rejects an empty paste when not nullable but clears when nullable", () => {
- expect(cell.renderer.onPaste!(" ", cell.makeCell("+14155552671").data)).toBeUndefined();
- expect(
- nullableCell.renderer.onPaste!(" ", nullableCell.makeCell("+14155552671").data),
- ).toEqual({ kind: "phone-nullable-test", value: null });
- });
-});
-
-describe("createPhoneCell.makeCell allowOverlay", () => {
- it("disables the overlay when allowOverlay is false", () => {
- expect(cell.makeCell("+14155552671", false).allowOverlay).toBe(false);
- });
-
- it("allows the overlay by default", () => {
- expect(cell.makeCell("+14155552671").allowOverlay).toBe(true);
- });
-
- it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => {
- expect(cell.makeCell(null, false).data.readOnly).toBe(true);
- });
-
- it("leaves an editable cell unflagged so an empty value stays blank", () => {
- expect(cell.makeCell("+14155552671").data.readOnly).toBeUndefined();
- });
-});
-
-function fakeCtx() {
- const calls: string[] = [];
- const ctx = {
- font: "",
- fillStyle: "" as string,
- textBaseline: "alphabetic" as CanvasTextBaseline,
- fillText: (t: string) => {
- calls.push(t);
- },
- };
- return { ctx: ctx as unknown as CanvasRenderingContext2D, calls };
-}
-
-const theme = {
- textDark: "#111",
- textLight: "#999",
- baseFontFull: "13px sans-serif",
- cellHorizontalPadding: 8,
-};
-const rect = { x: 0, y: 0, width: 120, height: 34 };
-const drawArgs = (ctx: CanvasRenderingContext2D) =>
- ({ ctx, rect, theme }) as unknown as Parameters[0];
-
-describe("createPhoneCell draw", () => {
- it("draws an em dash for a read-only empty cell", () => {
- const { ctx, calls } = fakeCtx();
- cell.renderer.draw(drawArgs(ctx), cell.makeCell(null, false));
- expect(calls).toEqual(["—"]);
- });
-
- it("draws nothing for an editable empty cell", () => {
- const { ctx, calls } = fakeCtx();
- cell.renderer.draw(drawArgs(ctx), cell.makeCell(null));
- expect(calls).toEqual([]);
- });
-});
diff --git a/src/lib/grid/createPhoneCell.tsx b/src/lib/grid/createPhoneCell.tsx
deleted file mode 100644
index 1565e1a..0000000
--- a/src/lib/grid/createPhoneCell.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid";
-import { Text, Theme } from "@radix-ui/themes";
-import PhoneInput, { type Country } from "react-phone-number-input";
-import "react-phone-number-input/style.css";
-import { gridRadixTheme } from "../../theme/radixTheme";
-import { formatPhoneDisplay, isValidPhoneValue, parsePhonePaste } from "../phone/phoneUtils";
-import {
- createCustomCell,
- drawEmptyDash,
- makeCustomCell,
- type EditorProps,
-} from "./createCustomCell";
-
-/** Cell payload. `value` is a canonical E.164 string, or null for an unset cell. */
-export interface PhoneCellData {
- kind: string;
- value: string | null;
- readOnly?: boolean;
-}
-
-export interface PhoneCell {
- renderer: CustomRenderer>;
- makeCell: (value: string | null, allowOverlay?: boolean) => CustomCell;
- validate: (cell: CustomCell) => boolean;
-}
-
-/**
- * Build a reusable phone-number cell: a canvas text `draw` (international format)
- * plus a `react-phone-number-input` editor. Stores canonical E.164 strings.
- * Empty is allowed only when `nullable`; any non-empty value must be valid.
- *
- * Call once at module scope (not inside a React render): the editor component
- * identity is tied to this call, so re-creating it per render would remount it.
- */
-export function createPhoneCell({
- kind,
- nullable = false,
- defaultCountry = "US",
-}: {
- kind: string;
- nullable?: boolean;
- defaultCountry?: Country;
-}): PhoneCell {
- const Editor = ({ value, onChange, isValid }: EditorProps) => {
- const handleChange = (next?: string) => {
- // PhoneInput emits E.164 (or undefined when cleared). Live-sync to glide;
- // glide commits this value on overlay close, gated by validateCell.
- onChange({ ...value, value: next ?? null });
- };
-
- const invalid = isValid === false;
-
- return (
-
-
-
- {invalid && (
-
- Invalid phone number
-
- )}
-
-
- );
- };
-
- const renderer = createCustomCell({
- kind,
- draw: (args, data) => {
- if (data.value == null || data.value === "") {
- if (data.readOnly) drawEmptyDash(args);
- return;
- }
-
- const { ctx, rect, theme } = args;
- ctx.fillStyle = theme.textDark;
- ctx.font = theme.baseFontFull;
- ctx.textBaseline = "middle";
- ctx.fillText(
- formatPhoneDisplay(data.value, defaultCountry),
- rect.x + theme.cellHorizontalPadding,
- rect.y + rect.height / 2,
- );
- },
- editor: Editor,
- onPaste: (val, data) => {
- const parsed = parsePhonePaste(val, defaultCountry);
-
- if (parsed === undefined) return undefined;
- if (parsed === null && !nullable) return undefined;
-
- return { ...data, value: parsed };
- },
- });
-
- const makeCell = (value: string | null, allowOverlay = true): CustomCell =>
- makeCustomCell(
- { kind, value, ...(allowOverlay ? {} : { readOnly: true }) },
- value ?? "",
- allowOverlay,
- );
-
- const validate = (cell: CustomCell): boolean =>
- isValidPhoneValue(cell.data.value, nullable);
-
- return { renderer, makeCell, validate };
-}
diff --git a/src/lib/grid/createTextCell.test.ts b/src/lib/grid/createTextCell.test.ts
deleted file mode 100644
index c976c03..0000000
--- a/src/lib/grid/createTextCell.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { GridCellKind } from "@glideapps/glide-data-grid";
-import { describe, expect, it } from "vitest";
-import { createTextCell } from "./createTextCell";
-
-const cell = createTextCell({ kind: "text-test", validation: { required: true, maxLength: 5 } });
-const optional = createTextCell({ kind: "text-optional", validation: { email: true } });
-
-describe("createTextCell", () => {
- it("makeCell builds a custom cell with kind, value, and string copyData", () => {
- const c = cell.makeCell("hi");
- expect(c.kind).toBe(GridCellKind.Custom);
- expect(c.data).toEqual({ kind: "text-test", value: "hi" });
- expect(c.copyData).toBe("hi");
- });
-
- it("makeCell(null) builds a cleared cell with null value and empty copyData", () => {
- const c = cell.makeCell(null);
- expect(c.data).toEqual({ kind: "text-test", value: null });
- expect(c.copyData).toBe("");
- });
-
- it("the renderer matches its own cell kind and not a foreign one", () => {
- expect(cell.renderer.isMatch(cell.makeCell("a"))).toBe(true);
- expect(cell.renderer.isMatch(optional.makeCell("a"))).toBe(false);
- });
-
- it("validate blocks empty when required, allows it when optional", () => {
- expect(cell.validate(cell.makeCell(null))).toBe(false);
- expect(optional.validate(optional.makeCell(null))).toBe(true);
- });
-
- it("validate enforces the configured rules (maxLength / email)", () => {
- expect(cell.validate(cell.makeCell("hello"))).toBe(true);
- expect(cell.validate(cell.makeCell("toolong"))).toBe(false);
- expect(optional.validate(optional.makeCell("a@b.co"))).toBe(true);
- expect(optional.validate(optional.makeCell("nope"))).toBe(false);
- });
-
- it("onPaste trims and stores the value, clearing to null when empty", () => {
- const onPaste = cell.renderer.onPaste!;
- const data = cell.makeCell(null).data;
- expect(onPaste(" hey ", data)).toEqual({ kind: "text-test", value: "hey" });
- expect(onPaste(" ", data)).toEqual({ kind: "text-test", value: null });
- });
-});
-
-describe("createTextCell.makeCell allowOverlay", () => {
- it("disables the overlay when allowOverlay is false", () => {
- expect(cell.makeCell("a", false).allowOverlay).toBe(false);
- });
- it("allows the overlay by default", () => {
- expect(cell.makeCell("a").allowOverlay).toBe(true);
- });
- it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => {
- expect(cell.makeCell(null, false).data.readOnly).toBe(true);
- });
- it("leaves an editable cell unflagged so an empty value stays blank", () => {
- expect(cell.makeCell("a").data.readOnly).toBeUndefined();
- });
-});
-
-function fakeCtx() {
- const calls: string[] = [];
- const ctx = {
- font: "",
- fillStyle: "" as string,
- textBaseline: "alphabetic" as CanvasTextBaseline,
- fillText: (t: string) => {
- calls.push(t);
- },
- };
- return { ctx: ctx as unknown as CanvasRenderingContext2D, calls };
-}
-
-const theme = {
- textDark: "#111",
- textLight: "#999",
- baseFontFull: "13px sans-serif",
- cellHorizontalPadding: 8,
-};
-const rect = { x: 0, y: 0, width: 120, height: 34 };
-const drawArgs = (ctx: CanvasRenderingContext2D) =>
- ({ ctx, rect, theme }) as unknown as Parameters[0];
-
-describe("createTextCell draw", () => {
- it("draws an em dash for a read-only empty cell", () => {
- const { ctx, calls } = fakeCtx();
- cell.renderer.draw(drawArgs(ctx), cell.makeCell(null, false));
- expect(calls).toEqual(["—"]);
- });
-
- it("draws nothing for an editable empty cell", () => {
- const { ctx, calls } = fakeCtx();
- cell.renderer.draw(drawArgs(ctx), cell.makeCell(null));
- expect(calls).toEqual([]);
- });
-
- it("draws the text for a non-empty cell", () => {
- const { ctx, calls } = fakeCtx();
- cell.renderer.draw(drawArgs(ctx), cell.makeCell("hi", false));
- expect(calls).toEqual(["hi"]);
- });
-});
diff --git a/src/lib/grid/createTextCell.tsx b/src/lib/grid/createTextCell.tsx
deleted file mode 100644
index 15523ee..0000000
--- a/src/lib/grid/createTextCell.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid";
-import { Text, Theme } from "@radix-ui/themes";
-import { Input } from "../../components/ui/input";
-import { gridRadixTheme } from "../../theme/radixTheme";
-import { validateText, type TextValidationOptions } from "../text/textValidation";
-import {
- createCustomCell,
- drawEmptyDash,
- makeCustomCell,
- type EditorProps,
-} from "./createCustomCell";
-
-/** Cell payload. `value` is the text string, or null for an unset cell. */
-export interface TextCellData {
- kind: string;
- value: string | null;
- readOnly?: boolean;
-}
-
-export interface TextCell {
- renderer: CustomRenderer>;
- makeCell: (value: string | null, allowOverlay?: boolean) => CustomCell;
- validate: (cell: CustomCell) => boolean;
-}
-
-/**
- * Build a reusable text cell: a canvas text `draw` plus an ` ` editor with
- * configurable validation. The editor shows the first failing rule's message;
- * `validate` gates commit; `onPaste` accepts the trimmed string (commit-gated).
- * `validation.required` plays the role of the number/phone cells' `nullable`.
- *
- * Call once at module scope (not inside a React render): the editor component
- * identity is tied to this call, so re-creating it per render would remount it.
- */
-export function createTextCell({
- kind,
- validation = {},
-}: {
- kind: string;
- validation?: TextValidationOptions;
-}): TextCell {
- const Editor = ({ value, onChange, isValid }: EditorProps) => {
- const text = value.value ?? "";
-
- const result = validateText(text, validation);
- const error = result.valid ? null : result.error;
-
- const invalid = isValid === false;
-
- return (
-
-
-
- onChange({ ...value, value: e.target.value === "" ? null : e.target.value })
- }
- autoFocus
- style={invalid ? { borderColor: "var(--red-9)" } : undefined}
- />
- {invalid && error && (
-
- {error}
-
- )}
-
-
- );
- };
-
- const renderer = createCustomCell({
- kind,
- draw: (args, data) => {
- if (data.value == null || data.value === "") {
- if (data.readOnly) drawEmptyDash(args);
- return;
- }
-
- const { ctx, rect, theme } = args;
- ctx.fillStyle = theme.textDark;
- ctx.font = theme.baseFontFull;
- ctx.textBaseline = "middle";
- ctx.fillText(data.value, rect.x + theme.cellHorizontalPadding, rect.y + rect.height / 2);
- },
- editor: Editor,
- onPaste: (val, data) => {
- const trimmed = val.trim();
- return { ...data, value: trimmed === "" ? null : trimmed };
- },
- });
-
- const makeCell = (value: string | null, allowOverlay = true): CustomCell =>
- makeCustomCell(
- { kind, value, ...(allowOverlay ? {} : { readOnly: true }) },
- value ?? "",
- allowOverlay,
- );
-
- const validate = (cell: CustomCell): boolean =>
- validateText(cell.data.value ?? "", validation).valid;
-
- return { renderer, makeCell, validate };
-}
diff --git a/src/lib/grid/createDateCell.editor.test.tsx b/src/lib/grid/dateCell.editor.test.tsx
similarity index 70%
rename from src/lib/grid/createDateCell.editor.test.tsx
rename to src/lib/grid/dateCell.editor.test.tsx
index 193e415..b991991 100644
--- a/src/lib/grid/createDateCell.editor.test.tsx
+++ b/src/lib/grid/dateCell.editor.test.tsx
@@ -4,9 +4,10 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentType } from "react";
import { describe, expect, it, vi } from "vitest";
-import { createDateCell, type DateCellData } from "./createDateCell";
+import { dateCellRenderer, makeDateCell, DATE_CELL_TYPE, type DateCellData } from "./dateCell";
-const date = createDateCell({ kind: "date", nullable: true });
+const nullable = { nullable: true };
+const nullableWithTime = { nullable: true, withTime: true };
interface EditorHandlers {
value: CustomCell;
@@ -17,7 +18,7 @@ interface EditorHandlers {
/** Mount the cell's overlay editor the way glide-data-grid does. */
function mountEditor(cell: CustomCell, handlers: Partial = {}) {
- const Editor = date.renderer.provideEditor?.(cell) as unknown as ComponentType;
+ const Editor = dateCellRenderer.provideEditor?.(cell) as unknown as ComponentType;
const props: EditorHandlers = {
value: cell,
@@ -37,7 +38,7 @@ const utcIso = (y: number, m: number, d: number) => new Date(Date.UTC(y, m - 1,
describe("date cell editor", () => {
it("shows the cell's value in the masked input", () => {
- mountEditor(date.makeCell(utcIso(2000, 1, 1), false));
+ mountEditor(makeDateCell(utcIso(2000, 1, 1), nullable));
expect(screen.getByLabelText("Date")).toHaveValue("01/01/2000");
});
@@ -47,7 +48,7 @@ describe("date cell editor", () => {
const onFinishedEditing = vi.fn();
const onChange = vi.fn();
- mountEditor(date.makeCell(null, false), { onFinishedEditing, onChange });
+ mountEditor(makeDateCell(null, nullable), { onFinishedEditing, onChange });
await user.type(screen.getByLabelText("Date"), "06202026");
expect(onFinishedEditing).not.toHaveBeenCalled();
@@ -57,9 +58,9 @@ describe("date cell editor", () => {
expect(onFinishedEditing).toHaveBeenCalledOnce();
expect(onFinishedEditing.mock.calls[0][0].data).toEqual({
- kind: "date",
+ kind: DATE_CELL_TYPE,
value: utcIso(2026, 6, 20),
- withTime: false,
+ options: nullable,
});
});
@@ -67,36 +68,35 @@ describe("date cell editor", () => {
const user = userEvent.setup();
const onFinishedEditing = vi.fn();
- mountEditor(date.makeCell(null, false), { onFinishedEditing });
+ mountEditor(makeDateCell(null, nullable), { onFinishedEditing });
await user.type(screen.getByLabelText("Date"), "06202026");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onFinishedEditing).toHaveBeenCalledExactlyOnceWith(undefined);
});
- it("offers a time input for a withTime cell", () => {
- mountEditor(date.makeCell(new Date(2026, 5, 20, 13, 30).toISOString(), true));
+ it("offers a time input when the column's options say withTime", () => {
+ mountEditor(makeDateCell(new Date(2026, 5, 20, 13, 30).toISOString(), nullableWithTime));
expect(screen.getByLabelText("Time")).toHaveValue("13:30");
});
- it("commits null from an empty value because the cell is nullable", async () => {
+ it("commits null from an empty value because the column is nullable", async () => {
const user = userEvent.setup();
const onFinishedEditing = vi.fn();
- mountEditor(date.makeCell(utcIso(2000, 1, 1), false), { onFinishedEditing });
+ mountEditor(makeDateCell(utcIso(2000, 1, 1), nullable), { onFinishedEditing });
await user.clear(screen.getByLabelText("Date"));
await user.click(screen.getByRole("button", { name: "OK" }));
expect(onFinishedEditing.mock.calls[0][0].data.value).toBeNull();
});
- it("blocks OK on an empty value when the cell is not nullable", () => {
- const required = createDateCell({ kind: "required-date", nullable: false });
- const cell = required.makeCell(null, false);
- const Editor = required.renderer.provideEditor?.(
- cell,
- ) as unknown as ComponentType>;
+ it("blocks OK on an empty value when the column is not nullable", () => {
+ const cell = makeDateCell(null, {});
+ const Editor = dateCellRenderer.provideEditor?.(cell) as unknown as ComponentType<
+ Record
+ >;
render(
diff --git a/src/lib/grid/dateCell.test.ts b/src/lib/grid/dateCell.test.ts
new file mode 100644
index 0000000..5fa00b1
--- /dev/null
+++ b/src/lib/grid/dateCell.test.ts
@@ -0,0 +1,155 @@
+import { describe, expect, it } from "vitest";
+import {
+ dateCellDef,
+ dateCellRenderer,
+ makeDateCell,
+ DATE_CELL_TYPE,
+ type DateCellData,
+} from "./dateCell";
+
+const dateOnly = { nullable: true };
+const withTime = { nullable: true, withTime: true };
+
+const validate = dateCellDef.validate!;
+
+describe("dg:date makeDateCell", () => {
+ it("stores the ISO value, the column's options, and ISO copyData", () => {
+ const cell = makeDateCell("2026-06-20T00:00:00Z", withTime);
+ expect(cell.data.kind).toBe(DATE_CELL_TYPE);
+ expect(cell.data.value).toBe("2026-06-20T00:00:00Z");
+ expect(cell.data.options.withTime).toBe(true);
+ expect(cell.copyData).toBe("2026-06-20T00:00:00Z");
+ });
+
+ it("renders a null value as empty copyData", () => {
+ const cell = makeDateCell(null, dateOnly);
+ expect(cell.data.value).toBeNull();
+ expect(cell.data.options.withTime).toBeUndefined();
+ expect(cell.copyData).toBe("");
+ });
+
+ it("disables the overlay when allowOverlay is false", () => {
+ expect(makeDateCell("2026-06-20T00:00:00Z", dateOnly, false).allowOverlay).toBe(false);
+ });
+
+ it("allows the overlay by default", () => {
+ expect(makeDateCell("2026-06-20T00:00:00Z", dateOnly).allowOverlay).toBe(true);
+ });
+
+ it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => {
+ expect(makeDateCell(null, dateOnly, false).data.readOnly).toBe(true);
+ });
+
+ it("leaves an editable cell unflagged so an empty value stays blank", () => {
+ expect(makeDateCell(null, dateOnly).data.readOnly).toBeUndefined();
+ });
+});
+
+describe("dg:date validate", () => {
+ it("accepts a valid ISO value", () => {
+ expect(validate(makeDateCell("2026-06-20T00:00:00Z", dateOnly) as never)).toBe(true);
+ });
+
+ it("accepts empty when the column is nullable", () => {
+ expect(validate(makeDateCell(null, dateOnly) as never)).toBe(true);
+ });
+
+ it("rejects empty when the column is not nullable", () => {
+ expect(validate(makeDateCell(null, {}) as never)).toBe(false);
+ });
+
+ it("rejects a malformed non-empty value", () => {
+ expect(validate(makeDateCell("garbage", dateOnly) as never)).toBe(false);
+ });
+});
+
+describe("dg:date renderer", () => {
+ it("matches cells of its kind", () => {
+ expect(dateCellRenderer.isMatch(makeDateCell("2026-06-20T00:00:00Z", dateOnly))).toBe(true);
+ });
+});
+
+describe("dg:date onPaste", () => {
+ const baseData: DateCellData = { kind: DATE_CELL_TYPE, value: null, options: dateOnly };
+
+ it("pins a pasted ISO date to UTC midnight for a date-only cell", () => {
+ expect(dateCellRenderer.onPaste?.("2026-06-20", baseData)).toEqual({
+ ...baseData,
+ value: "2026-06-20T00:00:00.000Z",
+ });
+ });
+
+ it("rejects an unparseable paste (returns undefined)", () => {
+ expect(dateCellRenderer.onPaste?.("garbage", baseData)).toBeUndefined();
+ });
+
+ it("accepts an empty paste as null because the column is nullable", () => {
+ expect(dateCellRenderer.onPaste?.(" ", baseData)).toEqual({ ...baseData, value: null });
+ });
+
+ it("rejects an empty paste when the column is not nullable", () => {
+ const strict: DateCellData = { kind: DATE_CELL_TYPE, value: null, options: {} };
+ expect(dateCellRenderer.onPaste?.(" ", strict)).toBeUndefined();
+ });
+
+ it("preserves the time component when the column is withTime", () => {
+ const timed: DateCellData = { kind: DATE_CELL_TYPE, value: null, options: withTime };
+ expect(dateCellRenderer.onPaste?.("2026-06-20T14:30:00Z", timed)).toEqual({
+ ...timed,
+ value: "2026-06-20T14:30:00.000Z",
+ });
+ });
+});
+
+describe("dg:date def", () => {
+ it("reads withTime off the column's options, not off a ctx field", () => {
+ const cell = dateCellDef.make("2026-06-20T00:00:00Z", {
+ editable: true,
+ options: withTime,
+ });
+ expect((cell as unknown as { data: DateCellData }).data.options.withTime).toBe(true);
+ });
+});
+
+function fakeCtx() {
+ const calls: string[] = [];
+ const ctx = {
+ font: "",
+ fillStyle: "" as string,
+ textBaseline: "alphabetic" as CanvasTextBaseline,
+ fillText: (t: string) => {
+ calls.push(t);
+ },
+ };
+ return { ctx: ctx as unknown as CanvasRenderingContext2D, calls };
+}
+
+const theme = {
+ textDark: "#111",
+ textLight: "#999",
+ baseFontFull: "13px sans-serif",
+ cellHorizontalPadding: 8,
+};
+const rect = { x: 0, y: 0, width: 120, height: 34 };
+const drawArgs = (ctx: CanvasRenderingContext2D) =>
+ ({ ctx, rect, theme }) as unknown as Parameters[0];
+
+describe("dg:date draw", () => {
+ it("draws an em dash for a read-only empty cell", () => {
+ const { ctx, calls } = fakeCtx();
+ dateCellRenderer.draw(drawArgs(ctx), makeDateCell(null, dateOnly, false));
+ expect(calls).toEqual(["—"]);
+ });
+
+ it("draws nothing for an editable empty cell", () => {
+ const { ctx, calls } = fakeCtx();
+ dateCellRenderer.draw(drawArgs(ctx), makeDateCell(null, dateOnly));
+ expect(calls).toEqual([]);
+ });
+
+ it("draws the localized date for a non-empty cell", () => {
+ const { ctx, calls } = fakeCtx();
+ dateCellRenderer.draw(drawArgs(ctx), makeDateCell("2026-06-20T00:00:00Z", dateOnly, false));
+ expect(calls).toEqual(["6/20/2026"]);
+ });
+});
diff --git a/src/lib/grid/dateCell.tsx b/src/lib/grid/dateCell.tsx
new file mode 100644
index 0000000..05550cf
--- /dev/null
+++ b/src/lib/grid/dateCell.tsx
@@ -0,0 +1,84 @@
+import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid";
+import { formatDateDisplay, isValidDateValue, parseDatePaste } from "../date/dateUtils";
+import type { CellTypeDef } from "./cellRegistry";
+import { createCustomCell, drawEmptyDash, makeCustomCell } from "./createCustomCell";
+import { DateCellEditor } from "./dateCellEditor";
+
+/** The `ColumnDef.type` string that selects this cell. */
+export const DATE_CELL_TYPE = "dg:date";
+
+/**
+ * Per-column settings for a `dg:date` column, written on the column's `options`.
+ *
+ * `withTime` used to sit on `ColumnDef` itself, which made one cell type's
+ * setting a field every other column carried and ignored.
+ */
+export interface DateCellOptions {
+ /** Allow an empty cell. */
+ nullable?: boolean;
+
+ /** Keep the time part instead of a floating calendar date. */
+ withTime?: boolean;
+}
+
+/** Cell payload. `value` is a canonical UTC ISO string, or null for an unset cell. */
+export interface DateCellData {
+ kind: string;
+ value: string | null;
+ readOnly?: boolean;
+ options: DateCellOptions;
+}
+
+export const dateCellRenderer: CustomRenderer> =
+ createCustomCell({
+ kind: DATE_CELL_TYPE,
+ draw: (args, data) => {
+ if (data.value == null || data.value === "") {
+ if (data.readOnly) drawEmptyDash(args);
+ return;
+ }
+
+ const { ctx, rect, theme } = args;
+ ctx.fillStyle = theme.textDark;
+ ctx.font = theme.baseFontFull;
+ ctx.textBaseline = "middle";
+ ctx.fillText(
+ formatDateDisplay(data.value, data.options.withTime ?? false),
+ rect.x + theme.cellHorizontalPadding,
+ rect.y + rect.height / 2,
+ );
+ },
+ editor: DateCellEditor,
+ onPaste: (val, data) => {
+ const parsed = parseDatePaste(val, data.options.withTime ?? false);
+
+ if (parsed === undefined) return undefined;
+ if (parsed === null && data.options.nullable !== true) return undefined;
+
+ return { ...data, value: parsed };
+ },
+ });
+
+export function makeDateCell(
+ value: string | null,
+ options: DateCellOptions,
+ allowOverlay = true,
+): CustomCell {
+ return makeCustomCell(
+ { kind: DATE_CELL_TYPE, value, options, ...(allowOverlay ? {} : { readOnly: true }) },
+ value ?? "",
+ allowOverlay,
+ );
+}
+
+export const dateCellDef: CellTypeDef = {
+ type: DATE_CELL_TYPE,
+ kind: DATE_CELL_TYPE,
+ renderer: dateCellRenderer as unknown as CustomRenderer,
+ make: (raw, ctx) =>
+ makeDateCell(raw == null ? null : String(raw), ctx.options as DateCellOptions, ctx.editable),
+ validate: (cell) => {
+ const data = cell.data as unknown as DateCellData;
+ return isValidDateValue(data.value, data.options.nullable ?? false);
+ },
+};
diff --git a/src/lib/grid/dateCellEditor.tsx b/src/lib/grid/dateCellEditor.tsx
new file mode 100644
index 0000000..b4dd9d8
--- /dev/null
+++ b/src/lib/grid/dateCellEditor.tsx
@@ -0,0 +1,25 @@
+import { Theme } from "@radix-ui/themes";
+import { DatePicker } from "../../components/ui/datePicker";
+import { gridRadixTheme } from "../../theme/radixTheme";
+import type { EditorProps } from "./createCustomCell";
+import type { DateCellData } from "./dateCell";
+
+/**
+ * The overlay editor for a `dg:date` cell.
+ *
+ * Its own file so `dateCell.tsx` exports no component.
+ *
+ * The editor HOLDS the edit: nothing reaches the cell until the user presses
+ * OK, and Cancel or Escape discards it. A click on a day is not a commit.
+ */
+export const DateCellEditor = ({ value, onFinishedEditing }: EditorProps) => (
+
+ onFinishedEditing({ ...value, value: next })}
+ onCancel={() => onFinishedEditing(undefined)}
+ />
+
+);
diff --git a/src/lib/grid/enumCell.test.ts b/src/lib/grid/enumCell.test.ts
new file mode 100644
index 0000000..f83b439
--- /dev/null
+++ b/src/lib/grid/enumCell.test.ts
@@ -0,0 +1,115 @@
+import { GridCellKind } from "@glideapps/glide-data-grid";
+import { describe, expect, it } from "vitest";
+import {
+ enumCellDef,
+ enumCellRenderer,
+ enumColorOf,
+ makeEnumCell,
+ ENUM_CELL_TYPE,
+} from "./enumCell";
+import { radixColorByIndex } from "./radixBadgePalette";
+
+const choices = [
+ { value: 1, label: "One" },
+ { value: 2, label: "Two", color: "red" as const },
+];
+
+const strict = { choices };
+const nullable = { choices, nullable: true };
+
+describe("dg:enum cell", () => {
+ it("makeEnumCell builds a custom cell with kind, value, options, and label copyData", () => {
+ const c = makeEnumCell(1, strict);
+ expect(c.kind).toBe(GridCellKind.Custom);
+ expect(c.data).toEqual({ kind: ENUM_CELL_TYPE, value: 1, options: strict });
+ expect(c.copyData).toBe("One");
+ });
+
+ it("makeEnumCell(null) builds a cleared cell with null value and empty copyData", () => {
+ const c = makeEnumCell(null, nullable);
+ expect(c.data).toEqual({ kind: ENUM_CELL_TYPE, value: null, options: nullable });
+ expect(c.copyData).toBe("");
+ });
+
+ it("one renderer serves every enum column, whatever its choices", () => {
+ expect(enumCellRenderer.isMatch(makeEnumCell(1, strict))).toBe(true);
+ expect(enumCellRenderer.isMatch(makeEnumCell(1, nullable))).toBe(true);
+ });
+});
+
+describe("enumColorOf", () => {
+ it("uses the index default when no override is given", () => {
+ expect(enumColorOf(choices, 1)).toBe(radixColorByIndex(1));
+ });
+
+ it("prefers a per-choice override over the index default", () => {
+ expect(enumColorOf(choices, 2)).toBe("red");
+ });
+
+ it("falls back to the index default for a value no choice names", () => {
+ expect(enumColorOf(choices, 9)).toBe(radixColorByIndex(9));
+ });
+});
+
+describe("dg:enum allowOverlay", () => {
+ it("disables the overlay (no Select) when allowOverlay is false", () => {
+ expect(makeEnumCell(1, strict, false).allowOverlay).toBe(false);
+ });
+
+ it("allows the overlay by default", () => {
+ expect(makeEnumCell(1, strict).allowOverlay).toBe(true);
+ });
+
+ it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => {
+ expect(makeEnumCell(null, nullable, false).data.readOnly).toBe(true);
+ });
+
+ it("leaves an editable cell unflagged so an empty value stays blank", () => {
+ expect(makeEnumCell(1, strict).data.readOnly).toBeUndefined();
+ });
+});
+
+describe("dg:enum def", () => {
+ it("make() coerces a raw row value and honours ctx.editable", () => {
+ const cell = enumCellDef.make("2", { editable: false, options: strict });
+ expect(cell.allowOverlay).toBe(false);
+ expect((cell as unknown as { data: { value: number } }).data.value).toBe(2);
+ });
+});
+
+function fakeCtx() {
+ const calls: string[] = [];
+ const ctx = {
+ font: "",
+ fillStyle: "" as string,
+ textBaseline: "alphabetic" as CanvasTextBaseline,
+ fillText: (t: string) => {
+ calls.push(t);
+ },
+ };
+ return { ctx: ctx as unknown as CanvasRenderingContext2D, calls };
+}
+
+const theme = {
+ textDark: "#111",
+ textLight: "#999",
+ baseFontFull: "13px sans-serif",
+ cellHorizontalPadding: 8,
+};
+const rect = { x: 0, y: 0, width: 160, height: 34 };
+const drawArgs = (ctx: CanvasRenderingContext2D) =>
+ ({ ctx, rect, theme }) as unknown as Parameters[0];
+
+describe("dg:enum draw", () => {
+ it("draws an em dash for a read-only empty cell", () => {
+ const { ctx, calls } = fakeCtx();
+ enumCellRenderer.draw(drawArgs(ctx), makeEnumCell(null, nullable, false));
+ expect(calls).toEqual(["—"]);
+ });
+
+ it("draws nothing for an editable empty cell", () => {
+ const { ctx, calls } = fakeCtx();
+ enumCellRenderer.draw(drawArgs(ctx), makeEnumCell(null, nullable));
+ expect(calls).toEqual([]);
+ });
+});
diff --git a/src/lib/grid/enumCell.tsx b/src/lib/grid/enumCell.tsx
new file mode 100644
index 0000000..fe06d99
--- /dev/null
+++ b/src/lib/grid/enumCell.tsx
@@ -0,0 +1,58 @@
+import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid";
+import type { CellTypeDef } from "./cellRegistry";
+import { createCustomCell, drawEmptyDash, makeCustomCell } from "./createCustomCell";
+import { resolveEnumOption, type EnumCellOptions } from "./enumChoices";
+import { EnumCellEditor } from "./enumCellEditor";
+import { drawSoftBadge } from "./softBadge";
+
+/** The `ColumnDef.type` string that selects this cell. */
+export const ENUM_CELL_TYPE = "dg:enum";
+
+export { enumColorOf, resolveEnumOption } from "./enumChoices";
+export type { EnumCellOptions, EnumOption } from "./enumChoices";
+
+/** Cell payload. `value` is the enum, or null for an unset cell. */
+export interface EnumCellData {
+ kind: string;
+ value: number | null;
+ readOnly?: boolean;
+ options: EnumCellOptions;
+}
+
+export const enumCellRenderer: CustomRenderer> =
+ createCustomCell({
+ kind: ENUM_CELL_TYPE,
+ draw: (args, data) => {
+ if (data.value == null) {
+ if (data.readOnly) drawEmptyDash(args);
+ return;
+ }
+
+ const { label, color } = resolveEnumOption(data.options.choices, data.value);
+
+ // Pass the grid's base font so drawSoftBadge measures with the same font it
+ // renders (glide's FullTheme exposes the composed font as `baseFontFull`).
+ drawSoftBadge(args.ctx, args.rect, color, label, args.theme.baseFontFull);
+ },
+ editor: EnumCellEditor,
+ });
+
+export function makeEnumCell(
+ value: number | null,
+ options: EnumCellOptions,
+ allowOverlay = true,
+): CustomCell {
+ return makeCustomCell(
+ { kind: ENUM_CELL_TYPE, value, options, ...(allowOverlay ? {} : { readOnly: true }) },
+ value == null ? "" : resolveEnumOption(options.choices, value).label,
+ allowOverlay,
+ );
+}
+
+export const enumCellDef: CellTypeDef = {
+ type: ENUM_CELL_TYPE,
+ kind: ENUM_CELL_TYPE,
+ renderer: enumCellRenderer as unknown as CustomRenderer,
+ make: (raw, ctx) =>
+ makeEnumCell(raw == null ? null : Number(raw), ctx.options as EnumCellOptions, ctx.editable),
+};
diff --git a/src/lib/grid/enumCellEditor.tsx b/src/lib/grid/enumCellEditor.tsx
new file mode 100644
index 0000000..e601bc1
--- /dev/null
+++ b/src/lib/grid/enumCellEditor.tsx
@@ -0,0 +1,65 @@
+import { Select, Text, Theme } from "@radix-ui/themes";
+import { gridRadixTheme } from "../../theme/radixTheme";
+import type { EditorProps } from "./createCustomCell";
+import type { EnumCellData } from "./enumCell";
+import { enumColorOf } from "./enumChoices";
+import { SoftBadge } from "./softBadgeView";
+
+/**
+ * Sentinel item value for the "clear" option in a nullable Select. Radix
+ * reserves the empty string for items, so a non-empty sentinel is required.
+ */
+const NONE_VALUE = "__none__";
+
+/**
+ * The overlay editor for a `dg:enum` cell.
+ *
+ * Its own file so `enumCell.tsx` exports no component. The choices come off
+ * `value.options`, so one editor serves every enum column — where each enum
+ * column used to need a `kind` of its own for glide's `isMatch` to route by.
+ */
+export const EnumCellEditor = ({ value, onFinishedEditing }: EditorProps) => {
+ const { choices, nullable = false } = value.options;
+
+ const handleValueChange = (next: string) => {
+ onFinishedEditing({ ...value, value: next === NONE_VALUE ? null : Number(next) });
+ };
+
+ return (
+
+
+
+ {/*
+ The dropdown is PORTALLED out of the cell overlay, so glide sees a
+ press on an option as a click outside the editor. Its outside-click
+ handler runs on `pointerdown`, in the capture phase, which is before
+ the Select decides anything on `pointerup` — so the overlay closed
+ and the choice was thrown away. Only the keyboard worked, because it
+ never presses a pointer.
+
+ `click-outside-ignore` is glide's own escape hatch for exactly this:
+ it walks up from whatever was pressed and stops if it finds the
+ class. See `internal/click-outside-container`.
+ */}
+
+ {nullable && (
+
+ None
+
+ )}
+ {choices.map((choice) => (
+
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/lib/grid/enumChoices.ts b/src/lib/grid/enumChoices.ts
new file mode 100644
index 0000000..7ac19e2
--- /dev/null
+++ b/src/lib/grid/enumChoices.ts
@@ -0,0 +1,63 @@
+import { radixColorByIndex, type RadixColor } from "./radixBadgePalette";
+
+/**
+ * What a `dg:enum` column can hold, and how each value is coloured.
+ *
+ * Its own module because three places need it and two of them draw: the cell,
+ * its editor, and the group header. Keeping it here is what stops
+ * `enumCell.tsx` and `enumCellEditor.tsx` importing each other's VALUES, which
+ * is a cycle a bundler is free to order the wrong way round.
+ */
+
+/** One selectable enum value: numeric value, label, and an optional color override. */
+export interface EnumOption {
+ value: number;
+ label: string;
+ color?: RadixColor;
+}
+
+/** Per-column settings for a `dg:enum` column, written on the column's `options`. */
+export interface EnumCellOptions {
+ /**
+ * Every value this column can hold. Colors default to
+ * `radixColorByIndex(value)` and are overridable per choice.
+ */
+ choices: EnumOption[];
+
+ /** Allow an empty cell, which the editor offers as a "None" item. */
+ nullable?: boolean;
+}
+
+interface ResolvedOption {
+ label: string;
+ color: RadixColor;
+}
+
+/**
+ * The label and colour one value draws with.
+ *
+ * A value no choice names is not an error: it draws as itself, in the colour
+ * its number indexes, which is more use than a blank cell when a server adds a
+ * value before the client hears about it.
+ */
+export function resolveEnumOption(choices: readonly EnumOption[], value: number): ResolvedOption {
+ const found = choices.find((c) => c.value === value);
+
+ if (found === undefined) {
+ return { label: String(value), color: radixColorByIndex(value) };
+ }
+
+ return { label: found.label, color: found.color ?? radixColorByIndex(found.value) };
+}
+
+/**
+ * The Radix scale one enum value draws in: the choice's own override, or the
+ * palette entry its value indexes.
+ *
+ * The group header reads it too. Grouping by an enum column makes the header's
+ * name take the colour of the badges under it, and both sides have to answer
+ * from the same place — see `groupColumnColor`.
+ */
+export function enumColorOf(choices: readonly EnumOption[], value: number): RadixColor {
+ return resolveEnumOption(choices, value).color;
+}
diff --git a/src/lib/grid/enumEditorClickOutside.test.tsx b/src/lib/grid/enumEditorClickOutside.test.tsx
index 638cc6a..3a1b4e1 100644
--- a/src/lib/grid/enumEditorClickOutside.test.tsx
+++ b/src/lib/grid/enumEditorClickOutside.test.tsx
@@ -1,15 +1,14 @@
import { render, screen } from "@testing-library/react";
import type { ComponentType } from "react";
import { describe, expect, it, vi } from "vitest";
-import { createEnumCell } from "./createEnumCell";
+import { enumCellRenderer, makeEnumCell } from "./enumCell";
-const cell = createEnumCell({
- kind: "enum-click-outside-test",
- options: [
+const options = {
+ choices: [
{ value: 1, label: "One" },
{ value: 2, label: "Two" },
],
-});
+};
/**
* Glide's own outside-click test, copied from
@@ -35,7 +34,7 @@ function glideWouldCloseTheEditor(target: Element | null): boolean {
function renderEditor() {
const onFinishedEditing = vi.fn();
- const provided = cell.renderer.provideEditor?.(cell.makeCell(1));
+ const provided = enumCellRenderer.provideEditor?.(makeEnumCell(1, options));
const Editor = (
typeof provided === "function" ? provided : (provided as { editor: unknown })?.editor
@@ -43,7 +42,7 @@ function renderEditor() {
render(
{}}
onFinishedEditing={onFinishedEditing}
forceEditMode
diff --git a/src/lib/grid/glideCells.test.ts b/src/lib/grid/glideCells.test.ts
new file mode 100644
index 0000000..92c3c5e
--- /dev/null
+++ b/src/lib/grid/glideCells.test.ts
@@ -0,0 +1,128 @@
+import { GridCellKind } from "@glideapps/glide-data-grid";
+import { describe, expect, it } from "vitest";
+import {
+ glideBooleanCellDef,
+ glideBubbleCellDef,
+ glideDrilldownCellDef,
+ glideImageCellDef,
+ glideMarkdownCellDef,
+ glideNumberCellDef,
+ glideTextCellDef,
+ glideUriCellDef,
+} from "./glideCells";
+
+const editable = { editable: true, options: undefined };
+const readOnly = { editable: false, options: undefined };
+
+describe("glide text", () => {
+ it("builds a plain glide TextCell", () => {
+ expect(glideTextCellDef.make("hi", editable)).toEqual({
+ kind: GridCellKind.Text,
+ data: "hi",
+ displayData: "hi",
+ allowOverlay: true,
+ readonly: false,
+ });
+ });
+
+ it("reads a null row value as an empty string, never as the text 'null'", () => {
+ expect(glideTextCellDef.make(null, editable)).toMatchObject({ data: "", displayData: "" });
+ });
+
+ it("closes the overlay for a read-only column", () => {
+ expect(glideTextCellDef.make("hi", readOnly)).toMatchObject({
+ allowOverlay: false,
+ readonly: true,
+ });
+ });
+});
+
+describe("glide number", () => {
+ it("coerces the row value and displays it", () => {
+ expect(glideNumberCellDef.make("42", editable)).toMatchObject({
+ kind: GridCellKind.Number,
+ data: 42,
+ displayData: "42",
+ });
+ });
+
+ it("reads null and empty string as no number at all", () => {
+ expect(glideNumberCellDef.make(null, editable)).toMatchObject({
+ data: undefined,
+ displayData: "",
+ });
+ expect(glideNumberCellDef.make("", editable)).toMatchObject({ data: undefined });
+ });
+});
+
+describe("glide boolean", () => {
+ it("never opens an overlay, because glide's checkbox has none", () => {
+ expect(glideBooleanCellDef.make(true, editable)).toEqual({
+ kind: GridCellKind.Boolean,
+ data: true,
+ allowOverlay: false,
+ readonly: false,
+ });
+ });
+
+ it("keeps a null row value null rather than reading it as false", () => {
+ expect(glideBooleanCellDef.make(null, editable)).toMatchObject({ data: null });
+ });
+
+ it("blocks the toggle on a read-only column", () => {
+ expect(glideBooleanCellDef.make(true, readOnly)).toMatchObject({ readonly: true });
+ });
+});
+
+describe("glide uri and markdown", () => {
+ it("builds a Uri cell", () => {
+ expect(glideUriCellDef.make("https://example.com", editable)).toMatchObject({
+ kind: GridCellKind.Uri,
+ data: "https://example.com",
+ });
+ });
+
+ it("builds a Markdown cell", () => {
+ expect(glideMarkdownCellDef.make("# hi", editable)).toMatchObject({
+ kind: GridCellKind.Markdown,
+ data: "# hi",
+ });
+ });
+});
+
+describe("glide image, bubble, and drilldown", () => {
+ it("takes a list straight from the row", () => {
+ expect(glideImageCellDef.make(["a.png", "b.png"], editable)).toMatchObject({
+ kind: GridCellKind.Image,
+ data: ["a.png", "b.png"],
+ });
+ });
+
+ it("wraps a lone value, so a one-string row field needs no mapping", () => {
+ expect(glideImageCellDef.make("a.png", editable)).toMatchObject({ data: ["a.png"] });
+ expect(glideBubbleCellDef.make("one", editable)).toMatchObject({ data: ["one"] });
+ });
+
+ it("reads a null row value as an empty list", () => {
+ expect(glideImageCellDef.make(null, editable)).toMatchObject({ data: [] });
+ });
+
+ it("drops nulls inside a list rather than drawing 'null'", () => {
+ expect(glideBubbleCellDef.make(["a", null, "b"], editable)).toMatchObject({
+ data: ["a", "b"],
+ });
+ });
+
+ it("shapes drilldown items the way glide wants them", () => {
+ expect(glideDrilldownCellDef.make(["one", "two"], editable)).toMatchObject({
+ kind: GridCellKind.Drilldown,
+ data: [{ text: "one" }, { text: "two" }],
+ });
+ });
+
+ it("never edits, whatever the column says", () => {
+ for (const def of [glideImageCellDef, glideBubbleCellDef, glideDrilldownCellDef]) {
+ expect(def.make("x", editable).allowOverlay).toBe(false);
+ }
+ });
+});
diff --git a/src/lib/grid/glideCells.ts b/src/lib/grid/glideCells.ts
new file mode 100644
index 0000000..fdaaeb3
--- /dev/null
+++ b/src/lib/grid/glideCells.ts
@@ -0,0 +1,140 @@
+import { GridCellKind, type GridCell } from "@glideapps/glide-data-grid";
+import type { CellTypeDef } from "./cellRegistry";
+
+/**
+ * Glide's own cell kinds, reachable from a `ColumnDef` by glide's own name.
+ *
+ * A column that writes `type: "text"` gets a plain glide `TextCell` — no
+ * renderer of ours, no editor of ours, nothing to register. That is the point:
+ * the engine's five `dg:` cells are additions to glide's set, never a
+ * replacement for it, and a host that wants glide's boolean should not have to
+ * rebuild it as a custom cell first.
+ *
+ * The names here are glide's, unprefixed, which is exactly why every cell this
+ * package DRAWS is prefixed `dg:`. The two sets never collide, and glide may add
+ * kinds to its own set without ever colliding with ours.
+ *
+ * Absent are glide's internal kinds — `custom`, `loading`, `protected`, and
+ * `row-id`. They describe a cell's STATE rather than a column's data, and the
+ * engine already answers `loading` itself for a row it has not fetched.
+ */
+
+/**
+ * Read a value that may be one item or many as a list.
+ *
+ * `image`, `bubble`, and `drilldown` are the only cells whose row field is a
+ * list, and a server field like `avatarUrl` is one string. Wrapping a lone
+ * value here is what stops every such host mapping `[row.avatarUrl]` by hand on
+ * every row.
+ */
+function asStringArray(raw: unknown): string[] {
+ if (raw == null) return [];
+ if (Array.isArray(raw)) return raw.filter((v) => v != null).map((v) => String(v));
+
+ return [String(raw)];
+}
+
+function asText(raw: unknown): string {
+ return raw == null ? "" : String(raw);
+}
+
+export const glideTextCellDef: CellTypeDef = {
+ type: "text",
+ make: (raw, ctx): GridCell => ({
+ kind: GridCellKind.Text,
+ data: asText(raw),
+ displayData: asText(raw),
+ allowOverlay: ctx.editable,
+ readonly: !ctx.editable,
+ }),
+};
+
+export const glideNumberCellDef: CellTypeDef = {
+ type: "number",
+ make: (raw, ctx): GridCell => {
+ const value = raw == null || raw === "" ? undefined : Number(raw);
+
+ return {
+ kind: GridCellKind.Number,
+ data: value,
+ displayData: value === undefined || Number.isNaN(value) ? "" : String(value),
+ allowOverlay: ctx.editable,
+ readonly: !ctx.editable,
+ };
+ },
+};
+
+export const glideBooleanCellDef: CellTypeDef = {
+ type: "boolean",
+ make: (raw, ctx): GridCell => ({
+ kind: GridCellKind.Boolean,
+ data: raw == null ? null : Boolean(raw),
+
+ // Glide's boolean is a checkbox drawn in the cell, toggled by a click on
+ // the cell itself. It has no overlay editor at all, so `allowOverlay` is
+ // false whether or not the column is editable; `readonly` is what decides
+ // whether the click does anything.
+ allowOverlay: false,
+ readonly: !ctx.editable,
+ }),
+};
+
+export const glideUriCellDef: CellTypeDef = {
+ type: "uri",
+ make: (raw, ctx): GridCell => ({
+ kind: GridCellKind.Uri,
+ data: asText(raw),
+ allowOverlay: ctx.editable,
+ readonly: !ctx.editable,
+ }),
+};
+
+export const glideMarkdownCellDef: CellTypeDef = {
+ type: "markdown",
+ make: (raw, ctx): GridCell => ({
+ kind: GridCellKind.Markdown,
+ data: asText(raw),
+ allowOverlay: ctx.editable,
+ readonly: !ctx.editable,
+ }),
+};
+
+export const glideImageCellDef: CellTypeDef = {
+ type: "image",
+ make: (raw): GridCell => ({
+ kind: GridCellKind.Image,
+ data: asStringArray(raw),
+ allowOverlay: false,
+ readonly: true,
+ }),
+};
+
+export const glideBubbleCellDef: CellTypeDef = {
+ type: "bubble",
+ make: (raw): GridCell => ({
+ kind: GridCellKind.Bubble,
+ data: asStringArray(raw),
+ allowOverlay: false,
+ }),
+};
+
+export const glideDrilldownCellDef: CellTypeDef = {
+ type: "drilldown",
+ make: (raw): GridCell => ({
+ kind: GridCellKind.Drilldown,
+ data: asStringArray(raw).map((text) => ({ text })),
+ allowOverlay: false,
+ }),
+};
+
+/** Every glide kind a column may name, in one list. */
+export const glideCellDefs: CellTypeDef[] = [
+ glideTextCellDef,
+ glideNumberCellDef,
+ glideBooleanCellDef,
+ glideUriCellDef,
+ glideMarkdownCellDef,
+ glideImageCellDef,
+ glideBubbleCellDef,
+ glideDrilldownCellDef,
+];
diff --git a/src/lib/grid/numberCell.test.ts b/src/lib/grid/numberCell.test.ts
new file mode 100644
index 0000000..39a0a8c
--- /dev/null
+++ b/src/lib/grid/numberCell.test.ts
@@ -0,0 +1,137 @@
+import { GridCellKind } from "@glideapps/glide-data-grid";
+import { describe, expect, it } from "vitest";
+import { makeNumberCell, numberCellDef, numberCellRenderer, NUMBER_CELL_TYPE } from "./numberCell";
+
+const bounded = { min: 0, max: 100 };
+const nullable = { min: 0, max: 100, nullable: true };
+
+const validate = numberCellDef.validate!;
+
+describe("dg:number cell", () => {
+ it("makeNumberCell builds a custom cell with kind, value, options, and string copyData", () => {
+ const c = makeNumberCell(42.5, bounded);
+ expect(c.kind).toBe(GridCellKind.Custom);
+ expect(c.data).toEqual({ kind: NUMBER_CELL_TYPE, value: 42.5, options: bounded });
+ expect(c.copyData).toBe("42.5");
+ });
+
+ it("makeNumberCell(null) builds a cleared cell with null value and empty copyData", () => {
+ const c = makeNumberCell(null, bounded);
+ expect(c.data).toEqual({ kind: NUMBER_CELL_TYPE, value: null, options: bounded });
+ expect(c.copyData).toBe("");
+ });
+
+ it("one renderer serves every number column, whatever its options", () => {
+ expect(numberCellRenderer.isMatch(makeNumberCell(1, bounded))).toBe(true);
+ expect(numberCellRenderer.isMatch(makeNumberCell(1, nullable))).toBe(true);
+ });
+
+ it("validate blocks empty when not nullable, allows it when nullable", () => {
+ expect(validate(makeNumberCell(null, bounded) as never)).toBe(false);
+ expect(validate(makeNumberCell(null, nullable) as never)).toBe(true);
+ });
+
+ it("validate accepts in-range and rejects out-of-range values", () => {
+ expect(validate(makeNumberCell(50, bounded) as never)).toBe(true);
+ expect(validate(makeNumberCell(500, bounded) as never)).toBe(false);
+ });
+
+ it("onPaste parses a separated string and rejects junk / out-of-range", () => {
+ const onPaste = numberCellRenderer.onPaste!;
+ const data = makeNumberCell(null, bounded).data;
+ expect(onPaste("50", data)).toEqual({ ...data, value: 50 });
+ expect(onPaste("garbage", data)).toBeUndefined();
+ expect(onPaste("500", data)).toBeUndefined();
+ });
+
+ it("onPaste rejects empty when not nullable but clears when nullable", () => {
+ const onPaste = numberCellRenderer.onPaste!;
+ expect(onPaste(" ", makeNumberCell(1, bounded).data)).toBeUndefined();
+
+ const nullableData = makeNumberCell(1, nullable).data;
+ expect(onPaste(" ", nullableData)).toEqual({ ...nullableData, value: null });
+ });
+});
+
+describe("dg:number allowOverlay", () => {
+ it("disables the overlay when allowOverlay is false", () => {
+ expect(makeNumberCell(1, bounded, false).allowOverlay).toBe(false);
+ });
+
+ it("allows the overlay by default", () => {
+ expect(makeNumberCell(1, bounded).allowOverlay).toBe(true);
+ });
+
+ it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => {
+ expect(makeNumberCell(1, bounded, false).data.readOnly).toBe(true);
+ });
+
+ it("leaves an editable cell unflagged so an empty value stays blank", () => {
+ expect(makeNumberCell(1, bounded).data.readOnly).toBeUndefined();
+ });
+});
+
+describe("dg:number def", () => {
+ it("make() coerces a raw row value and honours ctx.editable", () => {
+ const cell = numberCellDef.make("42.5", { editable: false, options: bounded });
+ expect(cell.allowOverlay).toBe(false);
+ expect((cell as unknown as { data: { value: number } }).data.value).toBe(42.5);
+ });
+
+ it("make() reads a null row value as an empty cell", () => {
+ const cell = numberCellDef.make(null, { editable: true, options: bounded });
+ expect((cell as unknown as { data: { value: null } }).data.value).toBeNull();
+ });
+});
+
+function fakeCtx() {
+ const calls: string[] = [];
+ const ctx = {
+ font: "",
+ fillStyle: "" as string,
+ textBaseline: "alphabetic" as CanvasTextBaseline,
+ fillText: (t: string) => {
+ calls.push(t);
+ },
+ };
+ return { ctx: ctx as unknown as CanvasRenderingContext2D, calls, raw: ctx };
+}
+
+const theme = {
+ textDark: "#111",
+ textLight: "#999",
+ baseFontFull: "13px sans-serif",
+ cellHorizontalPadding: 8,
+};
+const rect = { x: 0, y: 0, width: 120, height: 34 };
+const drawArgs = (ctx: CanvasRenderingContext2D) =>
+ ({ ctx, rect, theme }) as unknown as Parameters[0];
+
+describe("dg:number draw", () => {
+ it("draws an em dash for a read-only empty cell", () => {
+ const { ctx, calls, raw } = fakeCtx();
+ numberCellRenderer.draw(drawArgs(ctx), makeNumberCell(null, bounded, false));
+ expect(calls).toEqual(["—"]);
+ expect(raw.fillStyle).toBe("#999");
+ });
+
+ it("draws nothing for an editable empty cell", () => {
+ const { ctx, calls } = fakeCtx();
+ numberCellRenderer.draw(drawArgs(ctx), makeNumberCell(null, bounded));
+ expect(calls).toEqual([]);
+ });
+
+ it("draws the formatted value for a non-empty cell", () => {
+ const { ctx, calls, raw } = fakeCtx();
+ numberCellRenderer.draw(drawArgs(ctx), makeNumberCell(42.5, bounded, false));
+ expect(calls).toEqual(["42.5"]);
+ expect(raw.fillStyle).toBe("#111");
+ });
+
+ it("draws each column's own format, from the cell's own options", () => {
+ const { ctx, calls } = fakeCtx();
+ const currency = { format: "currency" as const, currency: "USD", decimalScale: 2 };
+ numberCellRenderer.draw(drawArgs(ctx), makeNumberCell(1234.5, currency, false));
+ expect(calls[0]).toContain("1,234.50");
+ });
+});
diff --git a/src/lib/grid/numberCell.tsx b/src/lib/grid/numberCell.tsx
new file mode 100644
index 0000000..c23203a
--- /dev/null
+++ b/src/lib/grid/numberCell.tsx
@@ -0,0 +1,91 @@
+import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid";
+import {
+ formatNumberDisplay,
+ isValueInRange,
+ parseNumberPaste,
+ type NumberFormatOptions,
+} from "../number/numberUtils";
+import type { CellTypeDef } from "./cellRegistry";
+import { createCustomCell, drawEmptyDash, makeCustomCell } from "./createCustomCell";
+import { NumberCellEditor } from "./numberCellEditor";
+
+/** The `ColumnDef.type` string that selects this cell. */
+export const NUMBER_CELL_TYPE = "dg:number";
+
+/**
+ * Per-column settings for a `dg:number` column, written on the column's
+ * `options`. Everything the display formatter takes, plus whether an empty cell
+ * is allowed.
+ */
+export interface NumberCellOptions extends NumberFormatOptions {
+ nullable?: boolean;
+}
+
+/** Cell payload. `value` is a JS number, or null for an unset cell. */
+export interface NumberCellData {
+ kind: string;
+ value: number | null;
+ readOnly?: boolean;
+ options: NumberCellOptions;
+}
+
+export const numberCellRenderer: CustomRenderer> =
+ createCustomCell({
+ kind: NUMBER_CELL_TYPE,
+ draw: (args, data) => {
+ if (data.value == null) {
+ if (data.readOnly) drawEmptyDash(args);
+ return;
+ }
+
+ const { ctx, rect, theme } = args;
+ ctx.fillStyle = theme.textDark;
+ ctx.font = theme.baseFontFull;
+ ctx.textBaseline = "middle";
+ ctx.fillText(
+ formatNumberDisplay(data.value, data.options),
+ rect.x + theme.cellHorizontalPadding,
+ rect.y + rect.height / 2,
+ );
+ },
+ editor: NumberCellEditor,
+ onPaste: (val, data) => {
+ const { min, max, nullable = false } = data.options;
+ const parsed = parseNumberPaste(val, { min, max, nullable });
+
+ if (parsed === undefined) return undefined;
+
+ return { ...data, value: parsed };
+ },
+ });
+
+export function makeNumberCell(
+ value: number | null,
+ options: NumberCellOptions,
+ allowOverlay = true,
+): CustomCell {
+ return makeCustomCell(
+ { kind: NUMBER_CELL_TYPE, value, options, ...(allowOverlay ? {} : { readOnly: true }) },
+ value == null ? "" : String(value),
+ allowOverlay,
+ );
+}
+
+export const numberCellDef: CellTypeDef = {
+ type: NUMBER_CELL_TYPE,
+ kind: NUMBER_CELL_TYPE,
+ renderer: numberCellRenderer as unknown as CustomRenderer,
+ make: (raw, ctx) =>
+ makeNumberCell(
+ raw == null ? null : Number(raw),
+ ctx.options as NumberCellOptions,
+ ctx.editable,
+ ),
+ validate: (cell) => {
+ const data = cell.data as unknown as NumberCellData;
+
+ if (data.value == null) return data.options.nullable === true;
+
+ return isValueInRange(data.value, data.options.min, data.options.max);
+ },
+};
diff --git a/src/lib/grid/numberCellEditor.tsx b/src/lib/grid/numberCellEditor.tsx
new file mode 100644
index 0000000..0fa1527
--- /dev/null
+++ b/src/lib/grid/numberCellEditor.tsx
@@ -0,0 +1,24 @@
+import { Theme } from "@radix-ui/themes";
+import { NumericInput } from "../../components/ui/numericInput";
+import { gridRadixTheme } from "../../theme/radixTheme";
+import type { EditorProps } from "./createCustomCell";
+import type { NumberCellData } from "./numberCell";
+
+/**
+ * The overlay editor for a `dg:number` cell.
+ *
+ * Its own file so `numberCell.tsx` exports no component. The format and the
+ * range come off `value.options`, so one editor serves every number column.
+ */
+export const NumberCellEditor = ({ value, onChange }: EditorProps) => (
+
+
+ onChange({ ...value, value: next })}
+ autoFocus
+ />
+
+
+);
diff --git a/src/lib/grid/phoneCell.test.ts b/src/lib/grid/phoneCell.test.ts
new file mode 100644
index 0000000..cf839e6
--- /dev/null
+++ b/src/lib/grid/phoneCell.test.ts
@@ -0,0 +1,114 @@
+import { GridCellKind } from "@glideapps/glide-data-grid";
+import { describe, expect, it } from "vitest";
+import { makePhoneCell, phoneCellDef, phoneCellRenderer, PHONE_CELL_TYPE } from "./phoneCell";
+
+const strict = {};
+const nullable = { nullable: true };
+
+const validate = phoneCellDef.validate!;
+
+describe("dg:phone cell", () => {
+ it("makePhoneCell builds a custom cell with kind, E.164 value, and E.164 copyData", () => {
+ const c = makePhoneCell("+14155552671", strict);
+ expect(c.kind).toBe(GridCellKind.Custom);
+ expect(c.data).toEqual({ kind: PHONE_CELL_TYPE, value: "+14155552671", options: strict });
+ expect(c.copyData).toBe("+14155552671");
+ });
+
+ it("makePhoneCell(null) builds a cleared cell with null value and empty copyData", () => {
+ const c = makePhoneCell(null, strict);
+ expect(c.data).toEqual({ kind: PHONE_CELL_TYPE, value: null, options: strict });
+ expect(c.copyData).toBe("");
+ });
+
+ it("one renderer serves every phone column, whatever its options", () => {
+ expect(phoneCellRenderer.isMatch(makePhoneCell("+14155552671", strict))).toBe(true);
+ expect(phoneCellRenderer.isMatch(makePhoneCell("+14155552671", nullable))).toBe(true);
+ });
+
+ it("validate blocks empty when not nullable, allows it when nullable", () => {
+ expect(validate(makePhoneCell(null, strict) as never)).toBe(false);
+ expect(validate(makePhoneCell(null, nullable) as never)).toBe(true);
+ });
+
+ it("validate accepts a valid number and rejects a malformed one", () => {
+ expect(validate(makePhoneCell("+14155552671", strict) as never)).toBe(true);
+ expect(validate(makePhoneCell("+1234", strict) as never)).toBe(false);
+ });
+
+ it("onPaste parses a national string to E.164 and rejects junk", () => {
+ const onPaste = phoneCellRenderer.onPaste!;
+ const data = makePhoneCell(null, strict).data;
+ expect(onPaste("(415) 555-2671", data)).toEqual({ ...data, value: "+14155552671" });
+ expect(onPaste("garbage", data)).toBeUndefined();
+ });
+
+ it("onPaste rejects an empty paste when not nullable but clears when nullable", () => {
+ const onPaste = phoneCellRenderer.onPaste!;
+ expect(onPaste(" ", makePhoneCell("+14155552671", strict).data)).toBeUndefined();
+
+ const nullableData = makePhoneCell("+14155552671", nullable).data;
+ expect(onPaste(" ", nullableData)).toEqual({ ...nullableData, value: null });
+ });
+
+ it("parses a national string against the column's own defaultCountry", () => {
+ const onPaste = phoneCellRenderer.onPaste!;
+ const gb = makePhoneCell(null, { defaultCountry: "GB" as const }).data;
+ expect(onPaste("020 7946 0958", gb)).toEqual({ ...gb, value: "+442079460958" });
+ });
+});
+
+describe("dg:phone allowOverlay", () => {
+ it("disables the overlay when allowOverlay is false", () => {
+ expect(makePhoneCell("+14155552671", strict, false).allowOverlay).toBe(false);
+ });
+
+ it("allows the overlay by default", () => {
+ expect(makePhoneCell("+14155552671", strict).allowOverlay).toBe(true);
+ });
+
+ it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => {
+ expect(makePhoneCell(null, strict, false).data.readOnly).toBe(true);
+ });
+
+ it("leaves an editable cell unflagged so an empty value stays blank", () => {
+ expect(makePhoneCell("+14155552671", strict).data.readOnly).toBeUndefined();
+ });
+});
+
+function fakeCtx() {
+ const calls: string[] = [];
+ const ctx = {
+ font: "",
+ fillStyle: "" as string,
+ textBaseline: "alphabetic" as CanvasTextBaseline,
+ fillText: (t: string) => {
+ calls.push(t);
+ },
+ };
+ return { ctx: ctx as unknown as CanvasRenderingContext2D, calls };
+}
+
+const theme = {
+ textDark: "#111",
+ textLight: "#999",
+ baseFontFull: "13px sans-serif",
+ cellHorizontalPadding: 8,
+};
+const rect = { x: 0, y: 0, width: 120, height: 34 };
+const drawArgs = (ctx: CanvasRenderingContext2D) =>
+ ({ ctx, rect, theme }) as unknown as Parameters[0];
+
+describe("dg:phone draw", () => {
+ it("draws an em dash for a read-only empty cell", () => {
+ const { ctx, calls } = fakeCtx();
+ phoneCellRenderer.draw(drawArgs(ctx), makePhoneCell(null, strict, false));
+ expect(calls).toEqual(["—"]);
+ });
+
+ it("draws nothing for an editable empty cell", () => {
+ const { ctx, calls } = fakeCtx();
+ phoneCellRenderer.draw(drawArgs(ctx), makePhoneCell(null, strict));
+ expect(calls).toEqual([]);
+ });
+});
diff --git a/src/lib/grid/phoneCell.tsx b/src/lib/grid/phoneCell.tsx
new file mode 100644
index 0000000..27c5caf
--- /dev/null
+++ b/src/lib/grid/phoneCell.tsx
@@ -0,0 +1,87 @@
+import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid";
+import type { Country } from "react-phone-number-input";
+import {
+ DEFAULT_PHONE_COUNTRY,
+ formatPhoneDisplay,
+ isValidPhoneValue,
+ parsePhonePaste,
+} from "../phone/phoneUtils";
+import type { CellTypeDef } from "./cellRegistry";
+import { createCustomCell, drawEmptyDash, makeCustomCell } from "./createCustomCell";
+import { PhoneCellEditor } from "./phoneCellEditor";
+
+/** The `ColumnDef.type` string that selects this cell. */
+export const PHONE_CELL_TYPE = "dg:phone";
+
+export { DEFAULT_PHONE_COUNTRY } from "../phone/phoneUtils";
+
+/** Per-column settings for a `dg:phone` column, written on the column's `options`. */
+export interface PhoneCellOptions {
+ /** Allow an empty cell. Any non-empty value must still be a valid number. */
+ nullable?: boolean;
+
+ /** Country assumed for a number typed or pasted without a `+` prefix. */
+ defaultCountry?: Country;
+}
+
+/** Cell payload. `value` is a canonical E.164 string, or null for an unset cell. */
+export interface PhoneCellData {
+ kind: string;
+ value: string | null;
+ readOnly?: boolean;
+ options: PhoneCellOptions;
+}
+
+export const phoneCellRenderer: CustomRenderer> =
+ createCustomCell({
+ kind: PHONE_CELL_TYPE,
+ draw: (args, data) => {
+ if (data.value == null || data.value === "") {
+ if (data.readOnly) drawEmptyDash(args);
+ return;
+ }
+
+ const { ctx, rect, theme } = args;
+ ctx.fillStyle = theme.textDark;
+ ctx.font = theme.baseFontFull;
+ ctx.textBaseline = "middle";
+ ctx.fillText(
+ formatPhoneDisplay(data.value, data.options.defaultCountry ?? DEFAULT_PHONE_COUNTRY),
+ rect.x + theme.cellHorizontalPadding,
+ rect.y + rect.height / 2,
+ );
+ },
+ editor: PhoneCellEditor,
+ onPaste: (val, data) => {
+ const parsed = parsePhonePaste(val, data.options.defaultCountry ?? DEFAULT_PHONE_COUNTRY);
+
+ if (parsed === undefined) return undefined;
+ if (parsed === null && data.options.nullable !== true) return undefined;
+
+ return { ...data, value: parsed };
+ },
+ });
+
+export function makePhoneCell(
+ value: string | null,
+ options: PhoneCellOptions,
+ allowOverlay = true,
+): CustomCell {
+ return makeCustomCell(
+ { kind: PHONE_CELL_TYPE, value, options, ...(allowOverlay ? {} : { readOnly: true }) },
+ value ?? "",
+ allowOverlay,
+ );
+}
+
+export const phoneCellDef: CellTypeDef = {
+ type: PHONE_CELL_TYPE,
+ kind: PHONE_CELL_TYPE,
+ renderer: phoneCellRenderer as unknown as CustomRenderer,
+ make: (raw, ctx) =>
+ makePhoneCell(raw == null ? null : String(raw), ctx.options as PhoneCellOptions, ctx.editable),
+ validate: (cell) => {
+ const data = cell.data as unknown as PhoneCellData;
+ return isValidPhoneValue(data.value, data.options.nullable ?? false);
+ },
+};
diff --git a/src/lib/grid/phoneCellEditor.tsx b/src/lib/grid/phoneCellEditor.tsx
new file mode 100644
index 0000000..2a8ef99
--- /dev/null
+++ b/src/lib/grid/phoneCellEditor.tsx
@@ -0,0 +1,49 @@
+import { Text, Theme } from "@radix-ui/themes";
+import PhoneInput from "react-phone-number-input";
+import "react-phone-number-input/style.css";
+import { gridRadixTheme } from "../../theme/radixTheme";
+import type { EditorProps } from "./createCustomCell";
+import { DEFAULT_PHONE_COUNTRY } from "../phone/phoneUtils";
+import type { PhoneCellData } from "./phoneCell";
+
+/**
+ * The overlay editor for a `dg:phone` cell.
+ *
+ * Its own file so `phoneCell.tsx` exports no component.
+ */
+export const PhoneCellEditor = ({ value, onChange, isValid }: EditorProps) => {
+ const handleChange = (next?: string) => {
+ // PhoneInput emits E.164 (or undefined when cleared). Live-sync to glide;
+ // glide commits this value on overlay close, gated by validateCell.
+ onChange({ ...value, value: next ?? null });
+ };
+
+ const invalid = isValid === false;
+
+ return (
+
+
+
+ {invalid && (
+
+ Invalid phone number
+
+ )}
+
+
+ );
+};
diff --git a/src/lib/grid/textCell.test.ts b/src/lib/grid/textCell.test.ts
new file mode 100644
index 0000000..95e4f6c
--- /dev/null
+++ b/src/lib/grid/textCell.test.ts
@@ -0,0 +1,126 @@
+import { GridCellKind } from "@glideapps/glide-data-grid";
+import { describe, expect, it } from "vitest";
+import { makeNumberCell } from "./numberCell";
+import { makeTextCell, textCellDef, textCellRenderer, TEXT_CELL_TYPE } from "./textCell";
+
+const strict = { required: true, maxLength: 5 };
+const optional = { email: true };
+
+const validate = textCellDef.validate!;
+
+describe("dg:text cell", () => {
+ it("makeTextCell builds a custom cell with kind, value, options, and string copyData", () => {
+ const c = makeTextCell("hi", strict);
+ expect(c.kind).toBe(GridCellKind.Custom);
+ expect(c.data).toEqual({ kind: TEXT_CELL_TYPE, value: "hi", options: strict });
+ expect(c.copyData).toBe("hi");
+ });
+
+ it("makeTextCell(null) builds a cleared cell with null value and empty copyData", () => {
+ const c = makeTextCell(null, strict);
+ expect(c.data).toEqual({ kind: TEXT_CELL_TYPE, value: null, options: strict });
+ expect(c.copyData).toBe("");
+ });
+
+ it("one renderer serves every text column, whatever its options", () => {
+ expect(textCellRenderer.isMatch(makeTextCell("a", strict))).toBe(true);
+ expect(textCellRenderer.isMatch(makeTextCell("a", optional))).toBe(true);
+ });
+
+ it("the renderer does not match another cell type", () => {
+ expect(textCellRenderer.isMatch(makeNumberCell(1, {}) as never)).toBe(false);
+ });
+
+ it("validate reads the rules off the cell, not off a factory closure", () => {
+ expect(validate(makeTextCell(null, strict) as never)).toBe(false);
+ expect(validate(makeTextCell(null, optional) as never)).toBe(true);
+ });
+
+ it("validate enforces the configured rules (maxLength / email)", () => {
+ expect(validate(makeTextCell("hello", strict) as never)).toBe(true);
+ expect(validate(makeTextCell("toolong", strict) as never)).toBe(false);
+ expect(validate(makeTextCell("a@b.co", optional) as never)).toBe(true);
+ expect(validate(makeTextCell("nope", optional) as never)).toBe(false);
+ });
+
+ it("onPaste trims and stores the value, clearing to null when empty", () => {
+ const onPaste = textCellRenderer.onPaste!;
+ const data = makeTextCell(null, strict).data;
+ expect(onPaste(" hey ", data)).toEqual({ ...data, value: "hey" });
+ expect(onPaste(" ", data)).toEqual({ ...data, value: null });
+ });
+});
+
+describe("dg:text allowOverlay", () => {
+ it("disables the overlay when allowOverlay is false", () => {
+ expect(makeTextCell("a", strict, false).allowOverlay).toBe(false);
+ });
+
+ it("allows the overlay by default", () => {
+ expect(makeTextCell("a", strict).allowOverlay).toBe(true);
+ });
+
+ it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => {
+ expect(makeTextCell(null, strict, false).data.readOnly).toBe(true);
+ });
+
+ it("leaves an editable cell unflagged so an empty value stays blank", () => {
+ expect(makeTextCell("a", strict).data.readOnly).toBeUndefined();
+ });
+});
+
+describe("dg:text def", () => {
+ it("make() coerces a raw row value and honours ctx.editable", () => {
+ const cell = textCellDef.make(42, { editable: false, options: strict });
+ expect(cell.allowOverlay).toBe(false);
+ expect((cell as unknown as { data: { value: string } }).data.value).toBe("42");
+ });
+
+ it("make() reads a null row value as an empty cell", () => {
+ const cell = textCellDef.make(null, { editable: true, options: strict });
+ expect((cell as unknown as { data: { value: null } }).data.value).toBeNull();
+ });
+});
+
+function fakeCtx() {
+ const calls: string[] = [];
+ const ctx = {
+ font: "",
+ fillStyle: "" as string,
+ textBaseline: "alphabetic" as CanvasTextBaseline,
+ fillText: (t: string) => {
+ calls.push(t);
+ },
+ };
+ return { ctx: ctx as unknown as CanvasRenderingContext2D, calls };
+}
+
+const theme = {
+ textDark: "#111",
+ textLight: "#999",
+ baseFontFull: "13px sans-serif",
+ cellHorizontalPadding: 8,
+};
+const rect = { x: 0, y: 0, width: 120, height: 34 };
+const drawArgs = (ctx: CanvasRenderingContext2D) =>
+ ({ ctx, rect, theme }) as unknown as Parameters[0];
+
+describe("dg:text draw", () => {
+ it("draws an em dash for a read-only empty cell", () => {
+ const { ctx, calls } = fakeCtx();
+ textCellRenderer.draw(drawArgs(ctx), makeTextCell(null, strict, false));
+ expect(calls).toEqual(["—"]);
+ });
+
+ it("draws nothing for an editable empty cell", () => {
+ const { ctx, calls } = fakeCtx();
+ textCellRenderer.draw(drawArgs(ctx), makeTextCell(null, strict));
+ expect(calls).toEqual([]);
+ });
+
+ it("draws the text for a non-empty cell", () => {
+ const { ctx, calls } = fakeCtx();
+ textCellRenderer.draw(drawArgs(ctx), makeTextCell("hi", strict, false));
+ expect(calls).toEqual(["hi"]);
+ });
+});
diff --git a/src/lib/grid/textCell.tsx b/src/lib/grid/textCell.tsx
new file mode 100644
index 0000000..36e766c
--- /dev/null
+++ b/src/lib/grid/textCell.tsx
@@ -0,0 +1,78 @@
+import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid";
+import { validateText, type TextValidationOptions } from "../text/textValidation";
+import type { CellTypeDef } from "./cellRegistry";
+import { createCustomCell, drawEmptyDash, makeCustomCell } from "./createCustomCell";
+import { TextCellEditor } from "./textCellEditor";
+
+/** The `ColumnDef.type` string that selects this cell. */
+export const TEXT_CELL_TYPE = "dg:text";
+
+/**
+ * Per-column settings for a `dg:text` column, written on the column's `options`.
+ *
+ * They are the same declarative rules `` takes, so a text column
+ * and a text field in the host's own form validate identically.
+ * `required` plays the role the number and phone cells call `nullable`.
+ */
+export type TextCellOptions = TextValidationOptions;
+
+/**
+ * Cell payload. `value` is the text string, or null for an unset cell.
+ *
+ * `options` travels WITH the cell rather than living in a factory closure. That
+ * is what lets one module-scope renderer serve every text column in the app:
+ * the draw, the editor, and the validator all read the column's settings off
+ * the cell they were handed.
+ */
+export interface TextCellData {
+ kind: string;
+ value: string | null;
+ readOnly?: boolean;
+ options: TextCellOptions;
+}
+
+export const textCellRenderer: CustomRenderer> =
+ createCustomCell({
+ kind: TEXT_CELL_TYPE,
+ draw: (args, data) => {
+ if (data.value == null || data.value === "") {
+ if (data.readOnly) drawEmptyDash(args);
+ return;
+ }
+
+ const { ctx, rect, theme } = args;
+ ctx.fillStyle = theme.textDark;
+ ctx.font = theme.baseFontFull;
+ ctx.textBaseline = "middle";
+ ctx.fillText(data.value, rect.x + theme.cellHorizontalPadding, rect.y + rect.height / 2);
+ },
+ editor: TextCellEditor,
+ onPaste: (val, data) => {
+ const trimmed = val.trim();
+ return { ...data, value: trimmed === "" ? null : trimmed };
+ },
+ });
+
+export function makeTextCell(
+ value: string | null,
+ options: TextCellOptions,
+ allowOverlay = true,
+): CustomCell {
+ return makeCustomCell(
+ { kind: TEXT_CELL_TYPE, value, options, ...(allowOverlay ? {} : { readOnly: true }) },
+ value ?? "",
+ allowOverlay,
+ );
+}
+
+export const textCellDef: CellTypeDef = {
+ type: TEXT_CELL_TYPE,
+ kind: TEXT_CELL_TYPE,
+ renderer: textCellRenderer as unknown as CustomRenderer,
+ make: (raw, ctx) =>
+ makeTextCell(raw == null ? null : String(raw), ctx.options as TextCellOptions, ctx.editable),
+ validate: (cell) => {
+ const data = cell.data as unknown as TextCellData;
+ return validateText(data.value ?? "", data.options).valid;
+ },
+};
diff --git a/src/lib/grid/textCellEditor.tsx b/src/lib/grid/textCellEditor.tsx
new file mode 100644
index 0000000..1bf5719
--- /dev/null
+++ b/src/lib/grid/textCellEditor.tsx
@@ -0,0 +1,46 @@
+import { Text, Theme } from "@radix-ui/themes";
+import { Input } from "../../components/ui/input";
+import { gridRadixTheme } from "../../theme/radixTheme";
+import { validateText } from "../text/textValidation";
+import type { EditorProps } from "./createCustomCell";
+import type { TextCellData } from "./textCell";
+
+/**
+ * The overlay editor for a `dg:text` cell.
+ *
+ * Its own file so `textCell.tsx` exports no component: a module that mixes a
+ * component with plain exports cannot be hot-reloaded on its own.
+ *
+ * It reads the column's rules off `value.options`, not off a closure, which is
+ * why one editor serves every text column in the app.
+ */
+export const TextCellEditor = ({ value, onChange, isValid }: EditorProps) => {
+ const text = value.value ?? "";
+ const options = value.options;
+
+ const result = validateText(text, options);
+ const error = result.valid ? null : result.error;
+
+ const invalid = isValid === false;
+
+ return (
+
+
+
+ onChange({ ...value, value: e.target.value === "" ? null : e.target.value })
+ }
+ autoFocus
+ style={invalid ? { borderColor: "var(--red-9)" } : undefined}
+ />
+ {invalid && error && (
+
+ {error}
+
+ )}
+
+
+ );
+};
diff --git a/src/lib/phone/phoneUtils.ts b/src/lib/phone/phoneUtils.ts
index e6cd8a1..e65b51d 100644
--- a/src/lib/phone/phoneUtils.ts
+++ b/src/lib/phone/phoneUtils.ts
@@ -5,6 +5,15 @@ import {
type Country,
} from "react-phone-number-input";
+/**
+ * The country assumed for a number typed or pasted without a `+` prefix, when a
+ * column names none.
+ *
+ * Here rather than beside the cell so the cell and its editor can both read it
+ * without importing each other.
+ */
+export const DEFAULT_PHONE_COUNTRY: Country = "US";
+
/**
* Format a phone value for canvas display, in international form.
*