Saved views
The current search’s querystring, named and kept server-side. Saving
posts the filter form’s pairs plus a name (data-hx-include);
applying a view is a plain GET link — bookmarkable, shareable, zero
client state; deleting answers the re-rendered strip. When a view is
applied, the server re-renders the filter form with the controls
filled — the querystring a view expands to is always visible, so a
view is never opaque. Zero new JavaScript. Covered by the
versioning policy.
Also known as: saved filters, saved searches.
Live demo
Section titled “Live demo”Filter, name the result, Save view — the new chip lands in the
strip marked current. Click a chip to apply it (watch the filter
controls fill), × to delete it, and save the same name twice for the
422 branch. This demo stores nothing: each chip’s link carries the
view’s full querystring — a real app stores views per user.
The markup
Section titled “The markup”<form id="filters" action="/items" method="get" data-hx-get="/items" data-hx-target="#results"> <!-- …the filter fields: q, status, …, and an Apply button… --></form>
<form method="post" action="/views" data-hx-post="/views" data-hx-include="#filters" data-hx-target="#views" data-hx-disabled-elt="find button[type=submit]"> <div class="hc-field"> <label class="hc-field__label" for="view-name">View name</label> <input class="hc-input" id="view-name" name="name" required> </div> <button class="hc-button" data-variant="primary" type="submit">Save view</button></form>
<div id="views"> <ul class="hc-chips"> <li class="hc-chip"> <a href="/items?view=quarterly" data-hx-get="/items?view=quarterly" data-hx-target="#results">quarterly</a> <button class="hc-button" data-size="sm" type="button" aria-label="Delete view quarterly" data-hx-delete="/views/quarterly" data-hx-target="#views">×</button> </li> </ul></div>
<div id="results" aria-live="polite"></div>The save form pulls the filter pairs in with
data-hx-include="#filters" and targets the strip region — the
response is the #views contents, never a client-side splice. Each
chip is a plain apply link plus a delete button whose accessible name
names the view.
Server response contract
Section titled “Server response contract”| Case | Response |
|---|---|
POST /views (name + filter pairs) | 200 + the strip fragment, the new chip marked current (aria-current="true") |
| duplicate name | 422 + the strip with an inline field error (the field-errors shape, data-field="name") — swaps via the standard 422 allowance |
GET /items?view=<name> | the list fragment for #results with that view’s filters applied, plus an OOB outerHTML re-render of the filter form with the controls filled — a view is never opaque |
PUT /views/<name> (the current filter pairs) | 200 + the strip — update in place, so a corrected view keeps its name and every shared link |
POST /views/<name>/default | 200 + the strip with that view marked default |
DELETE /views/<name> | 200 + the strip |
| bare list URL with a default set | 303 to the default view’s URL |
The view’s name is its key (URL-encoded in the path). Storage, limits and scope — personal, shared, default — are the server’s business; the wire contract is the same either way. Because the applied querystring lands back in the controls, a view stays editable: apply, tweak a field, save under a new name.
Modified state
Section titled “Modified state”Applying a view and then changing one condition is the commonest thing users do with saved views, and the least served: nothing said whether what you are looking at is still the view. The user either loses the tweak or trusts a saved version that is not on screen.
The apply link names the view it came from (&from-view=<name>), so the
server can compare the incoming conditions with the stored ones and mark
the chip when they differ:
<li class="hc-chip" data-modified> <a href="…" aria-current="true">Overdue shipments</a> <span class="hc-badge" data-variant="warning">Modified</span> <button type="button" data-hx-put="/views/Overdue%20shipments" data-hx-include="#filters" data-hx-target="#views">Update</button> <a href="/items?view=Overdue%20shipments">Reset</a></li>Compare normalized querystrings — same params, sorted — or the same question will look different depending on whether it arrived from the form or from a link.
PUT /views/<name> updates in place, so correcting a view keeps its
name and every link already shared. 422 duplicate stays, but only for
a genuinely new name that collides.
Reset is a link, never <button type="reset">. A native reset
restores the values the server rendered into the controls — which, after
an apply plus a tweak, are the modified state. Point it at the view’s
own URL and let the apply response fill the form.
Where recall lives
Section titled “Where recall lives”Recall belongs on the screen, not inside the filter editor. A view is a named URL, so applying one is navigation; behind a dialog it costs four interactions for the screen’s most frequent act, and it mixes a “go there” verb into a “build a condition” surface. Naming what you built is the terminal step of composing, so saving stays with the editor while recall sits beside the screen title.
The chips strip above is the small-set shape. Past a handful of views,
render the same links as an hc-menu
whose button label is the applied view’s name:
<button class="hc-button" id="view-trigger" popovertarget="views" type="button"> <span>Overdue shipments</span> <span class="hc-badge" data-variant="warning">Modified</span></button>
<div class="hc-menu" id="views" popover role="menu" aria-labelledby="view-trigger"> <a class="hc-menu__item" role="menuitemradio" aria-checked="true" href="/items?view=overdue">Overdue shipments</a> <a class="hc-menu__item" role="menuitemradio" aria-checked="false" href="/items">Show everything</a></div>menuitemradio, because exactly one view is applied at a time, with Show everything as the none-of-them option — the way back when a default view redirected the bare list URL.- Still real
<a href>s: bookmarkable, middle-clickable, no-JS. - The server renders the menu, so it owns pinned / recent order and
which item carries
aria-checked="true"— the call it already makes for the strip.
The data grid page template shows it in place.
Saving asks three things
Section titled “Saving asks three things”A dialog that asks only for a name pushes the other two decisions onto whoever notices later:
| Field | Why it is asked here |
|---|---|
| name | the view’s key — URL-encoded in view= and in the DELETE path |
| scope — personal / shared | a department standard is the normal case, and silently forking a colleague’s view is an accident |
| default | a screen that opens on the wrong question wastes a step every day; the bare list URL then 303s to it, so the address bar still shows the real conditions |
<fieldset class="hc-field"> <legend class="hc-field__label">Who can see it</legend> <label class="hc-radio-label"> <input class="hc-radio" type="radio" name="scope" value="personal" checked> Only me </label> <label class="hc-radio-label"> <input class="hc-radio" type="radio" name="scope" value="shared"> My team </label></fieldset>
<label class="hc-checkbox-label"> <input class="hc-checkbox" type="checkbox" name="default" value="1"> Open this screen on it</label>- At most one default — a screen that opens on two questions has none; saving a new default clears the old.
- Scope and default are not conditions.
PUT /views/<name>corrects what the view asks, and must never silently re-home it or move the default. - Shared views are labelled in the strip, and editing one is a distinct, visible act — offer “copy to my views” instead of forking.
Copy link sits beside Save, because a view is a URL:
data-hc-copy-text puts the
apply URL on the clipboard, no shared object involved. Most “can you
send me that list?” moments are a link, not a new object; reserve
shared views for standards that outlive a conversation.
What a view captures
Section titled “What a view captures”A view is a question you want to ask again, not a place you were standing:
| In | Out |
|---|---|
| filter conditions | page number |
| sort (ordered, multi-column) | row selection |
| column set / order, if the view pins them | scroll position |
| page size, grouping | expanded rows |
Page number is the dangerous one — “page 7 of yesterday’s data” means nothing, and a shared link lands the recipient somewhere else.
Columns are a preference first. A user’s column choice follows them between screens, so it is stored per user rather than pushed into every URL. A view may pin a column set — “Shipping check” usually means the filters and the columns for that job — and when it does, applying it visibly changes the columns and offers a way back. Resolution order is always URL → user preference → app default, which is what makes a shared link beat a stored layout.
Scope, default, and re-authorisation
Section titled “Scope, default, and re-authorisation”- Scope. Views are not necessarily personal; a department standard is the normal case. The server owns scope, the strip labels shared views, and editing one is a distinct, visible action — offer “copy to my views” rather than silently forking a colleague’s.
- Default. A bare list URL may
303to the default view’s URL, so the address bar always shows the real conditions. A default must never be a hidden filter. - Re-authorisation. A stored view can hold conditions the user has
since lost the right to run. Re-check on every apply and fail
closed (
403, or re-ask) — quietly dropping the condition would widen the result set, the one failure a business screen cannot afford. - Ordering. Comfortable at five views, unusable at thirty: pinned and recently-used first, the rest behind a menu, and say when the strip was capped.
Progressive enhancement
Section titled “Progressive enhancement”Apply links are real hrefs — bookmarkable, shareable, full-page
navigations without JavaScript. The save form keeps method="post" +
action="/views", so a native submit works (answer a full page or a
classic 303 post/redirect/get). The delete button is htmx-only; wrap
it in a tiny POST form if deletion must work without JavaScript.
Accessibility
Section titled “Accessibility”- The strip is a real list —
ul.hc-chipsannounces as “list, N items” — andaria-current="true"marks the current view. - Every × has an accessible name naming its view (“Delete view quarterly”), not a bare glyph.
- The results region is
aria-live="polite", so applying a view announces the new list without stealing focus; the duplicate-name error is arole="alert"field-errors fragment. - The OOB filter re-render keeps ids stable, so
<label for>associations survive.
Related
Section titled “Related”- filter-popover — where the filter form itself often lives.
- live-search — the
as-you-type sibling for the same
qinput. - field-errors — the 422 shape the duplicate-name branch answers with.
- data-region — keeping the result list fresh once views select it.