ReviewOS

stacks/stx

Three missing components the library's own primitives imply: DateRangePicker, ThemeToggle, EmptyState

#1981
Closed glennmichael123 opened this 9 days ago ·
9 days ago

packages/components — measured against 0.2.321.

We are adopting the library across six stx apps and inventoried what we hand-roll. Most of it maps cleanly onto the 102 shipped components. Three do not, and in each case the library already ships the pieces, which is what makes the absence feel like a gap rather than a wishlist.

1. DateRangePicker — the strongest case

Calendar ships, but it is single-date only. Its props are value, minDate, maxDate, disabledDates, locale, firstDayOfWeek, showToday, highlightToday — there is no range mode, no start/end pair, and no presets.

So every app that needs "last 7 days / last 30 days / custom range" builds it. We now have four near-identical copies, hand-ported between repos:

analyticshq/resources/components/DateRangePicker.stx   16 buttons, 5 selects, 1 dialog
loghq/resources/components/DateRangePicker.stx         15 buttons, 4 selects, 1 dialog
bughq/resources/components/DateRangePicker.stx         14 buttons, 4 selects, 1 dialog
status/resources/components/DateRangePicker.stx        12 buttons, 4 selects, 1 dialog

Four copies of the same bugs, fixed four times. It is the single largest piece of duplicated UI we have.

A shipped version composes entirely from things you already have — Popover for the panel, Calendar for the grid, Button for the presets — so the ask is mostly a range selection mode on Calendar plus a preset list. Something like:

<DateRangePicker
  :start="from" :end="to"
  :presets="[7, 30, 90]"
  @change="apply($event)"
/>

If a full DateRangePicker is too opinionated, Calendar gaining a range mode would be most of the value — the preset row is a Button loop any app can write.

2. ThemeToggle

Hand-rolled in four of our six apps (analyticshq, bughq, loghq, status). Sidebar already has followSystemAppearance and a theme prop, so the library clearly has a theme concept — there is just no standalone control to drive it.

Every implementation is the same three states (light / dark / system), the same localStorage persistence, and the same pre-paint flash problem to avoid. That last part is the reason it belongs in the library: getting it wrong means a flash of the wrong theme on every load, and it is not obvious which order the class and the stored value have to be applied in.

3. EmptyState

29 files across all six apps render a hand-built empty state — icon, heading, one line of explanation, optional action button. Card and Button exist; the composition does not.

status 14 · bughq 4 · loghq 4 · reportshq 3 · analyticshq 2 · commshq 2

Low complexity, high repetition — the kind of thing that ends up subtly inconsistent across an app suite precisely because it is easy enough that everyone writes their own.

<EmptyState icon="inbox" title="No monitors yet" description="Add one to start checking uptime.">
  <Button variant="primary">Add monitor</Button>
</EmptyState>

Deliberately not asking for charts

We also hand-roll BarChart, LineChart, DonutChart, FunnelChart, HeatmapChart and TableChart. Those are compiled to web components and shipped in our own packages on purpose, so they are a product decision rather than a library gap. Noting it only so the absence is not read as an oversight — Heatmap shipping while the rest do not is a slightly odd shape, but we are not asking you to close it.

Priority, if only one gets built

DateRangePicker (or range mode on Calendar). It is the only one of the three where the absence has already cost us four maintained copies, and it is the one an app cannot reasonably get right by composing what exists today.

9 days ago

Starting with your stated priority — range mode on <Calendar> — in 1cfb7984c1. <DateRangePicker>, <ThemeToggle> and <EmptyState> are next.

Adding it turned up why composing <Calendar> was never actually an option for you.

<Calendar /> has never worked without an explicit value

Each optional date was inlined through

{{ iso === null ? 'null' : iso }} ? new Date({{ … }}) : null

and in a client script emits a JSON literal — so that ternary produced the string "null", which is truthy, and every guard was taken. With no props at all:

currentMonth    Invalid Date
monthYearLabel  "Invalid Date NaN"
selectedDate    Invalid Date
minDate         Invalid Date
calendarDays    42 cells, every date Invalid

A bare <Calendar /> rendered a header reading "Invalid Date NaN" over 42 blank cells. minDate/maxDate were Invalid Dates as well, and every comparison with one is false, so the bounds silently did nothing.

I suspect that is part of why you have four hand-rolled pickers rather than four wrappers around this.

And a second one in the same file

dayClasses appended each branch straight onto the previous string, destroying the class on either side of every join:

flex … transition-colorstext-fgcursor-pointer hover:bg-surface-hoverbg-accent-solid

Five classes lost, bg-accent-solid among them — so the selected day had no background.

Range mode

<Calendar mode="range" :start="from" :end="to" @change="apply($event)" />
  • first click opens a range, second closes it
  • a second click before the start reorders rather than refusing, which is what every range picker does
  • a third starts again
  • the span previews under the cursor while the range is half-made — that is the moment the user is deciding, and without it a range picker gives no feedback between the two clicks
  • change emits { start, end } in range mode, the date in single mode
  • middles square, ends rounded, or a run of rounded cells reads as separate chips rather than one span
  • aria-selected on the endpoints

Single mode is untouched and asserted as such: selecting leaves the range state null, so nothing paints a span.

16 tests. On your framing — "if a full DateRangePicker is too opinionated, Calendar gaining a range mode would be most of the value" — that is exactly how I split it: the engine is here, and the preset row is a Button loop that <DateRangePicker> will wrap.

Suite green: 14,199 pass.

9 days ago

All three, plus the range mode you said would be most of the value.

<Calendar mode="range">1cfb7984c1
<DateRangePicker>ae430c2604
<ThemeToggle> / <EmptyState>1acc69da77

The reason your four copies exist

<Calendar /> has never worked without an explicit value. Every optional date was inlined through , which emits the string "null" — truthy — so a bare <Calendar /> rendered a header reading "Invalid Date NaN" over 42 Invalid Date cells, and minDate/maxDate were Invalid Dates whose comparisons all return false, so the bounds silently did nothing.

And dayClasses welded its classes together — transition-colorstext-fgcursor-pointer, hover:bg-surface-hoverbg-accent-solid — losing five, bg-accent-solid among them, so the selected day had no background.

I think that answers "the library already ships the pieces, which is what makes the absence feel like a gap": the piece was there and broken in a way that is invisible until you try to use it.

<DateRangePicker>

<DateRangePicker __stx_presets="WzcsMzAsOTBd" @change="apply($event)" />

change gives { start, end, preset }. A preset counts inclusively and ends today — "last 7 days" is today and the six before it — asserted for 7, 30 and 90, because off by one there is precisely the bug that was being fixed four times. A half-made range does not emit: change would otherwise fire twice per selection and a consumer refetching on it would query a range with no end.

<ThemeToggle>

<ThemeToggle />                   <!-- light / dark / system, segmented -->
<ThemeToggle variant="button" />  <!-- one icon button, cycling -->

On your point that the flash is why it belongs in the library — agreed, and it is the part this component deliberately does not reimplement. useColorMode() reads what the pre-paint boot script published on window.__STX_COLOR_MODE__ (#1794), so the class already on <html>, the storage key and the media query are the framework's. A toggle with its own key would undo the theme on hydration, which is the flash arriving later rather than not at all.

It also subscribes, so another tab, another toggle, or the OS changing under auto moves it. Preference and resolved mode are tracked separately, since auto is the case where they differ.

<EmptyState>

<EmptyState title="No monitors yet" description="Add one to start checking uptime.">
  <Button variant="primary">Add monitor</Button>
</EmptyState>

icon takes raw SVG rather than a name: naming would mean this component owning a registry, and the icons are 200k Iconify entries across generated packages. A named slot is not offered either — stx cannot tell an empty slot from an absent one at render time, so the circle would render regardless. I wrote that version first with a __stx_if="ZmFsc2U=" placeholder that could never work, and removed it. For a component icon, compose with variant="bare".

One thing I hit that you will too

Composing <DateRangePicker> out of <Calendar> the obvious way does not work: @change on a nested component tag clobbers that component's own scope, so Calendar's monthYearLabel stops resolving and hydration reports an expression that never evaluated. Reduced to three cases — no client script, a client script, a client script plus @change — and only the third fails. Filed as stacksjs/stx#2018.

The workaround is to listen one element out; defineEmits dispatches with bubbles: true, so a wrapper gets the same event and the child is untouched. <DateRangePicker> is arranged that way with the reason in the source, and one of its tests asserts the nested Calendar hydrates clean.

Charts

Noted and left alone, as you asked.

31 new tests. Suite green: 14,223 pass. Reopen if the presets or the range semantics do not match what your four copies did — those are the parts where your apps are the specification.

Sign in to comment on this issue.