@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.
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/bin/hc-cli.mjs +70 -0
- package/lib/recipes.mjs +77 -0
- package/package.json +30 -0
- package/recipes/chart/contract.md +136 -0
- package/recipes/chart/expanded.html +40 -0
- package/recipes/chart/recipe.html +16 -0
- package/recipes/confirm-action/contract.md +28 -0
- package/recipes/confirm-action/expanded.html +17 -0
- package/recipes/confirm-action/recipe.html +10 -0
- package/recipes/data-region/contract.md +25 -0
- package/recipes/data-region/expanded.html +17 -0
- package/recipes/data-region/recipe.html +9 -0
- package/recipes/datagrid-pager/contract.md +73 -0
- package/recipes/datagrid-pager/expanded.html +50 -0
- package/recipes/datagrid-pager/recipe.html +30 -0
- package/recipes/field-errors/contract.md +98 -0
- package/recipes/field-errors/expanded.html +44 -0
- package/recipes/field-errors/recipe.html +21 -0
- package/recipes/filter-popover/contract.md +18 -0
- package/recipes/filter-popover/expanded.html +25 -0
- package/recipes/filter-popover/recipe.html +17 -0
- package/recipes/inline-edit/contract.md +58 -0
- package/recipes/inline-edit/expanded.html +70 -0
- package/recipes/inline-edit/recipe.html +12 -0
- package/recipes/lazy-panel/contract.md +67 -0
- package/recipes/lazy-panel/expanded.html +68 -0
- package/recipes/lazy-panel/recipe.html +11 -0
- package/recipes/live-search/contract.md +21 -0
- package/recipes/live-search/expanded.html +17 -0
- package/recipes/live-search/recipe.html +15 -0
- package/recipes/remote-dialog/contract.md +20 -0
- package/recipes/remote-dialog/expanded.html +18 -0
- package/recipes/remote-dialog/recipe.html +10 -0
- package/recipes/request-action/contract.md +29 -0
- package/recipes/request-action/expanded.html +15 -0
- package/recipes/request-action/recipe.html +14 -0
- package/recipes/toast/contract.md +71 -0
- package/recipes/toast/expanded.html +42 -0
- 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>
|