A Vue 3 design system whose first (and so far only) component is <DatePicker />: a headless calendar engine in vanilla TypeScript wrapped by a thin Vue layer.
Storybook is the home of every component (no demo app, no playground page). Each component's
index.tsis its public contract; the story file next to it is the showcase.
Live demo: https://lulcca.github.io/gila-ds/
Requires Node ≥ 20.19 / 22.12 and pnpm (any 10.x).
pnpm install
pnpm dev # Storybook at http://localhost:6006
pnpm build # Storybook static build → ./storybook-static
pnpm lint # eslint
pnpm test:coverage # vitest, ≥ 75% across lines/branches/funcs/statements
pnpm type:check # vue-tsc --buildVertical slicing: each component owns its template, composable, headless engine, tests and stories. Re-exports go through one barrel per component.
src/
├── components/
│ └── date-picker/
│ ├── date-picker.vue ← UI: input + popover + BEM grid
│ ├── date-picker.stories.ts ← storybook showcase
│ ├── use-calendar.ts ← vue ↔ engine bridge
│ ├── engine/ ← headless logic
│ └── index.ts ← public API
└── styles/tokens.css ← css tokens
<DatePicker
v-model="date"
label="Pick a date"
locale="en-US"
:display-format="{ year: 'numeric', month: 'long', day: '2-digit' }"
:max-date="{ year: 2025, month: 12, day: 31 }"
:min-date="{ year: 2024, month: 1, day: 1 }"
/>displayFormat accepts any Intl.DateTimeFormatOptions.
locale is any BCP 47 tag, fed straight into Intl.DateTimeFormat.
Free-form ISO 8601 input (2024-07-04) is also accepted in the text field.
CalendarEngine is a framework-agnostic class, vanilla TypeScript. It owns the calendar state (viewYear, viewMonth, selected, focused) and exposes:
-
Pure read methods:
getState(),getGrid(),getMonthLabel(),getWeekdayLabels(). -
Mutating commands:
selectDate,nextMonth,moveFocus, etc. Each one returns the nextICalendarStateso callers can drive themselves without re-reading. -
subscribe(listener): minimal observer protocol, the only seam any UI framework needs to hook into.
The Vue bridge (use-calendar.ts) is deliberately minimal:
const state = shallowRef<ICalendarState>(engine.getState())
const unsubscribe = engine.subscribe((next) => {
state.value = next
})
onScopeDispose(unsubscribe)A single shallowRef holds the latest snapshot. The engine emits a fresh object on every mutation, so assigning it into state.value is enough to trigger Vue's reactivity (no per-field refs, no triggerRef). I picked this pattern over reactive on purpose:
-
The engine stays Vue-free. A
reactive(engine)would wrap the class with Proxies, leaking framework concerns into the domain layer and making it harder to port to React/Svelte later. -
No accidental fine-grained reactivity. Each engine command is transactional, exactly one snapshot per mutation, exactly one reactive update, exactly one Vue re-render. No cascade of intermediate updates.
-
Listeners only fire when state actually changes (the engine diff-compares snapshots internally), so the
shallowRefis updated only on real transitions.
The cost is one explicit void state.value in each computed that reads from the engine, a trade I'm happy to pay for the clean domain separation.
The challenge asks to "leverage the Temporal API where possible". I considered it carefully and chose to defer adoption, while keeping the codebase ready for migration. Reasoning:
-
Runtime availability is still patchy in May 2026. Temporal is at Stage 3 and shipping behind flags in some engines; consuming it in a zero-config library means either a polyfill (~30 KB minified) or shipping code that breaks in older runtimes the consumer hasn't opted into.
-
Adding
temporal-polyfillfor a single component is overkill. A design system component should be lean — pulling in a polyfill just to do arithmetic on month boundaries doesn't justify itself.
What I did instead, and how the migration looks:
-
The public type
ICalendarDateis Temporal-shaped:{ year: number; month: number; day: number }, with 1-indexed months — identical to whatTemporal.PlainDate.from({ year, month, day })accepts. The day swap istype ICalendarDate = Temporal.PlainDate. -
All
Dateusage is quarantined inengine/utils/date.ts. Day arithmetic (addDays) goes throughDate.UTC(...)+getUTC*to dodgeDate's two main footguns: timezone-dependent constructors and DST shifts. Month arithmetic (addMonths) and day-counts (getDaysInMonth) are done by hand with aDAYS_PER_MONTHtable + leap-year check — cheaper and easier to reason about than round-tripping throughDate. -
The engine never sees a
Dateinstance. It only consumes the plainICalendarDateshape. SwappingaddDays,addMonths,getDaysInMonthforTemporal.PlainDate.add(...)/.with(...)is a localised change inutils/date.ts— the engine, the composable and the Vue component need zero edits.
In short: Temporal-ready API, defensive Date implementation today, one-file migration when Temporal lands in every target runtime.
See Quick start above for the full command list. The shortest path to seeing the component is:
pnpm install && pnpm dev-
TypeScript with
strict: trueandnoUncheckedIndexedAccessfor safer array/object access. -
Vitest + @vue/test-utils + jsdom, 80 tests, ≥ 75% coverage gate enforced in CI.
-
WAI-ARIA combobox + grid patterns:
aria-haspopup="dialog",aria-expanded, rovingtabindex, verbose per-cellaria-label("Saturday, May 18, 2024"). Full keyboard support: arrows,Home/End,PageUp/PageDown(year jump withShift),Enter/Space,Esc. -
Themable via CSS custom properties. Two layers:
--ds-*(system-wide primitives) and--date-picker-*(component-specific knobs). Override either in any ancestor scope, see theCustomThemeandDarkThemestories. -
Injectable clock.
ICalendarEngineOptions.nowlets tests stub "today" deterministically without monkey-patchingDate.
-
.github/workflows/ci.yml: lint, type-check, and tests with coverage on every push / PR tomain. -
.github/workflows/deploy-storybook.yml: builds the Storybook static site and publishes it to GitHub Pages on every push tomain.
MIT — see LICENSE.