@hypermedia-components/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +28 -0
  3. package/bin/hc-cli.mjs +70 -0
  4. package/lib/recipes.mjs +77 -0
  5. package/package.json +30 -0
  6. package/recipes/chart/contract.md +136 -0
  7. package/recipes/chart/expanded.html +40 -0
  8. package/recipes/chart/recipe.html +16 -0
  9. package/recipes/confirm-action/contract.md +28 -0
  10. package/recipes/confirm-action/expanded.html +17 -0
  11. package/recipes/confirm-action/recipe.html +10 -0
  12. package/recipes/data-region/contract.md +25 -0
  13. package/recipes/data-region/expanded.html +17 -0
  14. package/recipes/data-region/recipe.html +9 -0
  15. package/recipes/datagrid-pager/contract.md +73 -0
  16. package/recipes/datagrid-pager/expanded.html +50 -0
  17. package/recipes/datagrid-pager/recipe.html +30 -0
  18. package/recipes/field-errors/contract.md +98 -0
  19. package/recipes/field-errors/expanded.html +44 -0
  20. package/recipes/field-errors/recipe.html +21 -0
  21. package/recipes/filter-popover/contract.md +18 -0
  22. package/recipes/filter-popover/expanded.html +25 -0
  23. package/recipes/filter-popover/recipe.html +17 -0
  24. package/recipes/inline-edit/contract.md +58 -0
  25. package/recipes/inline-edit/expanded.html +70 -0
  26. package/recipes/inline-edit/recipe.html +12 -0
  27. package/recipes/lazy-panel/contract.md +67 -0
  28. package/recipes/lazy-panel/expanded.html +68 -0
  29. package/recipes/lazy-panel/recipe.html +11 -0
  30. package/recipes/live-search/contract.md +21 -0
  31. package/recipes/live-search/expanded.html +17 -0
  32. package/recipes/live-search/recipe.html +15 -0
  33. package/recipes/remote-dialog/contract.md +20 -0
  34. package/recipes/remote-dialog/expanded.html +18 -0
  35. package/recipes/remote-dialog/recipe.html +10 -0
  36. package/recipes/request-action/contract.md +29 -0
  37. package/recipes/request-action/expanded.html +15 -0
  38. package/recipes/request-action/recipe.html +14 -0
  39. package/recipes/toast/contract.md +71 -0
  40. package/recipes/toast/expanded.html +42 -0
  41. package/recipes/toast/recipe.html +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ingcreators
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @hypermedia-components/cli
2
+
3
+ Copy [Hypermedia Components](https://github.com/ingcreators/hypermedia-components)
4
+ recipe scaffolds into your project. Recipes are **source files you own** —
5
+ plain HTML plus a documented server contract — not a runtime dependency.
6
+
7
+ ```bash
8
+ npx @hypermedia-components/cli list
9
+ npx @hypermedia-components/cli add confirm-action
10
+ npx @hypermedia-components/cli add live-search --dir src/recipes
11
+ ```
12
+
13
+ `add <recipe>` copies three files into `<dir>/<recipe>/`:
14
+
15
+ | File | What it is |
16
+ | --- | --- |
17
+ | `recipe.html` | The copyable starting point (semantic classes + `data-hx-*`). |
18
+ | `expanded.html` | The same pattern with every shorthand expanded. |
19
+ | `contract.md` | The server request/response contract the recipe expects. |
20
+
21
+ Existing files are never overwritten unless you pass `--force`.
22
+
23
+ The recipes ship inside this package, so the command works offline. Each
24
+ recipe is documented with a live demo on the
25
+ [docs site](https://hypermedia-components.ichimura-12c.workers.dev/hypermedia-components/recipes/).
26
+
27
+ The styles and behaviors the recipes reference come from
28
+ [`@hypermedia-components/core`](https://www.npmjs.com/package/@hypermedia-components/core).
package/bin/hc-cli.mjs ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ // npx @hypermedia-components/cli add <recipe> [--dir <target>] [--force]
3
+ // npx @hypermedia-components/cli list
4
+ import { parseArgs } from 'node:util';
5
+ import { listRecipes, copyRecipe, RECIPE_FILES } from '../lib/recipes.mjs';
6
+
7
+ const USAGE = `Usage:
8
+ hypermedia-components add <recipe> [--dir <target>] [--force]
9
+ hypermedia-components list
10
+
11
+ Commands:
12
+ add Copy a recipe's source files (${RECIPE_FILES.join(' / ')})
13
+ into <target>/<recipe>/ (target defaults to the current directory).
14
+ list Show the available recipes.
15
+
16
+ Options:
17
+ -d, --dir <target> Directory to copy into (default: ".")
18
+ -f, --force Overwrite existing files
19
+ -h, --help Show this help
20
+ `;
21
+
22
+ async function main(argv) {
23
+ const { values, positionals } = parseArgs({
24
+ args: argv,
25
+ allowPositionals: true,
26
+ options: {
27
+ dir: { type: 'string', short: 'd', default: '.' },
28
+ force: { type: 'boolean', short: 'f', default: false },
29
+ help: { type: 'boolean', short: 'h', default: false },
30
+ },
31
+ });
32
+ const [command, name] = positionals;
33
+
34
+ if (values.help || !command || command === 'help') {
35
+ process.stdout.write(USAGE);
36
+ return 0;
37
+ }
38
+
39
+ if (command === 'list') {
40
+ for (const recipe of await listRecipes()) {
41
+ process.stdout.write(`${recipe.name.padEnd(18)} ${recipe.purpose}\n`);
42
+ }
43
+ return 0;
44
+ }
45
+
46
+ if (command === 'add') {
47
+ if (!name) {
48
+ process.stderr.write(`Missing recipe name.\n\n${USAGE}`);
49
+ return 1;
50
+ }
51
+ const written = await copyRecipe(name, values.dir, { force: values.force });
52
+ for (const file of written) process.stdout.write(`${file}\n`);
53
+ process.stdout.write(
54
+ `\nCopied ${written.length} files. recipe.html is the starting point; ` +
55
+ `contract.md documents the server responses it expects.\n`,
56
+ );
57
+ return 0;
58
+ }
59
+
60
+ process.stderr.write(`Unknown command ${JSON.stringify(command)}.\n\n${USAGE}`);
61
+ return 1;
62
+ }
63
+
64
+ main(process.argv.slice(2)).then(
65
+ (code) => process.exit(code),
66
+ (error) => {
67
+ process.stderr.write(`${error.message}\n`);
68
+ process.exit(error.message.startsWith('Refusing to overwrite') ? 2 : 1);
69
+ },
70
+ );
@@ -0,0 +1,77 @@
1
+ // Recipe resolution + copying. The published tarball bundles a synced
2
+ // copy of the repo's recipes/ (created by scripts/sync-recipes.mjs at
3
+ // prepack), so the CLI works offline; inside the workspace the repo's
4
+ // recipes/ directory itself is used, so dev never needs a sync step.
5
+ import { cp, mkdir, readdir, readFile, stat } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
+
12
+ /** The three files every recipe ships (the recipe DoD's source format). */
13
+ export const RECIPE_FILES = ['recipe.html', 'expanded.html', 'contract.md'];
14
+
15
+ /** Bundled copy first (published tarball), repo root second (workspace dev). */
16
+ export function recipesRoot() {
17
+ for (const candidate of [join(PKG_ROOT, 'recipes'), resolve(PKG_ROOT, '..', '..', 'recipes')]) {
18
+ if (existsSync(join(candidate))) return candidate;
19
+ }
20
+ throw new Error('No recipes directory found (looked in the package and the workspace root).');
21
+ }
22
+
23
+ /** @returns {Promise<Array<{ name: string, purpose: string }>>} sorted by name */
24
+ export async function listRecipes() {
25
+ const root = recipesRoot();
26
+ const entries = await readdir(root, { withFileTypes: true });
27
+ const recipes = [];
28
+ for (const entry of entries) {
29
+ if (!entry.isDirectory()) continue;
30
+ if (!existsSync(join(root, entry.name, 'recipe.html'))) continue;
31
+ const contract = await readFile(join(root, entry.name, 'contract.md'), 'utf8').catch(() => '');
32
+ const purpose = contract.match(/^Purpose:\s*(.+)$/m)?.[1]?.trim() ?? '';
33
+ recipes.push({ name: entry.name, purpose });
34
+ }
35
+ return recipes.sort((a, b) => a.name.localeCompare(b.name));
36
+ }
37
+
38
+ /**
39
+ * Copy a recipe's source files into `<targetDir>/<name>/`.
40
+ *
41
+ * @param {string} name
42
+ * @param {string} targetDir
43
+ * @param {{ force?: boolean }} [opts]
44
+ * @returns {Promise<string[]>} the written file paths
45
+ */
46
+ export async function copyRecipe(name, targetDir, { force = false } = {}) {
47
+ if (!/^[a-z0-9-]+$/.test(name)) {
48
+ throw new Error(`Invalid recipe name: ${JSON.stringify(name)} (expected kebab-case).`);
49
+ }
50
+ const source = join(recipesRoot(), name);
51
+ const known = await listRecipes();
52
+ if (!known.some((r) => r.name === name)) {
53
+ throw new Error(
54
+ `Unknown recipe ${JSON.stringify(name)}. Available: ${known.map((r) => r.name).join(', ')}`,
55
+ );
56
+ }
57
+
58
+ const dest = join(resolve(targetDir), name);
59
+ if (!force) {
60
+ for (const file of RECIPE_FILES) {
61
+ const target = join(dest, file);
62
+ if (existsSync(target)) {
63
+ throw new Error(`Refusing to overwrite ${target} (pass --force to replace).`);
64
+ }
65
+ }
66
+ }
67
+
68
+ await mkdir(dest, { recursive: true });
69
+ const written = [];
70
+ for (const file of RECIPE_FILES) {
71
+ const from = join(source, file);
72
+ if (!(await stat(from).catch(() => null))) continue;
73
+ await cp(from, join(dest, file), { force: true });
74
+ written.push(join(dest, file));
75
+ }
76
+ return written;
77
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@hypermedia-components/cli",
3
+ "version": "0.1.0",
4
+ "description": "Copy Hypermedia Components recipe scaffolds (recipe.html / expanded.html / contract.md) into your project.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/ingcreators/hypermedia-components.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "hypermedia-components": "bin/hc-cli.mjs"
14
+ },
15
+ "files": [
16
+ "bin/",
17
+ "lib/",
18
+ "recipes/",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=24"
23
+ },
24
+ "devDependencies": {
25
+ "vitest": "^4.1.8"
26
+ },
27
+ "scripts": {
28
+ "test": "vitest run"
29
+ }
30
+ }
@@ -0,0 +1,136 @@
1
+ # chart — server response contract
2
+
3
+ Purpose: render a chart from a server-sent **semantic data table**. The
4
+ table is the data source, the no-JavaScript fallback, and the
5
+ screen-reader data. `installChart()` (from `@hypermedia-components/core`)
6
+ reads it and draws an [Observable Plot](https://observablehq.com/plot/)
7
+ SVG. Plot is an optional peer dependency — load it yourself (CDN UMD
8
+ global or a bundled import); without it the table simply stays visible.
9
+
10
+ This recipe **needs a behavior**: `installChart(document, { plot })`. It is
11
+ **not** part of the auto-init `@hypermedia-components/core/behaviors`
12
+ entry, because Plot is not bundled.
13
+
14
+ ## Required client markup
15
+
16
+ - `<figure class="hc-chart" data-hc-chart="<type>">` wrapping a
17
+ `<table class="hc-table">`.
18
+ - A `<thead>` whose first cell is the **x category** and whose remaining
19
+ cells name the **series**.
20
+ - A `<tbody>` of rows: first cell = x value, the rest = series values.
21
+ - Optional `<caption>` — used as the chart title.
22
+
23
+ ```html
24
+ <figure class="hc-chart" data-hc-chart="line" data-y-label="Sales ($k)">
25
+ <table class="hc-table">
26
+ <caption>Monthly sales</caption>
27
+ <thead><tr><th>Month</th><th>Tokyo</th><th>Osaka</th></tr></thead>
28
+ <tbody>
29
+ <tr><td>Jan</td><td>120</td><td>80</td></tr>
30
+ <tr><td>Feb</td><td>200</td><td>140</td></tr>
31
+ </tbody>
32
+ </table>
33
+ </figure>
34
+ ```
35
+
36
+ ## Chart types (`data-hc-chart`)
37
+
38
+ | Value | Renders |
39
+ | ------- | ------------------------------------------------------------- |
40
+ | `bar` | Vertical bars. Multiple series **stack**. |
41
+ | `line` | Lines with node dots, one per series. |
42
+ | `area` | Filled areas with a line edge, one per series. |
43
+ | `combo` | Per-column marks — set each `<th data-mark="bar\|line\|area">`. |
44
+
45
+ `data-hc-chart` is the **default mark** for any column without its own
46
+ `data-mark`. For `combo` the default is `bar`. So `bar`/`line`/`area` are
47
+ just the special case where every column shares one mark.
48
+
49
+ ## Per-column mark (combo)
50
+
51
+ ```html
52
+ <thead>
53
+ <tr>
54
+ <th>Month</th>
55
+ <th data-mark="bar">Sales</th>
56
+ <th data-mark="line">Target</th>
57
+ </tr>
58
+ </thead>
59
+ ```
60
+
61
+ ## Options (figure attributes)
62
+
63
+ | Attribute | Default | Effect |
64
+ | ---------------------- | ------------ | ------------------------------------------------- |
65
+ | `data-y-label` | _(none)_ | y-axis label. |
66
+ | `data-title` | `<caption>` | Chart title (falls back to the table caption). |
67
+ | `data-x-type` | `category` | `category` \| `number` \| `date` — x value coercion. |
68
+ | `data-width` | container | Plot width in px. |
69
+ | `data-height` | `--hc-chart-height` (320px) | Plot height in px. |
70
+ | `data-legend` | auto | `false` hides the colour legend. |
71
+
72
+ Cell values are coerced to numbers; thousands separators, currency
73
+ symbols, and `%` signs are stripped (`"1,200"` → `1200`). Bars expect a
74
+ `category` x; `number` / `date` x suit `line` / `area`.
75
+
76
+ ## Server response
77
+
78
+ Return the `<figure class="hc-chart">…</figure>` fragment (or just the
79
+ inner table for an existing figure target). The **same** endpoint must
80
+ return a usable table for a non-htmx request (full page load) so the
81
+ no-JavaScript path works — detect htmx via the `HX-Request: true` header
82
+ if you wrap fragments in a layout.
83
+
84
+ ```html
85
+ <!-- GET /reports/sales -->
86
+ <figure class="hc-chart" data-hc-chart="bar" data-y-label="Sales ($k)">
87
+ <table class="hc-table">
88
+ <caption>Monthly sales</caption>
89
+ <thead><tr><th>Month</th><th>Sales</th></tr></thead>
90
+ <tbody>
91
+ <tr><td>Jan</td><td>120</td></tr>
92
+ <tr><td>Feb</td><td>200</td></tr>
93
+ </tbody>
94
+ </table>
95
+ </figure>
96
+ ```
97
+
98
+ `installChart` listens for `htmx:load`, so a chart swapped into the page
99
+ renders automatically — no per-swap JavaScript.
100
+
101
+ Status: `200 OK` with the fragment for htmx requests *and* for the
102
+ full-page (no-JavaScript) request. A non-2xx response is not swapped
103
+ (htmx ≥ 2 default), so the previous chart stays.
104
+
105
+ ## Optional: embedded JSON source
106
+
107
+ For many series or config-heavy charts you may prefer embedding the data
108
+ as JSON instead of (or alongside) the table. This recipe's behavior reads
109
+ the **table**; if you adopt a JSON source, **escape `<` as `<`** when
110
+ serializing server-side to avoid breaking out of the `<script>` element
111
+ (an XSS vector with user data). Keep a visually-hidden table for the
112
+ no-JavaScript / screen-reader path.
113
+
114
+ ## Progressive enhancement
115
+
116
+ - **No JavaScript** → the `<table class="hc-table">` renders as a normal,
117
+ readable data table.
118
+ - **JavaScript, no Plot** → same: `installChart` is a no-op without Plot.
119
+ - **JavaScript + Plot** → the table is moved into the accessibility tree
120
+ (`.hc-sr-only`) and the SVG chart is shown.
121
+
122
+ ## Accessibility
123
+
124
+ - The source table is **kept** (hidden with `.hc-sr-only`, not removed),
125
+ so assistive tech reads the full tabular data.
126
+ - The rendered `<svg>` is `aria-hidden="true"` — it is a decorative
127
+ duplicate of the table, so it is not announced twice.
128
+ - Give the table a `<caption>` describing the chart.
129
+
130
+ ## Server-side rendering (alternative)
131
+
132
+ Charts can also be rendered to SVG on the server with Plot under a DOM
133
+ shim (linkedom) and returned inline, with **no client Plot**. Set
134
+ explicit `marginLeft` / `marginBottom` then, since server DOM shims do not
135
+ measure text for automatic axis margins. This recipe implements the
136
+ client-side path; the SSR path is documented for completeness.
@@ -0,0 +1,40 @@
1
+ <!-- Fully expanded usage. -->
2
+
3
+ <!-- 1. Load Observable Plot (an optional peer dependency — bring your own)
4
+ and install the behavior once per page. Here Plot is the UMD global
5
+ from a CDN; installChart picks it up via window.Plot automatically,
6
+ but passing it explicitly is clearer. -->
7
+ <script src="https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/dist/plot.umd.min.js"></script>
8
+ <script type="module">
9
+ import { installChart } from '@hypermedia-components/core';
10
+ installChart(document, { plot: window.Plot });
11
+ </script>
12
+
13
+ <!-- 2. A combo chart — a bar series and a line series, declared per column
14
+ with data-mark. data-hc-chart="combo" makes "bar" the default for any
15
+ column without an explicit data-mark. -->
16
+ <figure class="hc-chart" data-hc-chart="combo" data-y-label="Sales ($k)">
17
+ <table class="hc-table">
18
+ <caption>Monthly sales vs target</caption>
19
+ <thead>
20
+ <tr>
21
+ <th>Month</th>
22
+ <th data-mark="bar">Sales</th>
23
+ <th data-mark="line">Target</th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ <tr><td>Jan</td><td>120</td><td>150</td></tr>
28
+ <tr><td>Feb</td><td>200</td><td>160</td></tr>
29
+ <tr><td>Mar</td><td>150</td><td>170</td></tr>
30
+ </tbody>
31
+ </table>
32
+ </figure>
33
+
34
+ <!-- 3. htmx — a region that swaps in a server-rendered chart table.
35
+ installChart re-renders on htmx:load, so swapped-in charts draw
36
+ themselves with no extra wiring. -->
37
+ <div data-hx-get="/reports/sales" data-hx-trigger="load" data-hx-swap="innerHTML">
38
+ <!-- The server returns the <figure class="hc-chart">…</figure> fragment.
39
+ Without JavaScript this GET still returns a readable table. -->
40
+ </div>
@@ -0,0 +1,16 @@
1
+ <!-- Short recommended usage. The <table> is the data source AND the
2
+ no-JavaScript / no-Plot accessible fallback. installChart() reads it
3
+ and draws the chart. -->
4
+ <figure class="hc-chart" data-hc-chart="bar" data-y-label="Sales ($k)">
5
+ <table class="hc-table">
6
+ <caption>Monthly sales</caption>
7
+ <thead>
8
+ <tr><th>Month</th><th>Sales</th></tr>
9
+ </thead>
10
+ <tbody>
11
+ <tr><td>Jan</td><td>120</td></tr>
12
+ <tr><td>Feb</td><td>200</td></tr>
13
+ <tr><td>Mar</td><td>150</td></tr>
14
+ </tbody>
15
+ </table>
16
+ </figure>
@@ -0,0 +1,28 @@
1
+ # confirm-action — server response contract
2
+
3
+ Purpose: confirm with the user before sending an htmx request.
4
+
5
+ ## Required client markup
6
+
7
+ - `data-hx-{post|put|patch|delete}` — destructive method and URL.
8
+ - `data-hx-trigger="hc:confirmed"` — wait for the confirm behavior to dispatch.
9
+ - `data-hc-confirm` — confirm message shown in the shared dialog.
10
+ - Optional `data-hx-target`, `data-hx-swap`.
11
+
12
+ ## Behavior flow
13
+
14
+ 1. User clicks the element.
15
+ 2. `hc.behaviors.js` intercepts the click and opens the shared confirm dialog.
16
+ 3. User confirms.
17
+ 4. Behavior dispatches `hc:confirmed` on the original element.
18
+ 5. htmx sends the request.
19
+
20
+ ## Server response
21
+
22
+ - Return HTML for the target area; or
23
+ - Return `HX-Trigger` with events such as `hc:toast`; or
24
+ - Both.
25
+
26
+ Status: any `2xx` for the swap and/or header. A non-2xx response is not
27
+ swapped (htmx ≥ 2 default); note that `hc:confirmed` has already fired
28
+ by then — the confirmation gates the *request*, not its outcome.
@@ -0,0 +1,17 @@
1
+ <!-- Fully expanded HTML. -->
2
+ <span class="hc-action">
3
+ <button
4
+ class="hc-button"
5
+ data-variant="error"
6
+ type="button"
7
+ data-hx-delete="/items/123"
8
+ data-hx-trigger="hc:confirmed"
9
+ data-hx-target="closest tr"
10
+ data-hx-swap="outerHTML"
11
+ data-hx-disabled-elt="this"
12
+ data-hx-indicator="closest .hc-action"
13
+ data-hc-confirm="Delete this item?">
14
+ Delete
15
+ </button>
16
+ <span class="hc-spinner htmx-indicator" aria-hidden="true"></span>
17
+ </span>
@@ -0,0 +1,10 @@
1
+ <!-- Short recommended usage. -->
2
+ <button
3
+ class="hc-button"
4
+ data-variant="error"
5
+ data-hc-confirm="Delete this item?"
6
+ data-hx-delete="/items/123"
7
+ data-hx-trigger="hc:confirmed"
8
+ data-hx-target="closest tr">
9
+ Delete
10
+ </button>
@@ -0,0 +1,25 @@
1
+ # data-region — server response contract
2
+
3
+ Purpose: a named region the server can re-render on demand. Other parts of the page invalidate it by dispatching a domain event.
4
+
5
+ ## Required client markup
6
+
7
+ - `<section id="..." class="hc-data-region">` with `data-hx-get` and `data-hx-swap="outerHTML"`.
8
+ - `data-hx-trigger="load, <event>:<name> from:body"` — load on first render, refresh when an event fires anywhere on the body.
9
+
10
+ ## Invalidation pattern
11
+
12
+ Other recipes (confirm-action, remote-dialog, …) can invalidate the region by returning an `HX-Trigger` header:
13
+
14
+ ```http
15
+ HX-Trigger: {"items:changed":{}}
16
+ ```
17
+
18
+ …or by dispatching the event from client behaviors after a local update.
19
+
20
+ ## Server response
21
+
22
+ Return the complete `<section>` element (same id, same class, same attributes) so the swap is idempotent. Include an empty state when there are no rows.
23
+
24
+ Status: `200 OK` with the fragment. A non-2xx response is not swapped
25
+ (htmx ≥ 2 default), so the region keeps its previous rendering.
@@ -0,0 +1,17 @@
1
+ <!-- Fully expanded HTML. -->
2
+ <section
3
+ id="items"
4
+ class="hc-data-region"
5
+ data-hx-get="/items"
6
+ data-hx-trigger="load, items:changed from:body"
7
+ data-hx-swap="outerHTML"
8
+ data-hx-indicator="closest .hc-data-region"
9
+ aria-busy="false">
10
+ <header class="hc-data-region__header">
11
+ <h2>Items</h2>
12
+ <span class="hc-spinner htmx-indicator" aria-hidden="true"></span>
13
+ </header>
14
+
15
+ <!-- server-rendered list goes here -->
16
+ <ul class="hc-list"></ul>
17
+ </section>
@@ -0,0 +1,9 @@
1
+ <!-- Short recommended usage. A named region that the server can refresh. -->
2
+ <section
3
+ id="items"
4
+ class="hc-data-region"
5
+ data-hx-get="/items"
6
+ data-hx-trigger="load, items:changed from:body"
7
+ data-hx-swap="outerHTML">
8
+ <!-- server-rendered list goes here -->
9
+ </section>
@@ -0,0 +1,73 @@
1
+ # datagrid-pager — server response contract
2
+
3
+ Purpose: paginate an `hc-datagrid` from the server with htmx. The grid is
4
+ built for paged data — the server owns the data window; htmx swaps one
5
+ page of rows; `installDatagrid()` re-initialises the swapped rows.
6
+
7
+ ## Required client markup
8
+
9
+ - The grid's `<tbody class="hc-datagrid__body" id="rows">` is the swap
10
+ target, with `data-hx-target="#rows"` and **`data-hx-swap="innerHTML"`**.
11
+ - The pager is an `hc-pagination` `<nav id="pager">`; each `.hc-pagination__item`
12
+ carries `data-hx-get="/…?page=N"`, `data-hx-target="#rows"`,
13
+ `data-hx-swap="innerHTML"`.
14
+ - Optional status text (`#rows-status`, `aria-live="polite"`).
15
+
16
+ ## Why `innerHTML` (not `outerHTML`)
17
+
18
+ `installDatagrid()` watches the **`<tbody>` element** for child changes.
19
+ Swapping the rows *inside* the tbody (`innerHTML`) keeps that element, so
20
+ the observer fires and the grid re-applies its roles, sticky offsets, and
21
+ any resized column widths to the new rows. Replacing the whole `<tbody>`
22
+ (`outerHTML`) would discard the observed node — avoid it.
23
+
24
+ ## Server response
25
+
26
+ `GET /products?page=N&size=100` returns **only the page's rows** (the
27
+ `innerHTML` of the tbody):
28
+
29
+ ```html
30
+ <tr class="hc-datagrid__row">
31
+ <td class="hc-datagrid__cell" data-frozen><input type="checkbox" class="hc-checkbox" aria-label="Select row …"></td>
32
+ <th class="hc-datagrid__cell" data-frozen data-frozen-edge scope="row">101</th>
33
+ <td class="hc-datagrid__cell" data-col="name">…</td>
34
+ <td class="hc-datagrid__cell">$…</td>
35
+ </tr>
36
+ <!-- …one <tr> per row in the page… -->
37
+ ```
38
+
39
+ Render each row with the **same column structure** as the header
40
+ (`data-frozen` / `data-frozen-edge` on frozen cells, `data-col` on
41
+ resizable/editable columns). Frozen-column `--hc-datagrid-left` offsets and
42
+ resized widths are re-applied automatically after the swap — the server
43
+ does not need to compute them per row.
44
+
45
+ Status: `200 OK` with the rows (and the out-of-band pager/status
46
+ fragments below). A non-2xx response is not swapped (htmx ≥ 2 default),
47
+ so the current page stays — surface failures via an `HX-Trigger` toast.
48
+
49
+ ### Updating the pager and status (out-of-band)
50
+
51
+ Return the new pager and status as out-of-band fragments in the same
52
+ response so they update without a second request:
53
+
54
+ ```html
55
+ <nav class="hc-pagination" id="pager" hx-swap-oob="true" aria-label="Pagination">
56
+ …items with aria-current="page" on the active page…
57
+ </nav>
58
+ <p id="rows-status" hx-swap-oob="true" aria-live="polite">101–200 / 5,000</p>
59
+ ```
60
+
61
+ Mark the current page with `aria-current="page"`, and disable Prev/Next at
62
+ the ends with `aria-disabled="true"`.
63
+
64
+ ## Notes
65
+
66
+ - **Focus.** Swapping rows removes the previously active cell; the grid
67
+ resets a tabbable cell but does not move focus. Restore focus from the
68
+ server with `HX-Retarget` / an out-of-band focus target if needed.
69
+ - **Selection** is per page unless the server re-renders selected rows with
70
+ `aria-selected="true"` (server-tracked selection across pages).
71
+ - This recipe targets the standard one-`<tbody>` rows layout. For multi-row
72
+ records (`.hc-datagrid__record` tbodies) swap a wrapping region and let
73
+ the document-level observer re-initialise the grid.
@@ -0,0 +1,50 @@
1
+ <!-- datagrid-pager — expanded form.
2
+ A frozen ID column, a status line ("X–Y / total"), and a prev/next +
3
+ numbered pager. The grid body and the pager + status are updated
4
+ together: rows swap into #rows (innerHTML); the pager and status are
5
+ returned as out-of-band fragments (hx-swap-oob) by the server. -->
6
+ <div class="hc-datagrid" style="--hc-datagrid-max-height: 24rem;">
7
+ <div class="hc-datagrid__scroll">
8
+ <table class="hc-datagrid__table">
9
+ <thead class="hc-datagrid__head">
10
+ <tr>
11
+ <th class="hc-datagrid__headcell" data-frozen scope="col" style="--hc-datagrid-left:0;">
12
+ <input type="checkbox" class="hc-checkbox" aria-label="Select all">
13
+ </th>
14
+ <th class="hc-datagrid__headcell" data-frozen data-frozen-edge scope="col" style="--hc-datagrid-left:2.5rem;">ID</th>
15
+ <th class="hc-datagrid__headcell" data-resizable data-col="name" scope="col">Name</th>
16
+ <th class="hc-datagrid__headcell" scope="col">Unit price</th>
17
+ </tr>
18
+ </thead>
19
+
20
+ <!-- Swap target. Load the first page, then the pager drives it. -->
21
+ <tbody class="hc-datagrid__body" id="rows"
22
+ data-hx-get="/products?page=1&size=100" data-hx-trigger="load"
23
+ data-hx-target="#rows" data-hx-swap="innerHTML">
24
+ <!-- Server renders the page's rows here, e.g.:
25
+ <tr class="hc-datagrid__row">
26
+ <td class="hc-datagrid__cell" data-frozen style="--hc-datagrid-left:0;"><input type="checkbox" class="hc-checkbox" aria-label="Select row 1"></td>
27
+ <th class="hc-datagrid__cell" data-frozen data-frozen-edge scope="row" style="--hc-datagrid-left:2.5rem;">1</th>
28
+ <td class="hc-datagrid__cell" data-col="name">Chai</td>
29
+ <td class="hc-datagrid__cell">$18.00</td>
30
+ </tr>
31
+ -->
32
+ </tbody>
33
+ </table>
34
+ </div>
35
+
36
+ <div class="hc-cluster" style="justify-content: space-between;">
37
+ <p id="rows-status" aria-live="polite" style="margin:0;">1–100 / 5,000</p>
38
+
39
+ <nav class="hc-pagination" id="pager" aria-label="Pagination">
40
+ <a class="hc-pagination__item" data-hc-rel="prev" data-hx-get="/products?page=1&size=100"
41
+ data-hx-target="#rows" data-hx-swap="innerHTML" aria-disabled="true" href="?page=1">Prev</a>
42
+ <a class="hc-pagination__item" aria-current="page" data-hx-get="/products?page=1&size=100"
43
+ data-hx-target="#rows" data-hx-swap="innerHTML" href="?page=1">1</a>
44
+ <a class="hc-pagination__item" data-hx-get="/products?page=2&size=100"
45
+ data-hx-target="#rows" data-hx-swap="innerHTML" href="?page=2">2</a>
46
+ <a class="hc-pagination__item" data-hc-rel="next" data-hx-get="/products?page=2&size=100"
47
+ data-hx-target="#rows" data-hx-swap="innerHTML" href="?page=2">Next</a>
48
+ </nav>
49
+ </div>
50
+ </div>