Date range

Date range forms ControllerEndpoint

Two native date inputs driving one htmx exchange — the from/to filter bar for time-scoped regions.

Layer: L1 surface · Recipe: unset — see docs/agent/pick-a-surface.md. Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.

Copy this

html
<div class="date-range-picker date-range-bar" data-date-range>
  <label class="date-range-label" for="hm-dr-from">From</label>
  <span class="date-range-group"><input type="date" id="hm-dr-from" name="date_from" value="2026-06-01" class="date-range-input" hx-get="/mock/search" hx-target="#hm-dr-out" hx-swap="innerHTML" hx-include="closest .date-range-bar"><input data-date-iso class="date-range-iso" type="text" spellcheck="false" autocomplete="off" aria-label="From ISO date" value="2026-06-01"></span>
  <label class="date-range-label" for="hm-dr-to">To</label>
  <span class="date-range-group"><input type="date" id="hm-dr-to" name="date_to" value="2026-06-30" class="date-range-input" hx-get="/mock/search" hx-target="#hm-dr-out" hx-swap="innerHTML" hx-include="closest .date-range-bar"><input data-date-iso class="date-range-iso" type="text" spellcheck="false" autocomplete="off" aria-label="To ISO date" value="2026-06-30"></span>
  <div id="hm-dr-out" hidden></div>
</div>

Server exchange

When the client affordance finishes (click, confirm, keystroke…), htmx issues this request. Your API must return the response fragment in the table — usually HTML, not JSON (unless the partial says otherwise). Dazzle often renders these routes from the app model; a standalone HTMX4 app implements them explicitly. The Envelope column is the exchange envelope (part of the Swap contract) — what the response may re-emit relative to the persistent slot.

Do not reimplement the gallery. Flash toasts (e.g. “Deleted (demo).”), /mock/* paths, and other static-site scaffolding are demo-only (MOCK_HTMX in site/build_site.py). They are not Hyperpart surface and not a product API. If an agent is stuck “making the toast work,” stop — implement the exchange row below instead.

RequestTriggerResponse fragmentSwapEnvelopeStates
GET /app/{region}?date_from=&date_to=either date input's change — hx-include sends both boundsthe re-rendered region body for the new rangeinnerHTMLbody_only—

Swap contract

Agent-visible HTMX topology (ADR-0054 / decision 0012). exchange envelope = what the response may re-emit relative to the persistent slot (body_only | outer | none | host_owned | document). dual-lock validates part markup only — not this envelope. Stem: stems/morph-safe-hypermedia.md; decision: docs/decisions/0012-swap-identity-contract.md.

Gallery mocks may approximate morph with innerHTML — production follows the Swap + Envelope columns in Server exchange.

Exchanges (swap · envelope)

  • GET /app/{region}?date_from=&date_to= → innerHTML · envelope=body_only

Envelope rules

  • body_only — innerHTML / innerMorph into a slot; response is interior only (no re-wrap of slot id / nested data-dz-region).
  • outer — outerHTML / outerMorph; response may carry identity.
  • none — no HTML swap (JSON/204/bytes; client or OOB companion).
  • host_owned — swap target/mode chosen by the host button’s hx-target / hx-swap.
  • document — full navigation / document load (not a fragment).
  • Slot owns stable id / domain keys; state in DOM, not Alpine.

Envelope response examples

What the server returns for each exchange on Date range. Match the exchange envelope; dual-lock still applies to interior markup.

GET /app/{region}?date_from=&date_to= · envelope=body_only

Correct response for body_only into #{region}-body (innerHTML / innerMorph). Wrong: re-wrapping the slot.

Do — correct response body

html
<!-- envelope=body_only → re-rendered region body for the range -->
<div class="dz-stack" data-dz-gap="sm">
  <!-- metrics / rows for date_from..date_to -->
</div>

Don’t — violates body_only

html
<!-- WRONG: date-range chrome re-emitted into the region body -->
<div id="{region}-body" data-dz-region>
  <input type="date" name="date_from" />
  <input type="date" name="date_to" />
</div>

How to use it

Seams

  • root data-date-range owns both native date inputs
  • native type=date is the submitted value; [data-date-iso] is the editable companion
  • hx-include closest .date-range-bar sends both bounds on either change

Do / Don't

DoDon't
write a valid companion ISO into the date; refuse leftover junkPOST From after To and let the server return an unexplained empty list

Pitfalls

  • inverted From>To must not hx-get a silent empty region
  • empty either bound is an open range — do not invent a missing date
  • leftover ISO junk must not invent a bound (do not revert on blur)

Keyboard / AT

  • native type=date keeps the platform picker and constraint UI
  • custom validity names the inversion so the bubble is not a generic required miss
  • companion has aria-label; leftover junk fails custom validity

DOM contract

What the emitted HTML must satisfy — the table is the required surface; Python under contracts/ is the package-internal dual-lock CI runs (tests/test_contracts.py), not an app route. Standalone HTMX4: implement the API so responses match this markup. Dazzle: the agent emits SSR that already satisfies it. Do not invent attrs outside these tables. For request/response wiring see Server exchange.

contracts/date_range.py

Required in the DOM: root [data-date-range] (part date-range). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).

NodeAttrConstraint
[data-date-range]data-date-rangepresent (any value)
[data-date-iso]data-date-isopresent (any value)

Ingestion model DateRange

Server-side shape before render — one normalisation boundary for producers.

FieldTypeRequired
region_namestringoptional
endpointstringoptional
date_fromstringoptional
date_tostringoptional
targetstringoptional
include_closedstringoptional
as_ofstringoptional

Exemplar render()

Executable in CI: the Python below is render(); the boxed preview is render(EXEMPLARS[0]) — the first fixture the dual-lock tests emit, not a separate widget and not gallery mock data.

python
def render(d: DateRange) -> str:
    """Model → date-range picker bar."""
    rname = html.escape(d.region_name, quote=True)
    endpoint = html.escape(d.endpoint, quote=True)
    qs = _leftover_honest_temporal(
        getattr(d, "include_closed", ""),
        getattr(d, "as_of", ""),
    )
    if qs:
        endpoint = f"{endpoint}&amp;{qs}" if "?" in endpoint else f"{endpoint}?{qs}"
    target = html.escape(d.target or f"#region-{d.region_name}", quote=True)
    date_from = html.escape(_leftover_honest_iso_date(d.date_from), quote=True)
    date_to = html.escape(_leftover_honest_iso_date(d.date_to), quote=True)
    return (
        f'<div class="dz-date-range-picker date-range-bar" data-dz-date-range>'
        f"{_bound(rname, 'from', 'date_from', date_from, endpoint, target, 'From')}"
        f"{_bound(rname, 'to', 'date_to', date_to, endpoint, target, 'To')}"
        f"</div>"
    )

Live output of render(EXEMPLARS[0]) — fixture markup the dual-lock validates (sample field values only).

Notes

Dual-lock root is data-date-range (contracts/date_range.py). Native type="date" is the submitted value (ISO companion has no name). Each date input fires the region's hx-get on change and hx-include="closest .date-range-bar" sends BOTH bounds every time, so the server always sees the full range. date-range.js blocks an inverted From>To change (custom validity + no silent empty-region GET). Leftover ISO junk must not invent a bound (cycle 2139).

Source files

One logical Hyperpart, 4 code items (CSS layered, JS bundled). Bound by HYPERPART: date-range — python tools/hyperpart.py date-range lists them.

site/registry.py · contracts/date_range.py · components/date-range.css:1 · controllers/dz-date-range.js