@kalendio/timepicker
v1.0.0
Published
Headless, UI-agnostic timepicker — configurable slot grid with time-zone projection, selection, and composable disable predicates.
Maintainers
Readme
@kalendio/timepicker
Headless, UI-agnostic timepicker. Produces a grid of time slots spaced by interval between startAt and endAt, anchors them on a calendar day, and drives selection through select / clear — no rendering, no DOM, no framework lock-in.
createTimepicker() returns an instance exposing read-only getters (slots, selected) and two verbs (select, clear). Every method returns the same instance for chaining; slots are rebuilt on read from a single pre-projected anchor so time-zone-sensitive values stay consistent.
Requirements
- Node.js
>=22.0.0(when running in Node) - pnpm
>=10.0.0(when consuming via pnpm)
Peer dependencies
| Package | Version | Notes |
| ------------------ | ------------- | -------------------------------------------------------------------- |
| @kalendio/shared | workspace:* | Pulled in automatically as a normal dependency — listed for clarity. |
Installation
# pnpm
pnpm add @kalendio/timepicker
# npm
npm install @kalendio/timepicker
# yarn
yarn add @kalendio/timepicker
# bun
bun add @kalendio/timepickerQuick Start
import { createTimepicker } from "@kalendio/timepicker";
const picker = createTimepicker({
interval: 30,
startAt: { hour: 9 },
endAt: { hour: 17 },
value: new Date(2026, 5, 16, 10, 30),
});
picker.slots; // → TimeSlot[] (ready-to-render)
picker.selected; // → Date(2026-06-16 10:30) (fresh copy — safe to mutate)
picker.select(picker.slots[4].start); // chainable
picker.clear();Composing with disable predicates
import {
createTimepicker,
disableBeforeNow,
disableTimeRange,
disableSlots,
combineDisablers,
} from "@kalendio/timepicker";
const picker = createTimepicker({
interval: 30,
startAt: { hour: 9 },
endAt: { hour: 18 },
isDisabled: combineDisablers(
disableBeforeNow({ reason: "past" }),
disableTimeRange({ startAt: { hour: 12 }, endAt: { hour: 13 }, reason: "lunch" }),
disableSlots(bookedDates, { reason: "booked" }),
),
});Every slot carries { start, isSelected, isDisabled, disabledReason?, meta? } — spread it onto whatever UI you're rendering.
Design notes
- Time-zone projection is single-shot.
valueand "today" are resolved throughtimeZone(via@kalendio/shared'sadjustDateTimeZone) once at construction. The rest of the factory reads plain local components — the anchor andtodaycan never disagree on which local day they refer to. valuesnaps to the grid. If it lands between two slots,selectedstaysundefined. A picker cannot point at a slot that isn't in its grid.- Slot outputs are cloned.
slotsandselectedreturn freshDateobjects on every read. Mutating them does not leak into internal state. - Boundaries accept mixed forms.
startAt/endAttake either minutes-from-midnight (0–1440) or a{ hour, minute? }object.{ hour: 24 }marks end-of-day; combininghour: 24with a non-zero minute is rejected. endAtis exclusive. A slot whose start equalsendAtis not emitted.selectre-projects.DateLikearguments run through the same time-zone projection asvalue, so callers can pass raw UTC instants back in.- Predicates receive
PredicateContext.isDisabledandgetMetaare called with(slotStart, { today })— reuse composable predicates written for@kalendio/core. - Disabled reason is dropped on the false branch. Predicates only emit the rich
{ disabled, reason }shape whendisabled: true, so UIs never render a reason on an enabled slot.
API
createTimepicker<TMeta>(config?: TimepickerConfig<TMeta>): Timepicker<TMeta>
| Option | Type | Default | Description |
| ------------ | ---------------------- | ----------- | -------------------------------------------------------------------------------------------------- |
| interval | number | 30 | Gap between consecutive slots, in minutes. Positive integer. |
| startAt | number \| TimeOfDay | 0 | First slot. Minutes-from-midnight (0–1440) or { hour, minute? }. |
| endAt | number \| TimeOfDay | 1440 | End of the day (exclusive). Same forms as startAt; { hour: 24 } marks end-of-day. |
| value | DateLike | undefined | Initial selection. Anchors slots on its calendar day; undefined uses today. |
| timeZone | string | host local | IANA time zone (e.g. "America/New_York") applied to value, "today", and any select argument. |
| isDisabled | DisabledPredicate | undefined | Marks slots disabled. Returns boolean or { disabled, reason? }. |
| getMeta | MetaPredicate<TMeta> | undefined | Attaches consumer metadata to each slot. |
Timepicker<TMeta>
| Member | Returns | Description |
| -------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| slots | TimeSlot<TMeta>[] | Ready-to-render slots. Rebuilt on read; consumer metadata / disabled state resolved per slot. |
| selected | Date \| undefined | Currently selected slot start (fresh copy), or undefined. |
| select(slot) | Timepicker<TMeta> | Selects the slot whose start matches slot. Throws INVALID_DATE on unparseable input; SLOT_NOT_FOUND when misaligned. |
| clear() | Timepicker<TMeta> | Clears the current selection. |
Predicate factories
Composable DisabledPredicate builders shipped from predicates/disablers.
| Export | Description |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| disableTimeRange({ startAt, endAt, reason? }) | Disables slots whose start-of-day minute falls in [startAt, endAt). Accepts number \| TimeOfDay for each boundary. |
| disableBeforeNow({ reason? }) | Disables slots whose start is strictly before Date.now(). Re-reads the clock on every call. |
| disableSlots(dates, { reason? }) | Disables exact-timestamp matches from a list of DateLike. Empty list returns a no-op predicate. |
| combineDisablers(...ps) | OR-composes predicates. Falsy entries are skipped; a single surviving predicate is returned unchanged. |
Constants
| Export | Description |
| ----------------- | ---------------------------------------------------- |
| MINUTES_PER_DAY | 1440. Upper bound for numeric startAt / endAt. |
Types
Timepicker, TimepickerConfig, TimeSlot, TimeOfDay.
Typed metadata
The generic TMeta slot threads consumer-defined fields through getMeta into every TimeSlot:
interface Meta {
price: number;
staffId: string;
}
const picker = createTimepicker<Meta>({
interval: 30,
getMeta: (start) => ({ price: 4000, staffId: pickStaff(start) }),
});
picker.slots[0].meta?.price; // typed as number | undefinedLicense
MIT
