diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d90550..4df5c08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: - run: npm run lint - run: npm run typecheck + - run: npm run check:docs - run: npm test - run: npm run build - run: npm run demo:build diff --git a/.gitignore b/.gitignore index 8017647..995b062 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,12 @@ coverage/ .env .env.* !.env.example -docs + +docs/* +!docs/examples/ + +docs/examples/.check-*/ +tsconfig..check-*.json # Serena LSP cache / project config .serena/ diff --git a/.prettierignore b/.prettierignore index 7e4f816..9f249a6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ dist node_modules demo/public package-lock.json +docs/examples/configuration.md diff --git a/README.md b/README.md index 0bea0cf..fd77c89 100644 --- a/README.md +++ b/README.md @@ -13,117 +13,79 @@ more rows than the browser ever will. > **pre-release** build of glide-data-grid (`6.0.4-alpha24`); that pin loosens > when glide 6 ships stable. -## Install - -```bash -npm i @matthewhsu1/datagrid -``` - -It expects these to already be in your app: - -```bash -npm i react react-dom react-redux @reduxjs/toolkit @tanstack/react-query \ - @radix-ui/themes @glideapps/glide-data-grid@6.0.4-alpha24 -``` +## What it looks like -## Wire it up +All three clips are the demo in this repo: 100,000 synthetic rows behind a Mock +Service Worker. -### 1. Styles, once +### Scrolling -```ts -import "@matthewhsu1/datagrid/radix-styles"; // Radix token CSS the badges need -import "@matthewhsu1/datagrid/datagrid.css"; // the grid's own styles -``` +Pages load into the window as it moves, and evict behind it. The row count is +the server's, not the browser's. -There is no Tailwind here. The package ships plain CSS on `dg-`-prefixed -classes, so it styles itself whatever your app uses. +![Scrolling through 100,000 server-paged rows](./docs/examples/images/scrolling.gif) -### 2. Tell it about your theme, once +### Grouping and sorting -```ts -import { configureGridTheme } from "@matthewhsu1/datagrid"; +A collapse tells the server to leave those rows out, so the total changes with +it. The old rows stay on screen while the new view loads behind them. -configureGridTheme({ - accentColor: "grass", // match your app's Radix accent - grayColor: "slate", - selectAppearance: (state) => state.myTheme.mode, // optional, see below -}); -``` +![Grouping rows and sorting a column](./docs/examples/images/grouping-and-sorting.gif) -A cell editor portals out of the canvas and mounts its own ``, so it -cannot inherit your provider — this is how it learns your accent. +### Editing -### 3. Give it light/dark +An edit shows immediately and is sent behind it. A rejection rolls the cell back +and says why. -The grid reads `state.appearance.appearance` by default. Either mount the -slice the package ships: +![Editing a cell with optimistic update and rollback](./docs/examples/images/editing.gif) -```ts -import { appearanceReducer, setAppearance } from "@matthewhsu1/datagrid"; +## Install -configureStore({ reducer: { appearance: appearanceReducer /* ... */ } }); +```bash +npm i @matthewhsu1/datagrid ``` -…or point `configureGridTheme({ selectAppearance })` at wherever you already -keep it. What _writes_ it — an OS media query, a host page class, a toggle — is -always yours. - -### 4. The overlay portal (usually automatic) - -glide-data-grid draws every cell editor into a `
` it looks up -by id. **Without it, no cell can be edited** — and the grid otherwise looks -perfectly healthy, so it is a nasty thing to debug. `` creates the -element on mount if your app has not, so normally you do nothing. - -Declare it yourself only if you need to control its stacking: +It expects these to already be in your app: -```html - -
-
- +```bash +npm i react react-dom react-redux @reduxjs/toolkit @tanstack/react-query \ + @radix-ui/themes @glideapps/glide-data-grid@6.0.4-alpha24 ``` -An element you provide is left exactly as it is. +## Quick start -### 5. Describe a grid +Full detail — every option, its default, and the traps — is in +[`docs/examples/configuration.md`](./docs/examples/configuration.md). This is the shortest path +that works. ```ts -import { - createGridInstance, - localStorageColumnsAdapter, - type GridDescriptor, -} from "@matthewhsu1/datagrid"; +// 1. Styles, once at your entry point. +import "@matthewhsu1/datagrid/radix-styles"; +import "@matthewhsu1/datagrid/datagrid.css"; -const columns = localStorageColumnsAdapter("orders:columns"); +// 2. Point the grid at your accent, once, before the first grid renders. +configureGridTheme({ accentColor: "grass", grayColor: "slate" }); -export const orderGrid = createGridInstance( +// 3. Describe one grid. +const orderGrid = createGridInstance( { name: "orders", rowKey: (row) => row.id, - columns: { defs: COLUMN_DEFS, defaultOrder: [...] }, - api: { - fetchRows, // one ordered slice - fetchCount, // the total under the current view - fetchRow, // one row, for an id-only push - updateRow, // persist one cell edit - loadColumns: columns.loadColumns, - saveColumns: columns.saveColumns, - }, + columns: { defs: COLUMN_DEFS, defaultOrder: DEFAULT_ORDER }, cells: orderCells, - } satisfies GridDescriptor, - // Column persistence runs as a listener effect. The engine ships no - // middleware of its own, so hand it yours. + api: { fetchRows, fetchCount, fetchRow, updateRow }, + }, { startListening: listenerMiddleware.startListening }, ); -``` - -Mount `orderGrid.reducer` under `"orders"` in your store, and render: -```tsx - +// 4. 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. + ## Run the demo ```bash @@ -138,9 +100,10 @@ surface is not enough to build an app, the demo stops building. ## What is public -Only what `src/index.ts` re-exports, plus `@matthewhsu1/datagrid/testing`. -Deep imports are blocked by `exports`, deliberately: everything else is free to -move in a patch release. +Only what `src/index.ts` re-exports, plus `@matthewhsu1/datagrid/testing`. Deep +imports are blocked by `exports`, deliberately: everything else is free to move +in a patch release. Every public symbol is documented in +[`docs/examples/configuration.md`](./docs/examples/configuration.md). ## Vocabulary @@ -151,50 +114,14 @@ changing anything in `src/features/dataGrid/`. ## Develop ```bash -npm test # 430 tests +npm test # 615 tests npm run typecheck +npm run check:docs # typechecks every code block in docs/ npm run lint -npm run build # dist/ — JS, .d.ts, and datagrid.css -``` - -`just check` runs all four in one go. The recipes live in -[`justfile`](./justfile) and need [`just`](https://github.com/casey/just) -installed; everything they call is a plain `npm` script, so `just` is a -convenience, never a requirement. - -## Releasing - -Versions ship from CI on a `v*` tag, authenticated with npm **trusted -publishing** (OIDC) — there is no `NPM_TOKEN` anywhere in this repo. - -```bash -just release patch # or: minor, major +npm run build # dist/ — JS, .d.ts, and datagrid.css ``` -That is the whole process. `just release` runs the checks, bumps -`package.json`, commits, tags, and pushes — in that order, so the tag always -points at the code that actually ships. It refuses to run from a dirty tree, a -branch other than `main`, a branch out of sync with `origin`, or a failing -`just check`. - -Do not tag by hand. `git tag` on its own leaves `package.json` behind, and a -tag cut before a later fix keeps pointing at the old commit — CI then builds -code you already replaced. The workflow refuses to publish if the tag and -`package.json` disagree, but it cannot catch a stale tag that happens to match. - -Two things had to be set up once, and neither lives in this repo: - -- npm cannot enable OIDC for a package that does not exist yet - ([npm/cli#8544](https://github.com/npm/cli/issues/8544)), so `0.1.0` was - published by hand. -- The package must list this repo as a trusted publisher on npmjs.com - (package → Settings → Trusted publisher → GitHub Actions, workflow - `publish.yml`, no environment). Without it the publish fails at the last - step with `404 Not Found - PUT`, which reads like a missing package but - means npm rejected the credential. - -See the header of -[`.github/workflows/publish.yml`](.github/workflows/publish.yml). +`just check` runs all five in one go. ## Licence diff --git a/docs/examples/configuration.md b/docs/examples/configuration.md new file mode 100644 index 0000000..ffd4345 --- /dev/null +++ b/docs/examples/configuration.md @@ -0,0 +1,719 @@ +# Configuration + +Everything the host app supplies to stand a grid up, in the order you do it. + +Read [`CONTEXT.md`](../CONTEXT.md) first if a word here is unfamiliar — +_descriptor_, _host app_, _row key_, _group_, and _appearance_ all have exactly +one meaning in this codebase. + +Every code block on this page is extracted and typechecked by +`npm run check:docs`. Blocks import `./shared` for the example's row type and +server calls; in your app that import is your own types file. Its contents are +in [`docs/examples/shared.ts`](./examples/shared.ts). + +## Contents + +1. [Stylesheets](#1-stylesheets) +2. [The overlay portal](#2-the-overlay-portal) +3. [Theme](#3-theme) +4. [Appearance](#4-appearance) +5. [Cells](#5-cells) +6. [Columns](#6-columns) +7. [The descriptor](#7-the-descriptor) +8. [Grouping](#8-grouping) +9. [The grid instance](#9-the-grid-instance) +10. [The store](#10-the-store) +11. [The query client](#11-the-query-client) +12. [Rendering](#12-rendering) +13. [Also exported](#13-also-exported) + +--- + +## 1. Stylesheets + +Two imports, once, at your app's entry point. Order does not matter. + +```ts +import "@matthewhsu1/datagrid/radix-styles"; // Radix token CSS the badges read +import "@matthewhsu1/datagrid/datagrid.css"; // the grid's own styles +``` + +`radix-styles` is a side-effect module: it imports the Radix Themes component +CSS plus the twelve colour scales an enum badge can land on. Skip it and badges +draw with unresolved CSS variables, which logs a warning and renders no colour. + +`datagrid.css` carries layout only, on `dg:`-prefixed classes. It contains no +Tailwind preflight, so it will not reset your app's margins or form controls. + +--- + +## 2. The overlay portal + +`` creates `
` on mount if your page has none, so +normally you do nothing. Declare it yourself only to control its stacking. + +```html + +
+
+ +``` + +An element you provide is left exactly as it is. Without a portal, every cell +editor silently fails to open while the grid otherwise looks healthy. + +--- + +## 3. Theme + +Call once at app start, before the first grid renders. A cell editor portals out +of the canvas and mounts its own ``, so it cannot inherit your provider — +this call is how it learns your accent. + +```ts +import { configureGridTheme } from "@matthewhsu1/datagrid"; + +configureGridTheme({ + accentColor: "grass", // default "iris". Any Radix accent; match your app. + grayColor: "slate", // default "slate". Any Radix gray. + radius: "medium", // default "medium". Radix radius scale. + scaling: "100%", // default "100%". Radix scaling scale. + selectAppearance: (state) => (state as { ui: { mode: "light" | "dark" } }).ui.mode, + // ^ default reads state.appearance.appearance. See section 4. +}); +``` + +`accentColor` and `grayColor` accept any Radix scale, but an **enum badge** +colour must also appear in `RADIX_BADGE_SCALES`, or it resolves to nothing and +warns at runtime. The badge scales are listed in section 5. + +--- + +## 4. Appearance + +The engine reads light or dark. It never decides it. What writes it — an OS +media query, a page class, a user toggle — is always yours. + +Mount the slice the package ships at the `appearance` key: + +```ts +import { appearanceReducer, setAppearance } from "@matthewhsu1/datagrid"; +import { configureStore } from "@reduxjs/toolkit"; + +export const store = configureStore({ + reducer: { appearance: appearanceReducer }, +}); + +// Your watcher writes it. The engine only ever reads it. +window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => { + store.dispatch(setAppearance(e.matches ? "dark" : "light")); +}); +``` + +Keeping appearance somewhere else is fine — point +`configureGridTheme({ selectAppearance })` at it instead. A host that mounts +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: { + 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), + // ^ default undefined. Escape hatch. Return a message, or null when valid. + }, +}); +``` + +Rules run in one fixed order and the editor shows the **first** failure: +`required` → `minLength` → `maxLength` → `email` → `pattern` → `validate`. An +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. +}); +``` + +`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"; + +export const placedAtCell = createDateCell({ + kind: "placedAt", // REQUIRED. Unique across the grid. + nullable: true, // default false. true lets the user confirm an empty date. +}); +``` + +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. + +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" }, + ], +}); +``` + +`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` +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 + +```ts +import { createPhoneCell } from "@matthewhsu1/datagrid"; + +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. +}); +``` + +Values are stored canonically as E.164 (`+14155552671`). Any non-empty value +must parse as a valid number; `nullable` decides only whether empty is allowed. + +### A cell kind the package does not ship + +```tsx +import { createCustomCell, drawEmptyDash, makeCustomCell } from "@matthewhsu1/datagrid"; + +interface RatingData { + kind: "rating"; // REQUIRED. Literal type; routes glide's isMatch. + value: number | null; + readOnly?: boolean; +} + +export const ratingRenderer = createCustomCell({ + kind: "rating", // REQUIRED. Must equal cell.data.kind at runtime. + draw: (args, data) => { + // REQUIRED. Canvas only — no DOM reaches a cell. + if (data.value == null) return drawEmptyDash(args); + const { ctx, rect, theme } = args; + ctx.fillStyle = theme.textDark; + ctx.fillText("★".repeat(data.value), rect.x + 8, rect.y + rect.height / 2); + }, + editor: ({ value, onChange }) => ( + // REQUIRED. Real DOM — use Radix here. + onChange({ ...value, value: Number(e.target.value) })} + /> + ), + onPaste: (text, data) => { + // default undefined. Omit and the cell is not pasteable. + const n = Number(text); + return Number.isFinite(n) ? { ...data, value: n } : undefined; + }, +}); + +export const makeRatingCell = (value: number | null, allowOverlay = true) => + makeCustomCell({ kind: "rating", value }, String(value ?? ""), allowOverlay); +// ^ data ^ copyData (what Ctrl+C yields) ^ default true +``` + +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 + +One place maps a column's `type` to its maker. Build it once. + +```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"], + }), + ), +); +``` + +`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. + +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. + +--- + +## 6. Columns + +One definition per column, keyed by the row property it reads. + +```ts +import type { ColumnDef } from "@matthewhsu1/datagrid"; + +export const COLUMN_DEFS: Record = { + reference: { + field: "reference", // REQUIRED. Row property, and the column's id everywhere. + 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. + sortable: true, // default true. false hides this column's sort menu. + }, + 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. + }, +}; + +export const DEFAULT_ORDER = ["reference", "total", "placedAt"]; +``` + +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. + +--- + +## 7. The descriptor + +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 { 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. + }, + cells: orderCells, // REQUIRED. Looked up by ColumnDef.type. + 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. + }, + // grouping: omitted here. See section 8. +}; +``` + +`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`. + +`updateRow` may return the server's authoritative `row`, and the engine writes +it over the optimistic value — that is how a derived column updates after an +edit. Returning `{ ok: false }` rolls the edit back and shows the banner. + +`fetchCount` receives an opaque `filter`. The engine only carries it; put it in +your count's query key, because a stale total sizes the scrollbar for rows the +grid will never receive. + +--- + +## 8. Grouping + +Omit `grouping` and the grid renders flat. Supply it and rows are bucketed under +full-width header rows. + +```tsx +import type { GridGrouping } from "@matthewhsu1/datagrid"; +import type { OrderRow } from "./shared"; + +const REGIONS = ["APAC", "EMEA", "NA"]; // sorted, so indexOf gives the order + +export const byRegion: GridGrouping = { + field: "region", // REQUIRED. Row property the grouping reads. + of: (row) => row.region, // REQUIRED. MUST return a primitive — see below. + 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. + header: (g) => {g}, // default undefined. Your nodes, right of the name. + headerHeight: 40, // default 40. Pixels. Nothing measures what you drew. +}; +``` + +**`of` must return a primitive.** The engine tracks groups with `===`, `Set`, +and `Array.includes`. Return a fresh object per call — `{ year, month }` — and +every row reads as a new group, so collapse and dedup both silently do nothing +and no error is raised. Derive a string instead: `` `${year}-${month}` ``. + +**`order` must agree with `field` ascending.** Group X sorts before group Y here +exactly when `X.field < Y.field`. The rows are ordered by `field`; the headers +are placed by this function. Disagree and the headers describe a different order +from the rows beneath them. Keeping the values in a sorted array and returning +`indexOf` satisfies this by construction. + +**Your server must order by `grouping.field` ascending FIRST, then by `sort`.** +The group prefix never appears in the `fetchRows` request — `sort` carries only +the user's column. A server that ignores this returns the wrong rows for the +window, and the client cannot repair it. + +**`headerHeight` is a number, not a measurement.** The canvas reserves the space +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. + +`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 +inside. + +--- + +## 9. The grid instance + +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. +}); +``` + +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. + +**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 +mounted on the same instance at once overwrite each other's. A second view of +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. + +```ts +import { appearanceReducer } from "@matthewhsu1/datagrid"; +import { configureStore } from "@reduxjs/toolkit"; +import { listenerMiddleware, 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), +}); +``` + +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. + +--- + +## 11. The query client + +Row pages are fetched through TanStack Query. The grid needs a provider above +it; the client itself is yours. + +```tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Provider } from "react-redux"; +import { store } from "./shared"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, // recommended: a refetch fights the held window + retry: false, // recommended: the row store already handles gaps + }, + }, +}); + +export function AppRoot({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +--- + +## 12. Rendering + +`` takes exactly one prop. + +```tsx +import { DataGrid } from "@matthewhsu1/datagrid"; +import { orderGrid } from "./shared"; + +export function OrdersPage() { + return ; +} +``` + +Everything else — sort menus, the column picker, group headers, the edit banner +— is drawn by the grid from the descriptor. Give it a sized parent; it fills the +space it is given. + +--- + +## 13. Also exported + +These are not knobs you configure. They are pieces you may need beside the grid. + +### `DatePicker` and `DatePickerPopover` + +The picker the date cell edits through, exported for your own DOM — filters, +toolbars, forms. `DatePicker` renders inline, because a grid overlay already +floats it. `DatePickerPopover` adds a Radix trigger button for ordinary DOM. + +```tsx +import { DatePickerPopover } from "@matthewhsu1/datagrid"; +import { useState } from "react"; + +export function DueDateFilter() { + const [value, setValue] = useState(null); + + return ( + + ); +} +``` + +`DatePicker` takes the same `mode`/`value` union plus `onConfirm`, `onCancel`, +`nullable`, and `autoFocus` (default `true`). The `mode` narrows the value type: +`date` and `datetime` use a string, `range` and `range-datetime` use +`{ from, to }`. + +### Sorting + +The comparator the engine itself uses. Reach for it when you sort rows outside +the grid and need the two orders to agree exactly. + +```ts +import { compareBySpec, specFromGridSort, type SortSpec } from "@matthewhsu1/datagrid"; +import type { OrderRow } from "./shared"; + +const spec: SortSpec | null = specFromGridSort({ field: "total", dir: "desc" }); +// -> { field: "total", direction: "desc", nulls: "last" } + +export function sortRows(rows: OrderRow[], by: SortSpec): OrderRow[] { + return [...rows].sort((a, b) => compareBySpec(a, b, by, (row) => row.id)); +} + +export { spec }; +``` + +The order is **total**: equal values fall back to the row key, so no two +distinct rows ever compare equal and nothing jumps under a still viewport. +Nulls always sort last, in both directions. + +### `rowsKeyPrefix` + +The TanStack Query key prefix every row page of one grid shares. Use it when +something outside the grid changes rows and you need to drop the cache. + +```ts +import { rowsKeyPrefix } from "@matthewhsu1/datagrid"; +import { queryClient } from "./shared"; + +export function dropOrderRows(): void { + queryClient.invalidateQueries({ queryKey: rowsKeyPrefix("orders") }); +} +``` + +Pass the descriptor's `name`. For a change that **moves** rows — a create or a +delete — invalidating is not enough on its own; the engine bumps its own data +generation when a push arrives through `api.subscribe`. + +### Badge colours + +```ts +import { BADGE_SEQUENCE, radixColorByIndex, type RadixColor } from "@matthewhsu1/datagrid"; + +export const third: RadixColor = radixColorByIndex(2); // "amber" +export const cycleLength = BADGE_SEQUENCE.length; // 10 +``` + +`radixColorByIndex` is the rule an enum option follows when it names no colour: +`BADGE_SEQUENCE[value % 10]`. Call it yourself to colour something outside the +grid — a legend, a chart, a filter chip — to match the badges exactly. + +`drawSoftBadge(ctx, rect, color, label, font)` paints the same pill on a canvas. +It is for a **custom cell** that wants to look like the built-in enum cell. + +### Grid themes + +```ts +import { darkGridTheme, lightGridTheme, themeForAppearance } from "@matthewhsu1/datagrid"; + +export const current = themeForAppearance("dark"); // === darkGridTheme +export const pair = [lightGridTheme, darkGridTheme]; +``` + +These are glide-data-grid theme objects, not Radix ones. `useGridTheme()` is the +hook `` uses internally; it reads your appearance and returns the +right one. You rarely need any of them — reach for them to style a second glide +surface so it matches the grid. + +### Testing + +Published from a separate subpath so it never reaches an app bundle. + +```ts +import { createFakeRowServer } from "@matthewhsu1/datagrid/testing"; +import type { OrderRow } from "./shared"; + +const server = createFakeRowServer({ + rows: [], // REQUIRED. The starting rows. + rowKey: (row) => row.id, // REQUIRED. Same function the descriptor uses. + groupOf: (row) => row.region, // default undefined. Omit and collapse is ignored. +}); + +// server.api is a drop-in descriptor.api: fetchRows, fetchCount, fetchRow, +// updateRow, subscribe. +export const testDescriptorApi = server.api; + +// Arrange and act on the server itself. +server.setRows([]); // replace the rows outright +server.push({ kind: "update", id: 1 }); // emit a push; sequence is stamped for you +export const currentRows = server.rows; // read them back +``` + +It sorts with `compareBySpec` — the same function the live query uses. If the +two ever disagree, a test fails instead of a user watching rows jump. diff --git a/docs/examples/images/editing.gif b/docs/examples/images/editing.gif new file mode 100644 index 0000000..9dfd4ce Binary files /dev/null and b/docs/examples/images/editing.gif differ diff --git a/docs/examples/images/grouping-and-sorting.gif b/docs/examples/images/grouping-and-sorting.gif new file mode 100644 index 0000000..52df618 Binary files /dev/null and b/docs/examples/images/grouping-and-sorting.gif differ diff --git a/docs/examples/images/scrolling.gif b/docs/examples/images/scrolling.gif new file mode 100644 index 0000000..0664528 Binary files /dev/null and b/docs/examples/images/scrolling.gif differ diff --git a/docs/examples/shared.ts b/docs/examples/shared.ts new file mode 100644 index 0000000..325cbca --- /dev/null +++ b/docs/examples/shared.ts @@ -0,0 +1,193 @@ +/** + * The row type and the fake server calls every block in `docs/configuration.md` + * imports. + * + * It exists so a code block can stay short and still typecheck: without it, + * every block that touches a descriptor would have to redeclare `OrderRow` and + * four server functions. `scripts/check-docs.mjs` extracts each block into its + * own temporary module, and each one resolves this import for real. + * + * Nothing here ships. `package.json`'s `files` field publishes `dist` only. + */ +import type { + ColumnsState, + FetchRowsParams, + RowChange, + UpdateRowParams, +} from "@matthewhsu1/datagrid"; + +/** The row the documentation's example grid draws. */ +export interface OrderRow { + id: number; + reference: string; + customer: string; + total: number; + placedAt: string | null; + status: number; + phone: string | null; + region: string; +} + +/** Stand-in for the host app's real endpoint. */ +export async function fetchRows(_p: FetchRowsParams): Promise { + return []; +} + +export async function fetchCount(_p: { + collapsedGroups: string[]; + filter?: unknown; + signal?: AbortSignal; +}): Promise { + return 0; +} + +export async function fetchRow(_id: number, _signal?: AbortSignal): Promise { + return null; +} + +export async function updateRow( + _p: UpdateRowParams, +): Promise<{ ok: boolean; row?: OrderRow }> { + return { ok: true }; +} + +export function subscribe(_handler: (change: RowChange) => void): () => 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`. + +import { QueryClient } from "@tanstack/react-query"; +import { configureStore, createListenerMiddleware } from "@reduxjs/toolkit"; +import type { CustomRenderer } from "@glideapps/glide-data-grid"; +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", + }, + total: { field: "total", title: "Total", defaultWidth: 120, editable: true, type: "currency" }, + placedAt: { + field: "placedAt", + title: "Placed", + defaultWidth: 180, + editable: true, + type: "date", + withTime: true, + }, +}; + +export const DEFAULT_ORDER = ["reference", "total", "placedAt"]; + +const columnsAdapter = localStorageColumnsAdapter("orders:columns"); + +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, + }, +}; + +export const listenerMiddleware = createListenerMiddleware(); + +export const orderGrid = createGridInstance(orderDescriptor, { + startListening: listenerMiddleware.startListening, +}); + +export const store = configureStore({ + reducer: { appearance: appearanceReducer, orders: orderGrid.reducer }, + middleware: (getDefault) => getDefault().prepend(listenerMiddleware.middleware), +}); + +export const queryClient = new QueryClient(); diff --git a/justfile b/justfile index 642e56a..5ceb60e 100644 --- a/justfile +++ b/justfile @@ -6,6 +6,7 @@ default: check: npm run lint npm run typecheck + npm run check:docs npm test npm run build diff --git a/package.json b/package.json index 3b58e7a..758eaf2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@matthewhsu1/datagrid", "version": "0.1.2", - "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.", + "description": "A virtualised, server-paged data grid engine for React \u2014 windowed paging, a page cache, optimistic edits, cross-client row sync, grouping, and typed cell editors, over glide-data-grid.", "license": "MIT", "author": "MatthewHsu1 ", "repository": { @@ -46,6 +46,7 @@ "build": "tsup && npm run build:css", "build:css": "tailwindcss -i src/datagrid.src.css -o dist/datagrid.css", "typecheck": "tsc -b", + "check:docs": "node scripts/check-docs.mjs", "lint": "oxlint", "format": "prettier --write .", "format:check": "prettier --check .", @@ -53,7 +54,7 @@ "test:watch": "vitest", "dev": "vite --config demo/vite.config.ts", "demo:build": "vite build --config demo/vite.config.ts", - "release:manual": "npm run lint && npm run typecheck && npm test && npm run build && npm publish --access public" + "release:manual": "npm run lint && npm run typecheck && npm run check:docs && npm test && npm run build && npm publish --access public" }, "peerDependencies": { "@glideapps/glide-data-grid": "6.0.4-alpha24", diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs new file mode 100644 index 0000000..c43e6b1 --- /dev/null +++ b/scripts/check-docs.mjs @@ -0,0 +1,140 @@ +/** + * Typechecks every ```ts / ```tsx block in the docs against the real package. + * + * The markdown is the SOURCE OF TRUTH — there is no second copy of these + * examples to drift from. Each block is written to its own module beside + * `docs/examples/shared.ts` (so a block's `./shared` import resolves exactly as + * it reads), then `tsc` runs over the whole set in one pass. + * + * Blocks are separate modules on purpose: two blocks may both declare + * `orderGrid` without colliding, which is what lets each one stand alone. + * + * Opt a block out with an info string of `ts no-check` — for a snippet that is + * deliberately illustrative rather than complete. + */ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const EXAMPLES_DIR = join(ROOT, "docs", "examples"); +const DOCS = [join(ROOT, "docs", "examples", "configuration.md")]; + +/** A fenced block plus the 1-based line its fence opens on, for the error map. */ +function extractBlocks(markdown) { + const blocks = []; + const lines = markdown.split("\n"); + + let open = null; + + for (let i = 0; i < lines.length; i += 1) { + const fence = /^```(.*)$/.exec(lines[i]); + if (!fence) continue; + + if (open) { + const info = open.info.trim().split(/\s+/); + const lang = info[0]; + if ((lang === "ts" || lang === "tsx") && !info.includes("no-check")) { + blocks.push({ lang, line: open.line, code: lines.slice(open.line, i).join("\n") }); + } + open = null; + continue; + } + + // A fence with no info string closes; one with an info string opens. + open = { info: fence[1], line: i + 1 }; + } + + return blocks; +} + +const written = []; +let failed = false; +const origin = new Map(); + +// Written INTO docs/examples so `./shared` resolves the way the block reads it. +// The `.check-` prefix is gitignored. +const stamp = basename(mkdtempSync(join(EXAMPLES_DIR, ".check-"))); +const tmpDir = join(EXAMPLES_DIR, stamp); + +try { + for (const doc of DOCS) { + const markdown = readFileSync(doc, "utf8"); + const blocks = extractBlocks(markdown); + + blocks.forEach((block, index) => { + const name = `block-${String(index + 1).padStart(2, "0")}.${block.lang}`; + const file = join(tmpDir, name); + // `./shared` from one directory deeper is `../shared`. + writeFileSync(file, block.code.replaceAll('from "./shared"', 'from "../shared"')); + written.push(file); + origin.set(join(stamp, name), { doc: relative(ROOT, doc), fence: block.line }); + }); + } + + if (written.length === 0) { + // `process.exitCode`, never `process.exit()`: the latter terminates before + // the `finally` below runs, which would leak the scratch directory. + console.error("check-docs: no ts blocks found — the extractor is broken, not the docs."); + process.exitCode = 1; + failed = true; + } + + if (!failed) { + // `tsc --project` refuses source files on the same command line, so the file + // set is handed over as a generated project that extends the real one. + const projectFile = join(ROOT, `tsconfig.${stamp}.json`); + writeFileSync( + projectFile, + JSON.stringify( + { + extends: "./tsconfig.json", + compilerOptions: { noEmit: true, tsBuildInfoFile: null }, + // `files`, not `include`: TypeScript's include globs skip any + // dot-prefixed directory, and the scratch dir is one. + files: [...written.map((f) => relative(ROOT, f)), "docs/examples/shared.ts"], + }, + null, + 2, + ), + ); + + try { + execFileSync("npx", ["tsc", "--noEmit", "--project", projectFile], { + cwd: ROOT, + stdio: "pipe", + encoding: "utf8", + }); + } catch (error) { + const output = `${error.stdout ?? ""}${error.stderr ?? ""}`; + // Rewrite every temp-file coordinate into the markdown line it came from, + // so the failure names a place the reader can actually open. The block's + // own line 1 sits one line below its opening fence. + let mapped = output; + for (const [tmpName, { doc, fence }] of origin) { + const path = join("docs", "examples", tmpName).replaceAll("\\", "\\\\"); + const pattern = new RegExp( + `${path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\((\\d+),(\\d+)\\)`, + "g", + ); + mapped = mapped.replaceAll( + pattern, + (_m, line, col) => `${doc}:${fence + Number(line)}:${col}`, + ); + } + console.error(mapped.trim()); + console.error(`\ncheck-docs: ${written.length} block(s) — FAILED`); + process.exitCode = 1; + failed = true; + } finally { + rmSync(projectFile, { force: true }); + } + } + + if (!failed) { + console.log(`check-docs: ${written.length} block(s) typechecked`); + } +} finally { + rmSync(tmpDir, { recursive: true, force: true }); +} diff --git a/src/index.ts b/src/index.ts index 5597199..421938b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export { localStorageColumnsAdapter } from "./features/dataGrid/store/localStora export type { ColumnDef, + ColumnsState, EditError, EditState, FetchRowsParams, @@ -30,7 +31,9 @@ export type { GridInstanceOptions, GridSliceState, GridSort, + GroupsState, RowChange, + SelectionState, StartListening, UpdateRowParams, } from "./features/dataGrid/types";