<input type="range"> has one thumb. A date range needs two. This lesson builds
the two-thumb control out of two ordinary range inputs, keeps them in sync with
a store that can move them from outside, makes them announce dates rather than
numbers, and then adds a reset button whose entire appearing-and-disappearing
behaviour is one CSS selector. Checkpoint:
ui-06.
Two inputs, one control
Both inputs cover the same track and are stacked. Each has pointer-events: none on itself and pointer-events: auto on its thumb, so a click anywhere on
the track reaches whichever thumb is under the cursor rather than the input that
happens to be on top.
The values are day offsets, not dates. Range inputs are numeric, so the component converts:
private _toDay(iso: string): number {
return Math.round((Date.parse(iso) - Date.parse(this.minDate)) / 86_400_000);
}
private _toISO(day: number): string {
return new Date(Date.parse(this.minDate) + day * 86_400_000).toISOString().split('T')[0];
}
Days since minDate, which makes min, max, and step trivially integers.
Crossing is prevented at the source, in the input handler — clamp the start thumb to at most the end thumb’s value and vice versa. Doing it there rather than in the store means the thumb physically stops, instead of moving and snapping back a frame later.
The store can move the thumbs
The thumbs need local state, because they must respond during a drag before any
query runs. But they must also follow the store when something else changes the
range — the reset button, for instance. Reading the signal directly in render()
would fight the drag; keeping a copy that never updates would ignore the reset.
willUpdate is the seam:
willUpdate(): void {
const { startDate, endDate } = dateRange.get();
this._startValue = this._toDay(startDate);
this._endValue = this._toDay(endDate);
}
willUpdate runs before every render, including the re-render SignalWatcher
triggers when dateRange changes. So the thumbs derive from the store on every
paint, and the store is only written by the input handler. One direction of
truth, and the reset in the next section moves both thumbs without knowing this
component exists.
Sliders that say dates
A range input announces its number. A screen reader user dragging this one would hear “47”, which is useless:
html`<input type="range" aria-valuetext=${this._toISO(this._startValue)}
aria-label="Start date" …>`
aria-valuetext replaces the announced value with a string, so the thumb reads
“2025-03-31”. The visible labels use <time datetime=...> for the same reason
the stat cards used <data>: the machine-readable form travels with the human
one.
The pair is wrapped in <fieldset> with a <legend>, which is what groups two
inputs into one named control for assistive technology. Two aria-labeled
inputs floating loose are two controls that happen to be adjacent.
A button that needs no state
The reset button should appear only when a filter is active. The state-based version tracks “is anything filtered”, passes it down, and re-renders. There is no state here at all:
.reset-filters {
display: none;
}
.filters-card:has([data-active]) .reset-filters {
display: inline-flex;
}
.filters-card:has([data-active]) h2::after {
content: 'active';
/* …pill styling… */
}
:has() asks: does this card contain any descendant with data-active? The
dropdown sets that attribute when items are selected; the slider sets it when
the range is narrowed. Neither knows the button exists. Add a third filter next
month and it participates by setting the same attribute — the CSS does not
change.
The pseudo-element badge follows from the same selector, which is the part that tends to convert people: two unrelated pieces of UI reacting to a condition neither of them owns, with no JavaScript coordinating them.
The button itself lives in light DOM and its handler is the last one in
index.ts:
document.getElementById('reset-filters')?.addEventListener('click', () => {
setDateRange(DATE_MIN, DATE_MAX);
setFruitSelection([]);
});
Two actions. The dropdown clears because it renders from selectedFruits; the
slider’s thumbs move because of willUpdate. Neither is told.
Build it
| File | Action | What goes in it |
|---|---|---|
src/components/date-range-slider.ts |
write | Two stacked range inputs, day-offset conversion, crossing clamps, willUpdate derivation, aria-valuetext, fieldset/legend, data-active |
index.html |
modify | The slider and a #reset-filters button in the filters row |
src/index.ts |
modify | The reset handler |
src/style.css |
modify | .reset-filters and the two :has([data-active]) rules |
Done when: dragging the start thumb to the middle shows about 90 days and half the records, the reset button appears only while a filter is active, and clicking it returns to 180 days and 20,000 records with both thumbs back at the ends.
Answer key: ui-05...ui-06.
Challenge: delete willUpdate
Comment out the willUpdate body and click reset with the thumbs moved. The
numbers update, the query re-runs, and the thumbs stay exactly where they were —
a control lying about the state it represents. Restore it. Then try the opposite
mistake: read dateRange directly in render() instead, and drag a thumb.
Watching both failure modes is what makes the derive-in-willUpdate pattern
stick.
Next lesson: D3 arrives, and immediately has to deal with a shadow root.