@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
@@ -0,0 +1,30 @@
1
+ <!-- datagrid-pager — server pagination for hc-datagrid (short form).
2
+ htmx swaps the rows INTO the <tbody> (innerHTML), so the <tbody> stays
3
+ put and installDatagrid's MutationObserver re-runs (roles, sticky
4
+ offsets, resized widths). The pager updates out-of-band. -->
5
+ <div class="hc-datagrid">
6
+ <div class="hc-datagrid__scroll">
7
+ <table class="hc-datagrid__table">
8
+ <thead class="hc-datagrid__head">
9
+ <tr>
10
+ <th class="hc-datagrid__headcell" data-frozen data-frozen-edge scope="col">ID</th>
11
+ <th class="hc-datagrid__headcell" scope="col">Name</th>
12
+ <th class="hc-datagrid__headcell" scope="col">Price</th>
13
+ </tr>
14
+ </thead>
15
+ <!-- The swap target: rows replace #rows' children. -->
16
+ <tbody class="hc-datagrid__body" id="rows"
17
+ data-hx-get="/products?page=1" data-hx-trigger="load"
18
+ data-hx-target="#rows" data-hx-swap="innerHTML"></tbody>
19
+ </table>
20
+ </div>
21
+
22
+ <nav class="hc-pagination" id="pager" aria-label="Pagination">
23
+ <a class="hc-pagination__item" data-hx-get="/products?page=1"
24
+ data-hx-target="#rows" data-hx-swap="innerHTML" aria-current="page" href="?page=1">1</a>
25
+ <a class="hc-pagination__item" data-hx-get="/products?page=2"
26
+ data-hx-target="#rows" data-hx-swap="innerHTML" href="?page=2">2</a>
27
+ <a class="hc-pagination__item" data-hx-get="/products?page=3"
28
+ data-hx-target="#rows" data-hx-swap="innerHTML" href="?page=3">3</a>
29
+ </nav>
30
+ </div>
@@ -0,0 +1,98 @@
1
+ # field-errors — server response contract
2
+
3
+ Purpose: render server-side validation errors next to the form fields
4
+ they belong to, with correct ARIA wiring and **zero custom JavaScript**
5
+ on the consumer side. The fragment below is the canonical wire format —
6
+ template engines and code generators can emit it verbatim.
7
+
8
+ ## Required client markup
9
+
10
+ - A `<form>` whose controls have `name` attributes (an `hc-field`
11
+ wrapper per control is recommended but not required).
12
+ - An error container the response is swapped into — inside the form
13
+ (`<div id="form-errors"></div>` + `data-hx-target="#form-errors"`),
14
+ or anywhere else if the fragment points back at the form (see
15
+ `data-hc-field-errors` below).
16
+ - `installFieldErrors()` (included in the auto-init
17
+ `@hypermedia-components/core/behaviors` bundle).
18
+
19
+ ## Server response (validation failure)
20
+
21
+ Status: `422 Unprocessable Entity` (any status works — htmx ≥ 2 does
22
+ not swap non-2xx responses by default; see “htmx wiring” below).
23
+
24
+ ```html
25
+ <div class="hc-alert" data-variant="error" role="alert" data-hc-field-errors>
26
+ <p class="hc-alert__title">Unprocessable Entity</p>
27
+ <ul class="hc-alert__errors">
28
+ <li class="hc-alert__error" data-field="email" data-code="duplicate"
29
+ data-message-key="members.email.duplicate">email: duplicate</li>
30
+ </ul>
31
+ <p class="hc-alert__body">optional hint line</p>
32
+ </div>
33
+ ```
34
+
35
+ | Part | Required | Meaning |
36
+ | --- | --- | --- |
37
+ | `.hc-alert[data-variant="error"]` | yes | The summary container (the existing alert component). `role="alert"` makes screen readers announce the swap. |
38
+ | `data-hc-field-errors` | yes | Behavior opt-in. Empty value: distribute into `closest('form')`. Non-empty value: a CSS selector for the form (use for out-of-band swaps or an alert rendered outside the form). |
39
+ | `.hc-alert__title` | no | Summary line. |
40
+ | `.hc-alert__errors` > `.hc-alert__error` | yes (≥ 0 items) | One `<li>` per field error. Repeating a `data-field` is allowed (messages render one per line). |
41
+ | `data-field` | yes (per item) | The control's `name`. Radio/checkbox groups resolve via `form.elements` (the group's shared field receives the error). Items naming no known control stay visible in the summary. |
42
+ | `data-code` | no | Machine-readable error code; passed to the message resolver as `{code}` and left on the element. |
43
+ | `data-message-key` | no | A lookup key for client-side localization. Resolved through the i18n catalog (`setMessages()`); when the catalog has no such key, the `<li>` text renders instead. Servers that localize themselves just emit final text and omit this attribute. |
44
+ | `data-message-params` | no | A JSON object of interpolation values for the catalog lookup (e.g. `data-message-params='{"stock": 5}'` for a translation using `{stock}`). Merged over the implicit `{field}`/`{code}` params (item values win). Malformed or non-object JSON is ignored — the fallback chain is unchanged. |
45
+ | `data-summary="auto"` | no | Hide the whole alert once every item was distributed (for responses that carry only field errors). |
46
+ | `data-focus="none"` | no | Don't focus the first invalid control. |
47
+ | any other `data-*` (e.g. `data-error-code`) | no | Passed through untouched — consumer-specific metadata is fine. |
48
+
49
+ ## Client behavior
50
+
51
+ On `htmx:afterSwap` / `htmx:oobAfterSwap` (plus a `MutationObserver`
52
+ fallback and an install-time scan for full-page renders),
53
+ `installFieldErrors()`:
54
+
55
+ 1. Clears all previous server errors in the target form.
56
+ 2. For each `.hc-alert__error[data-field]` whose `data-field` matches a
57
+ control: resolves the message (`data-message-key` via the i18n
58
+ catalog — interpolating `{field}`, `{code}` and any
59
+ `data-message-params` — → `<li>` text → `fieldErrors.unknown`),
60
+ writes it into the
61
+ field's `.hc-field__error` (created after a bare control when there
62
+ is no `.hc-field`), sets `aria-invalid="true"` +
63
+ `aria-describedby` on the control and `data-invalid="true"` on the
64
+ field, and marks the `<li>` `data-distributed="true"` (hidden in
65
+ the summary by CSS).
66
+ 3. Stamps the alert `data-distributed="all" | "partial" | "none"` and
67
+ focuses the first invalid control.
68
+
69
+ A field's server error is cleared as soon as the user edits that field
70
+ (`input`/`change`), when the form is submitted again or reset, and
71
+ before a newly swapped-in fragment is distributed. Native constraint
72
+ validation (`installValidation()`) outranks a server error on the same
73
+ control — the native message reflects the current value.
74
+
75
+ ## htmx wiring
76
+
77
+ htmx does not swap non-2xx responses by default. Allow the 422 swap
78
+ once, globally:
79
+
80
+ ```js
81
+ document.body.addEventListener('htmx:beforeSwap', (event) => {
82
+ if (event.detail.xhr.status === 422) {
83
+ event.detail.shouldSwap = true;
84
+ event.detail.isError = false;
85
+ }
86
+ });
87
+ ```
88
+
89
+ Alternatively respond `200` with the fragment, or send
90
+ `HX-Retarget: #form-errors` + `HX-Reswap: innerHTML` headers to
91
+ redirect an arbitrary response into the error container.
92
+
93
+ ## Progressive enhancement
94
+
95
+ Without JavaScript (full-page re-render of the form + fragment), the
96
+ summary alert renders all errors as a plain list — nothing is lost;
97
+ distribution is an enhancement. Without htmx, the install-time scan
98
+ distributes errors present in the initial HTML.
@@ -0,0 +1,44 @@
1
+ <!-- Fully expanded HTML. -->
2
+
3
+ <!-- The form. The error container is the htmx target for non-2xx
4
+ responses (wire htmx:beforeSwap to allow 422 swaps — see contract). -->
5
+ <form data-hx-post="/members"
6
+ data-hx-target="#form-errors"
7
+ data-hx-swap="innerHTML"
8
+ data-hx-disabled-elt="find button[type=submit]">
9
+ <div id="form-errors"></div>
10
+
11
+ <div class="hc-field">
12
+ <label class="hc-field__label" for="email">Email</label>
13
+ <input class="hc-input" id="email" name="email" type="email" required
14
+ aria-describedby="email-help">
15
+ <p class="hc-field__message" id="email-help">We never share it.</p>
16
+ <!-- installFieldErrors() / installValidation() fill this slot; it is
17
+ created automatically when absent. -->
18
+ <p class="hc-field__error" aria-live="polite"></p>
19
+ </div>
20
+
21
+ <button type="submit" class="hc-button" data-variant="primary">Save</button>
22
+ </form>
23
+
24
+ <!-- Server fragment, swapped into #form-errors on a 422. -->
25
+ <div class="hc-alert" data-variant="error" role="alert"
26
+ data-hc-field-errors
27
+ data-error-code="APP-FIELD-4220">
28
+ <p class="hc-alert__title">Unprocessable Entity</p>
29
+ <ul class="hc-alert__errors">
30
+ <li class="hc-alert__error"
31
+ data-field="email"
32
+ data-code="duplicate"
33
+ data-message-key="members.email.duplicate">email: duplicate</li>
34
+ </ul>
35
+ <p class="hc-alert__body">Optional hint line (e.g. an optimistic-locking conflict).</p>
36
+ </div>
37
+
38
+ <!-- After distribution the behavior has:
39
+ - written the message into the field's .hc-field__error,
40
+ - set aria-invalid + aria-describedby on the input and
41
+ data-invalid on the field,
42
+ - marked the <li> data-distributed="true" (hidden in the summary),
43
+ - stamped the alert data-distributed="all|partial|none",
44
+ - focused the first invalid control (opt out: data-focus="none"). -->
@@ -0,0 +1,21 @@
1
+ <!-- Short recommended usage. -->
2
+ <form data-hx-post="/members" data-hx-target="#form-errors" data-hx-swap="innerHTML">
3
+ <div id="form-errors"></div>
4
+
5
+ <div class="hc-field">
6
+ <label class="hc-field__label" for="email">Email</label>
7
+ <input class="hc-input" id="email" name="email" type="email" required>
8
+ </div>
9
+
10
+ <button type="submit" class="hc-button" data-variant="primary">Save</button>
11
+ </form>
12
+
13
+ <!-- Server response on validation failure (422). installFieldErrors()
14
+ distributes each item to the field named by data-field. -->
15
+ <div class="hc-alert" data-variant="error" role="alert" data-hc-field-errors>
16
+ <p class="hc-alert__title">Please fix the errors below.</p>
17
+ <ul class="hc-alert__errors">
18
+ <li class="hc-alert__error" data-field="email" data-code="duplicate"
19
+ data-message-key="members.email.duplicate">email: duplicate</li>
20
+ </ul>
21
+ </div>
@@ -0,0 +1,18 @@
1
+ # filter-popover — server response contract
2
+
3
+ Purpose: a popover that holds a filter form. Submitting the form sends an htmx request and closes the popover.
4
+
5
+ ## Required client markup
6
+
7
+ - A trigger `<button popovertarget="...">`.
8
+ - A `<div class="hc-popover" popover>` with a form inside.
9
+ - The form has `data-hx-get` (or `post`), `data-hx-target`, and `data-hc-close-popover-on-success`.
10
+
11
+ ## Server response
12
+
13
+ - Return HTML for the `data-hx-target` element.
14
+ - Status `2xx` triggers `hc.behaviors.js` to call `hidePopover()` on the closest `[popover]`.
15
+
16
+ ## Accessibility
17
+
18
+ The popover is not implicitly a menu. If you need menu keyboard behavior, build it explicitly with roles and handlers — do not assume the popover provides it.
@@ -0,0 +1,25 @@
1
+ <!-- Fully expanded HTML. -->
2
+ <button
3
+ class="hc-button"
4
+ type="button"
5
+ popovertarget="filter-popover"
6
+ aria-expanded="false">
7
+ Filter
8
+ </button>
9
+
10
+ <div id="filter-popover" class="hc-popover" popover>
11
+ <form
12
+ class="hc-form"
13
+ data-hx-get="/items"
14
+ data-hx-target="#results"
15
+ data-hx-swap="innerHTML"
16
+ data-hc-close-popover-on-success>
17
+ <!-- filter fields here -->
18
+ <footer class="hc-popover__footer">
19
+ <button class="hc-button" type="reset">Reset</button>
20
+ <button class="hc-button" type="submit" data-variant="primary">Apply</button>
21
+ </footer>
22
+ </form>
23
+ </div>
24
+
25
+ <div id="results"></div>
@@ -0,0 +1,17 @@
1
+ <!-- Short recommended usage. -->
2
+ <button class="hc-button" type="button" popovertarget="filter-popover">
3
+ Filter
4
+ </button>
5
+
6
+ <div id="filter-popover" class="hc-popover" popover>
7
+ <form
8
+ class="hc-form"
9
+ data-hx-get="/items"
10
+ data-hx-target="#results"
11
+ data-hc-close-popover-on-success>
12
+ <!-- filter fields here -->
13
+ <button class="hc-button" type="submit" data-variant="primary">Apply</button>
14
+ </form>
15
+ </div>
16
+
17
+ <div id="results"></div>
@@ -0,0 +1,58 @@
1
+ # inline-edit — server response contract
2
+
3
+ Purpose: toggle a cell between a display rendering and an editable
4
+ form, swapping the same DOM node each way. No client behavior beyond
5
+ htmx — the recipe is purely a server-side state machine.
6
+
7
+ ## Endpoints
8
+
9
+ | Method | URL | Returns |
10
+ | ------ | ---------------------- | ------------------------------------------ |
11
+ | GET | `/items/:id/name` | Display fragment (HTML) |
12
+ | GET | `/items/:id/name/edit` | Edit-form fragment (HTML) |
13
+ | PUT | `/items/:id/name` | 200 + display fragment, **or** 422 + edit fragment |
14
+
15
+ All three return HTML, never JSON. The Cancel button hits
16
+ `GET /items/:id/name` to re-render the display state.
17
+
18
+ ## Required client markup
19
+
20
+ - Display and edit fragments share the same `id` so the
21
+ `data-hx-swap="outerHTML"` swap replaces the entire node each way.
22
+ - Display fragment: `data-hx-get="…/edit"`,
23
+ `data-hx-trigger="click"`, `data-hx-target="this"`.
24
+ - Edit fragment: `<form data-hx-put="…">`,
25
+ `data-hx-target="this"`, `data-hx-swap="outerHTML"`.
26
+
27
+ ## Save flow
28
+
29
+ 1. User clicks the display node. htmx fetches `GET /items/42/name/edit`
30
+ and swaps the response in place.
31
+ 2. User types and submits the form.
32
+ 3. Server validates.
33
+ - **Success** — return the updated display fragment with 200.
34
+ `outerHTML` swap replaces the `<form>` with the new `<span>`;
35
+ htmx re-processes the new attributes.
36
+ - **Failure** — return the edit fragment with 422 (or 200 +
37
+ `HX-Reswap: outerHTML`). Include `aria-invalid="true"` and an
38
+ `aria-describedby` message inside an `.hc-field[data-invalid]`
39
+ wrapper.
40
+
41
+ ## htmx settings
42
+
43
+ For 4xx responses to be swapped (so validation errors appear in the
44
+ page), one of:
45
+
46
+ - Set the global `htmx.config.responseHandling` to treat 422 as
47
+ swap-eligible; or
48
+ - Add `HX-Reswap: outerHTML` and `HX-Retarget: this` headers; or
49
+ - Use `data-hx-target-422="this"` on the form to opt the specific
50
+ status code in.
51
+
52
+ ## Optimistic interactions
53
+
54
+ If you want save-on-blur instead of an explicit Save button, add
55
+ `data-hx-trigger="blur"` on the input and skip the buttons. Keep the
56
+ form wrapper for `name`-based field submission. The Cancel path is
57
+ then `Escape` plus a small inline behavior that calls
58
+ `GET /items/:id/name`.
@@ -0,0 +1,70 @@
1
+ <!-- Fully expanded HTML.
2
+
3
+ Three server-rendered fragments at the same id share one DOM slot;
4
+ htmx swaps between them with `outerHTML`. No client behavior beyond
5
+ htmx itself. -->
6
+
7
+ <!-- 1. Display state (initial render and after successful save).
8
+ URL: GET /items/42/name -->
9
+ <span
10
+ id="item-42-name"
11
+ data-hx-get="/items/42/name/edit"
12
+ data-hx-trigger="click"
13
+ data-hx-target="this"
14
+ data-hx-swap="outerHTML"
15
+ style="cursor: pointer;">
16
+ Acme widgets
17
+ </span>
18
+
19
+ <!-- 2. Edit state (returned by GET /items/42/name/edit). -->
20
+ <form
21
+ id="item-42-name"
22
+ data-hx-put="/items/42/name"
23
+ data-hx-target="this"
24
+ data-hx-swap="outerHTML"
25
+ style="display: inline-flex; gap: .25rem;">
26
+ <input
27
+ name="name"
28
+ class="hc-input"
29
+ data-size="sm"
30
+ value="Acme widgets"
31
+ autofocus>
32
+ <button class="hc-button" data-size="sm" data-variant="primary" type="submit">
33
+ Save
34
+ </button>
35
+ <button class="hc-button" data-size="sm" type="button"
36
+ data-hx-get="/items/42/name"
37
+ data-hx-target="this"
38
+ data-hx-swap="outerHTML">
39
+ Cancel
40
+ </button>
41
+ </form>
42
+
43
+ <!-- 3. Edit state with a validation error (PUT returned 422 with
44
+ HX-Reswap: outerHTML or `data-hx-swap-oob`). Same id so the
45
+ outerHTML swap targets the same node. -->
46
+ <form
47
+ id="item-42-name"
48
+ data-hx-put="/items/42/name"
49
+ data-hx-target="this"
50
+ data-hx-swap="outerHTML">
51
+ <div class="hc-field" data-invalid="true">
52
+ <input
53
+ class="hc-input"
54
+ data-size="sm"
55
+ name="name"
56
+ value=""
57
+ aria-invalid="true"
58
+ aria-describedby="item-42-name-error">
59
+ <p id="item-42-name-error" class="hc-field__message">Name is required.</p>
60
+ </div>
61
+ <button class="hc-button" data-size="sm" data-variant="primary" type="submit">
62
+ Save
63
+ </button>
64
+ <button class="hc-button" data-size="sm" type="button"
65
+ data-hx-get="/items/42/name"
66
+ data-hx-target="this"
67
+ data-hx-swap="outerHTML">
68
+ Cancel
69
+ </button>
70
+ </form>
@@ -0,0 +1,12 @@
1
+ <!-- Short recommended usage.
2
+
3
+ The display state of an editable cell. Clicking swaps it with the
4
+ edit-form fragment returned by /items/42/name/edit. -->
5
+ <span
6
+ id="item-42-name"
7
+ data-hx-get="/items/42/name/edit"
8
+ data-hx-trigger="click"
9
+ data-hx-target="this"
10
+ style="cursor: pointer;">
11
+ Acme widgets
12
+ </span>
@@ -0,0 +1,67 @@
1
+ # lazy-panel — server response contract
2
+
3
+ Purpose: defer a region's content fetch until the user actually
4
+ encounters it — scroll, accordion open, or tab activation. The
5
+ recipe is purely htmx attributes; no behavior helper is needed.
6
+
7
+ ## Required client markup
8
+
9
+ One of three trigger forms, all `once` so the fetch never repeats:
10
+
11
+ | Trigger | Activated by |
12
+ | ---------------------------------------- | -------------------------------------- |
13
+ | `intersect once` | The panel scrolls into the viewport. |
14
+ | `toggle from:closest details once` | An ancestor `<details>` opens. |
15
+ | `reveal once` | The panel's `hidden` attribute is removed (tab activation). |
16
+
17
+ Required attributes on the panel:
18
+
19
+ - `data-hx-get="…"` — the URL that returns the panel content.
20
+ - `data-hx-trigger="…"` — one of the forms above.
21
+ - `data-hx-swap="innerHTML"` — replace the placeholder, keep the
22
+ wrapper.
23
+
24
+ Optional:
25
+
26
+ - `data-hx-indicator="this"` to fade in a spinner / skeleton while
27
+ the request is in flight (style via `.htmx-indicator`).
28
+
29
+ ## Server response
30
+
31
+ Return the panel body HTML. No special headers required. If the
32
+ panel needs cache headers (typical for dashboards), set them as
33
+ usual:
34
+
35
+ ```http
36
+ HTTP/1.1 200 OK
37
+ Content-Type: text/html; charset=utf-8
38
+ Cache-Control: private, max-age=60
39
+
40
+ <div class="hc-card">
41
+ ...
42
+ </div>
43
+ ```
44
+
45
+ ## Failure handling
46
+
47
+ A 4xx/5xx leaves the placeholder in place by default. To show an
48
+ error message in the same slot, return the error body with
49
+ `HX-Reswap: innerHTML`:
50
+
51
+ ```http
52
+ HTTP/1.1 503 Service Unavailable
53
+ HX-Reswap: innerHTML
54
+
55
+ <p class="hc-alert" data-variant="error" role="alert">
56
+ Reports are temporarily unavailable. Refresh in a minute.
57
+ </p>
58
+ ```
59
+
60
+ ## Combined with toast
61
+
62
+ Server can also signal a toast in the same response by adding
63
+ `HX-Trigger` — useful for non-fatal warnings ("data is stale").
64
+
65
+ ```http
66
+ HX-Trigger: {"hc:toast":{"message":"Data may be up to 5 minutes old","variant":"warning"}}
67
+ ```
@@ -0,0 +1,68 @@
1
+ <!-- Fully expanded HTML.
2
+
3
+ Three trigger variants for the same "load on first reveal" pattern.
4
+ Pick the one that matches how the panel becomes relevant. -->
5
+
6
+ <!-- Variant 1 — on intersection (panel scrolls into view).
7
+ `intersect once threshold:0.25` waits until 25% is visible. -->
8
+ <section
9
+ data-hx-get="/dashboards/usage"
10
+ data-hx-trigger="intersect once"
11
+ data-hx-swap="innerHTML"
12
+ data-hx-indicator="this">
13
+ <p class="hc-field__message">Loading when visible…</p>
14
+ </section>
15
+
16
+ <!-- Variant 2 — on <details> open (accordion).
17
+ `from:closest details` listens for the toggle on the ancestor.
18
+ `once` makes sure the fetch never repeats. -->
19
+ <details>
20
+ <summary>Advanced settings</summary>
21
+ <div
22
+ data-hx-get="/settings/advanced"
23
+ data-hx-trigger="toggle from:closest details once"
24
+ data-hx-swap="innerHTML"
25
+ data-hx-indicator="this">
26
+ <p class="hc-field__message">Loading…</p>
27
+ </div>
28
+ </details>
29
+
30
+ <!-- Variant 3 — on tab activation.
31
+ The tablist owns the aria-selected / hidden flipping (any tab
32
+ library will do). The inactive tab panel uses `reveal once`,
33
+ which fires when its `hidden` attribute is removed. -->
34
+ <div role="tablist" aria-label="Reports">
35
+ <button
36
+ role="tab"
37
+ aria-controls="panel-overview"
38
+ aria-selected="true"
39
+ data-tab-target="panel-overview">
40
+ Overview
41
+ </button>
42
+ <button
43
+ role="tab"
44
+ aria-controls="panel-revenue"
45
+ aria-selected="false"
46
+ data-tab-target="panel-revenue">
47
+ Revenue
48
+ </button>
49
+ </div>
50
+
51
+ <section
52
+ id="panel-overview"
53
+ role="tabpanel"
54
+ data-hx-get="/reports/overview"
55
+ data-hx-trigger="load"
56
+ data-hx-swap="innerHTML"
57
+ data-hx-indicator="this">
58
+ </section>
59
+
60
+ <section
61
+ id="panel-revenue"
62
+ role="tabpanel"
63
+ hidden
64
+ data-hx-get="/reports/revenue"
65
+ data-hx-trigger="reveal once"
66
+ data-hx-swap="innerHTML"
67
+ data-hx-indicator="this">
68
+ </section>
@@ -0,0 +1,11 @@
1
+ <!-- Short recommended usage.
2
+
3
+ A region whose content is fetched on first reveal. `intersect once`
4
+ uses IntersectionObserver and only fires the first time the panel
5
+ enters the viewport. -->
6
+ <section
7
+ data-hx-get="/dashboards/usage"
8
+ data-hx-trigger="intersect once"
9
+ data-hx-indicator="this">
10
+ <p class="hc-field__message">Loading when visible…</p>
11
+ </section>
@@ -0,0 +1,21 @@
1
+ # live-search — server response contract
2
+
3
+ Purpose: send a search request as the user types and swap the results.
4
+
5
+ ## Required client markup
6
+
7
+ - `<form role="search">` with a `GET` action so it works without JavaScript.
8
+ - `data-hx-get` on the input — same URL as the form action.
9
+ - `data-hx-trigger="input changed delay:300ms, search"` — debounce typing, also respond to the `search` event.
10
+ - `data-hx-target="#results"` and `data-hx-swap="innerHTML"`.
11
+ - `data-hx-sync="closest form:replace"` — cancel in-flight requests when a newer one starts.
12
+
13
+ ## Server response
14
+
15
+ - Return HTML for `#results`.
16
+ - Include empty-state markup when there are no results.
17
+ - Keep the normal form `GET` working without JavaScript.
18
+
19
+ Status: `200 OK` with the fragment. htmx ≥ 2 does not swap non-2xx
20
+ responses by default, so on a server error the previous results stay in
21
+ place — return `2xx` only when the fragment should replace them.
@@ -0,0 +1,17 @@
1
+ <!-- Fully expanded HTML. -->
2
+ <form class="hc-search" action="/items" method="get" role="search">
3
+ <input
4
+ class="hc-input"
5
+ type="search"
6
+ name="q"
7
+ placeholder="Search"
8
+ data-hx-get="/items"
9
+ data-hx-trigger="input changed delay:300ms, search"
10
+ data-hx-target="#results"
11
+ data-hx-swap="innerHTML"
12
+ data-hx-sync="closest form:replace">
13
+
14
+ <button class="hc-button" type="submit">Search</button>
15
+ </form>
16
+
17
+ <div id="results"></div>
@@ -0,0 +1,15 @@
1
+ <!-- Short recommended usage. -->
2
+ <form class="hc-search" action="/items" method="get" role="search">
3
+ <input
4
+ class="hc-input"
5
+ type="search"
6
+ name="q"
7
+ placeholder="Search"
8
+ data-hx-get="/items"
9
+ data-hx-trigger="input changed delay:300ms, search"
10
+ data-hx-target="#results"
11
+ data-hx-swap="innerHTML">
12
+ <button class="hc-button" type="submit">Search</button>
13
+ </form>
14
+
15
+ <div id="results"></div>
@@ -0,0 +1,20 @@
1
+ # remote-dialog — server response contract
2
+
3
+ Purpose: open a dialog whose contents are fetched from the server.
4
+
5
+ ## Required client markup
6
+
7
+ - Trigger element with `data-hx-get`, `data-hx-target="#dialog-root"`, `data-hx-swap="innerHTML"`.
8
+ - Mount point `<div id="dialog-root" data-hc-remote-dialog-root></div>` somewhere on the page.
9
+
10
+ ## Server response
11
+
12
+ Return a complete `<dialog class="hc-dialog">` element with the dialog content (title, body, footer, form). The dialog should not already be `open`.
13
+
14
+ Status: `200 OK` with the fragment. A non-2xx response is not swapped
15
+ (htmx ≥ 2 default), so no dialog opens — surface the failure via an
16
+ `HX-Trigger` toast (see the toast recipe) if the user needs feedback.
17
+
18
+ ## Client behavior
19
+
20
+ After the swap into `#dialog-root`, `hc.behaviors.js` finds the first `<dialog>` and calls `showModal()`. Forms inside the dialog can use `data-hc-close-dialog-on-success` to close after a successful submission.
@@ -0,0 +1,18 @@
1
+ <!-- Fully expanded HTML — what the server returns inside #dialog-root. -->
2
+ <dialog class="hc-dialog">
3
+ <header class="hc-dialog__header">
4
+ <h2 class="hc-dialog__title">Edit item</h2>
5
+ </header>
6
+
7
+ <form
8
+ class="hc-form"
9
+ data-hx-post="/items/123"
10
+ data-hx-target="closest dialog"
11
+ data-hx-swap="outerHTML">
12
+ <!-- form fields here -->
13
+ <footer class="hc-dialog__footer">
14
+ <button class="hc-button" type="button">Cancel</button>
15
+ <button class="hc-button" data-variant="primary" type="submit">Save</button>
16
+ </footer>
17
+ </form>
18
+ </dialog>
@@ -0,0 +1,10 @@
1
+ <!-- Short recommended usage. -->
2
+ <button
3
+ class="hc-button"
4
+ data-hx-get="/items/123/edit"
5
+ data-hx-target="#dialog-root"
6
+ data-hx-swap="innerHTML">
7
+ Edit
8
+ </button>
9
+
10
+ <div id="dialog-root" data-hc-remote-dialog-root></div>