formsclient
time picker
columns of hours and minutes, with am and pm if you want them. the value is always a plain “14:30”.
reminder
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add time-pickeror by hand: drop these files into components/ui and import from there. time picker leans on DatePicker.tsx, Floating.tsx, FieldMessage.tsx and useFloatingPanel.ts, so those come along too.
components/ui/TimePicker.tsxtsx
1"use client";2
3import React, { useId, useRef, useState } from "react";4import { ClearKey } from "./DatePicker";5import { Portal } from "./Floating";6import { FieldLabel, FieldMessage } from "./FieldMessage";7import { useFloatingPanel } from "./useFloatingPanel";8
9interface TimePickerProps {10 /** 24-hour "HH:mm", like "14:30" */11 value?: string | null;12 defaultValue?: string | null;13 onChange?: (time: string | null) => void;14 /** Minutes between choices in the minute column */15 step?: number;16 /** Show 1–12 with am / pm instead of 0–23 */17 hour12?: boolean;18 /** Earliest and latest times, as "HH:mm" */19 min?: string;20 max?: string;21 label?: string;22 hint?: string;23 error?: string;24 placeholder?: string;25 clearable?: boolean;26 disabled?: boolean;27 fullWidth?: boolean;28 name?: string;29 className?: string;30}31
32const pad = (n: number) => String(n).padStart(2, "0");33const toMinutes = (t?: string | null) => {34 if (!t) return null;35 const m = /^(\d{1,2}):(\d{2})$/.exec(t);36 return m ? Number(m[1]) * 60 + Number(m[2]) : null;37};38const fromMinutes = (m: number) => `${pad(Math.floor(m / 60))}:${pad(m % 60)}`;39
40export function formatTime(t: string, hour12: boolean) {41 const m = toMinutes(t);42 if (m === null) return t;43 const h = Math.floor(m / 60);44 return hour12 ? `${h % 12 || 12}:${pad(m % 60)} ${h < 12 ? "am" : "pm"}` : fromMinutes(m);45}46
47type Column = "hour" | "minute" | "period";48
49/* A field that opens a panel of columns: hours, minutes and, for a50 12-hour clock, am / pm. The chosen cell in each is a raised key in the51 inverse material. Arrows move within a column and between columns;52 Enter or picking a minute closes it. */53export function TimePicker({54 value,55 defaultValue = null,56 onChange,57 step = 15,58 hour12 = false,59 min,60 max,61 label,62 hint,63 error,64 placeholder = "pick a time",65 clearable = false,66 disabled = false,67 fullWidth = false,68 name,69 className = "",70}: TimePickerProps) {71 const uid = useId();72 const ref = useRef<HTMLDivElement>(null);73 const buttonRef = useRef<HTMLButtonElement>(null);74 const [internal, setInternal] = useState<string | null>(defaultValue);75 const current = value !== undefined ? value : internal;76 const total = toMinutes(current);77 const { open, mounted, toggle, close, setFloating, panelStyle } = useFloatingPanel(ref, {78 onEscape: () => buttonRef.current?.focus(),79 });80 const messageId = error || hint ? `${uid}-message` : undefined;81
82 const lo = toMinutes(min) ?? 0;83 const hi = toMinutes(max) ?? 24 * 60 - 1;84 const hour = total === null ? null : Math.floor(total / 60);85 const minute = total === null ? null : total % 60;86
87 const set = (t: string | null) => {88 setInternal(t);89 onChange?.(t);90 };91
92 /* Picks a time, nudging the minutes into range when the hour changes */93 const choose = (h: number, m: number) => {94 let t = h * 60 + m;95 if (t < lo) t = lo;96 if (t > hi) t = hi;97 set(fromMinutes(t));98 };99
100 const hours = hour12 ? Array.from({ length: 12 }, (_, i) => (i === 0 ? 12 : i)) : Array.from({ length: 24 }, (_, i) => i);101 const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step).filter((m) => m < 60);102 const isPm = hour !== null && hour >= 12;103 const to24 = (h12: number, pm: boolean) => (h12 % 12) + (pm ? 12 : 0);104
105 const hourOff = (h24: number) => h24 * 60 + 59 < lo || h24 * 60 > hi;106 const minuteOff = (m: number) => hour !== null && (hour * 60 + m < lo || hour * 60 + m > hi);107
108 type Cell = { key: string; label: string; selected: boolean; off: boolean; pick: () => void };109 const columns: { id: Column; label: string; cells: Cell[] }[] = [110 {111 id: "hour",112 label: "hour",113 cells: hours.map((h) => {114 const h24 = hour12 ? to24(h, isPm) : h;115 return {116 key: `h${h}`,117 label: hour12 ? String(h) : pad(h),118 selected: hour === h24,119 off: hourOff(h24),120 pick: () => choose(h24, minute ?? 0),121 };122 }),123 },124 {125 id: "minute",126 label: "minute",127 cells: minutes.map((m) => ({128 key: `m${m}`,129 label: pad(m),130 selected: minute === m,131 off: minuteOff(m),132 pick: () => {133 choose(hour ?? Math.floor(lo / 60), m);134 close();135 buttonRef.current?.focus();136 },137 })),138 },139 ];140 if (hour12) {141 columns.push({142 id: "period",143 label: "am or pm",144 cells: (["am", "pm"] as const).map((p) => {145 const pm = p === "pm";146 const h24 = to24(hour === null ? 12 : hour % 12 || 12, pm);147 return {148 key: p,149 label: p,150 selected: hour !== null && isPm === pm,151 off: pm ? hi < 12 * 60 : lo >= 12 * 60,152 pick: () => choose(h24, minute ?? 0),153 };154 }),155 });156 }157
158 const colRefs = useRef<Record<string, HTMLDivElement | null>>({});159
160 /* Each column scrolls its chosen cell into the middle when it mounts */161 const attachColumn = (id: Column) => (el: HTMLDivElement | null) => {162 colRefs.current[id] = el;163 const sel = el?.querySelector<HTMLElement>('[aria-selected="true"]');164 if (!el || el.dataset.scrolled) return;165 el.dataset.scrolled = "1";166 if (sel) el.scrollTop = sel.offsetTop - el.clientHeight / 2 + sel.offsetHeight / 2;167 // The panel lives at the end of <body>, so focus goes to it rather than waiting for Tab168 if (id === "hour") el.focus({ preventScroll: true });169 };170
171 const onColumnKey = (ci: number) => (e: React.KeyboardEvent) => {172 const col = columns[ci];173 const enabled = col.cells.map((c, i) => (c.off ? -1 : i)).filter((i) => i >= 0);174 const at = col.cells.findIndex((c) => c.selected);175 const pos = enabled.indexOf(at);176 const focusCol = (n: number) =>177 colRefs.current[columns[Math.max(0, Math.min(columns.length - 1, n))].id]?.focus();178 if (e.key === "ArrowDown" || e.key === "ArrowUp") {179 e.preventDefault();180 const d = e.key === "ArrowDown" ? 1 : -1;181 const next = enabled[pos < 0 ? 0 : (pos + d + enabled.length) % enabled.length];182 // the minute column closes on pick; from the keyboard, just move183 if (col.id === "minute") choose(hour ?? Math.floor(lo / 60), minutes[next]);184 else col.cells[next]?.pick();185 requestAnimationFrame(() =>186 colRefs.current[col.id]?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" }),187 );188 } else if (e.key === "ArrowRight") {189 e.preventDefault();190 focusCol(ci + 1);191 } else if (e.key === "ArrowLeft") {192 e.preventDefault();193 focusCol(ci - 1);194 } else if (e.key === "Enter") {195 e.preventDefault();196 close();197 buttonRef.current?.focus();198 }199 };200
201 return (202 <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>203 {label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}204 <div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-44", className].join(" ")}>205 {/* The field's error styling keys off aria-invalid, as Input's does */}206 {/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */}207 <button208 ref={buttonRef}209 type="button"210 disabled={disabled}211 onClick={toggle}212 aria-haspopup="dialog"213 aria-expanded={open}214 aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}215 aria-invalid={error ? true : undefined}216 aria-describedby={messageId}217 data-open={open || undefined}218 className={[219 "field group/time relative isolate flex w-full cursor-pointer items-center gap-2.5 rounded-xl py-3 pl-3.5 text-left text-[13.5px]",220 clearable && current ? "pr-10" : "pr-4",221 "focus:outline-none disabled:opacity-40 disabled:pointer-events-none",222 ].join(" ")}223 >224 <svg225 aria-hidden226 width="15"227 height="15"228 fill="none"229 stroke="currentColor"230 strokeWidth="2"231 strokeLinecap="round"232 viewBox="0 0 24 24"233 className={[234 "shrink-0 transition-[color,transform] duration-300 ease-[var(--ease-spring)]",235 open ? "scale-110 text-ink-soft" : "text-ink-faint group-hover/time:text-ink-body",236 ].join(" ")}237 >238 <circle cx="12" cy="12" r="9" />239 <path d="M12 7v5l3 2" />240 </svg>241 <span id={`${uid}-value`} className={["tabular-nums", current ? "font-medium text-ink" : "text-ink-faint"].join(" ")}>242 {current ? formatTime(current, hour12) : placeholder}243 </span>244 </button>245 {clearable && current && !disabled && (246 <ClearKey247 label="clear time"248 onClear={() => {249 set(null);250 buttonRef.current?.focus();251 }}252 />253 )}254 {name && <input type="hidden" name={name} value={current ?? ""} />}255 </div>256
257 {mounted && (258 <Portal>259 <div260 ref={setFloating}261 role="dialog"262 aria-label={label ? `choose ${label}` : "choose a time"}263 className="rounded-[16px] p-1.5"264 style={panelStyle}265 >266 <div className="flex gap-1 rounded-[11px] bg-panel-face p-1.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>267 {columns.map((col, ci) => (268 <div269 key={col.id}270 ref={attachColumn(col.id)}271 role="listbox"272 aria-label={col.label}273 tabIndex={0}274 onKeyDown={onColumnKey(ci)}275 className="flex max-h-56 w-14 flex-col gap-0.5 overflow-y-auto rounded-[9px] p-0.5 scroll-thin outline-none focus-visible:ring-2 focus-visible:ring-ink"276 style={{ scrollbarWidth: "none" }}277 >278 {col.cells.map((c) => (279 <div280 key={c.key}281 role="option"282 aria-selected={c.selected}283 aria-disabled={c.off || undefined}284 onClick={() => !c.off && c.pick()}285 className={[286 "grid h-8 shrink-0 place-items-center rounded-[8px] text-[13px] tabular-nums transition-colors duration-100",287 c.off288 ? "cursor-not-allowed text-ink-ghost"289 : c.selected290 ? "cursor-pointer font-semibold text-ink-inverse"291 : "cursor-pointer text-ink-body hover:bg-surface-active hover:text-ink",292 ].join(" ")}293 style={294 c.selected && !c.off295 ? {296 background: "var(--face-inverse)",297 boxShadow: "var(--face-inverse-shadow)",298 textShadow: "var(--emboss-inverse)",299 }300 : undefined301 }302 >303 {c.label}304 </div>305 ))}306 </div>307 ))}308 </div>309 </div>310 </Portal>311 )}312 <FieldMessage id={messageId} error={error} hint={hint} />313 </div>314 );315}components/ui/DatePicker.tsxtsx
1"use client";2
3import React, { useId, useMemo, useRef, useState } from "react";4import { Portal } from "./Floating";5import { FieldKey, FieldLabel, FieldMessage } from "./FieldMessage";6import { useFloatingPanel } from "./useFloatingPanel";7
8/* ── Date helpers ─────────────────────────────────────────────────── */9/* Everything works on local calendar days at midnight, so a date never10 drifts across a time zone boundary on its way through the picker. */11export const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());12const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);13const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);14const addMonths = (d: Date, n: number) => {15 const target = new Date(d.getFullYear(), d.getMonth() + n, 1);16 const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();17 return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), last));18};19const monthIndex = (d: Date) => d.getFullYear() * 12 + d.getMonth();20export const sameDay = (a?: Date | null, b?: Date | null) =>21 !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();22export const toISODate = (d: Date) =>23 `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;24
25export interface DateRange {26 from: Date | null;27 to: Date | null;28}29
30/* ── Calendar ─────────────────────────────────────────────────────── */31interface CalendarProps {32 value?: Date | null;33 /** Shade a span of days instead of marking one (see DateRangePicker) */34 range?: DateRange;35 onChange?: (date: Date) => void;36 /** The day under the pointer or keyboard, for previewing a range */37 onDayHover?: (date: Date | null) => void;38 /** The month shown first; defaults to the selected date, or today */39 defaultMonth?: Date;40 /** How many months sit side by side */41 months?: 1 | 2;42 min?: Date;43 max?: Date;44 /** Grey out and skip particular days, like weekends */45 isDateDisabled?: (date: Date) => boolean;46 /** 0 is Sunday, 1 is Monday */47 weekStartsOn?: 0 | 1;48 locale?: string;49 /** Move focus onto the grid as soon as it mounts */50 autoFocus?: boolean;51 className?: string;52}53
54/* A month of days on the panel's face. The chosen day is a raised key in55 the inverse material, today wears a small dot, and the grid follows56 the ARIA date-grid pattern: arrows move by day and week, page keys by57 month (shift for a year), home and end to the edges of the week. */58export function Calendar({59 value,60 range,61 onChange,62 onDayHover,63 defaultMonth,64 months = 1,65 min,66 max,67 isDateDisabled,68 weekStartsOn = 0,69 locale = "en-US",70 autoFocus = false,71 className = "",72}: CalendarProps) {73 const uid = useId();74 const [today] = useState(() => startOfDay(new Date()));75 const [focused, setFocused] = useState<Date>(() => startOfDay(value ?? range?.from ?? defaultMonth ?? today));76 const [view, setView] = useState<Date>(() => startOfMonth(focused));77 const [direction, setDirection] = useState<"left" | "right">("right");78 // Set by keyboard moves, spent by the next day button that mounts focused79 const pendingFocus = useRef(autoFocus);80
81 // A value set from outside brings its month into view82 const anchor = value ?? range?.from ?? null;83 const [seen, setSeen] = useState(anchor?.getTime());84 if (anchor?.getTime() !== seen) {85 setSeen(anchor?.getTime());86 if (anchor) {87 const d = startOfDay(anchor);88 setFocused(d);89 const shown = monthIndex(d) - monthIndex(view);90 if (shown < 0 || shown >= months) setView(startOfMonth(d));91 }92 }93
94 const minDay = min && startOfDay(min);95 const maxDay = max && startOfDay(max);96 const disabled = (d: Date) =>97 (minDay && d < minDay) || (maxDay && d > maxDay) || (isDateDisabled?.(d) ?? false);98
99 const monthFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }), [locale]);100 const dayLabel = useMemo(101 () => new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric" }),102 [locale],103 );104 const weekdays = useMemo(() => {105 const short = new Intl.DateTimeFormat(locale, { weekday: "short" });106 const long = new Intl.DateTimeFormat(locale, { weekday: "long" });107 // 2023-01-01 was a Sunday108 return Array.from({ length: 7 }, (_, i) => {109 const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));110 return { short: short.format(d).slice(0, 2), long: long.format(d) };111 });112 }, [locale, weekStartsOn]);113
114 /* Moves the roving focus, scrolling the view when the day is off it */115 const move = (next: Date, viaKeyboard: boolean) => {116 pendingFocus.current = viaKeyboard;117 const offset = monthIndex(next) - monthIndex(view);118 if (offset < 0) {119 setDirection("left");120 setView(startOfMonth(next));121 } else if (offset >= months) {122 setDirection("right");123 setView(startOfMonth(addMonths(next, -(months - 1))));124 }125 setFocused(next);126 if (viaKeyboard) onDayHover?.(next);127 };128
129 const flip = (n: number) => {130 pendingFocus.current = false;131 setDirection(n > 0 ? "right" : "left");132 setView(addMonths(view, n));133 setFocused(addMonths(focused, n));134 };135
136 const onKeyDown = (e: React.KeyboardEvent) => {137 const dow = (focused.getDay() - weekStartsOn + 7) % 7;138 const map: Record<string, () => Date> = {139 ArrowLeft: () => addDays(focused, -1),140 ArrowRight: () => addDays(focused, 1),141 ArrowUp: () => addDays(focused, -7),142 ArrowDown: () => addDays(focused, 7),143 PageUp: () => addMonths(focused, e.shiftKey ? -12 : -1),144 PageDown: () => addMonths(focused, e.shiftKey ? 12 : 1),145 Home: () => addDays(focused, -dow),146 End: () => addDays(focused, 6 - dow),147 };148 if (map[e.key]) {149 e.preventDefault();150 move(map[e.key](), true);151 }152 };153
154 /* The focused day takes focus after it renders, but only when the155 keyboard moved it: a click shouldn't yank focus around. */156 const focusRef = (el: HTMLButtonElement | null) => {157 if (!el || !pendingFocus.current) return;158 pendingFocus.current = false;159 el.focus({ preventScroll: true });160 };161
162 const prevDisabled = !!minDay && addDays(view, -1) < minDay;163 const nextDisabled = !!maxDay && addMonths(view, months) > maxDay;164
165 const from = range?.from ? startOfDay(range.from) : null;166 const to = range?.to ? startOfDay(range.to) : null;167
168 return (169 <div170 className={["flex flex-wrap justify-center gap-x-6 gap-y-4 select-none", className].join(" ")}171 onKeyDown={onKeyDown}172 onMouseLeave={() => onDayHover?.(null)}173 >174 {Array.from({ length: months }, (_, m) => {175 const month = addMonths(view, m);176 const first = startOfMonth(month);177 const gridStart = addDays(first, -((first.getDay() - weekStartsOn + 7) % 7));178 // Always six weeks, so the panel keeps its height from month to month179 const weeks = Array.from({ length: 6 }, (_, w) =>180 Array.from({ length: 7 }, (_, d) => addDays(gridStart, w * 7 + d)),181 );182 const labelId = `${uid}-month-${m}`;183 return (184 <div key={m} className="w-[266px]">185 <div className="mb-2 flex h-7 items-center justify-between gap-2">186 {m === 0 ? (187 <FieldKey label="previous month" disabled={prevDisabled} onClick={() => flip(-1)}>188 <path d="M15 19l-7-7 7-7" />189 </FieldKey>190 ) : (191 <span className="w-7" />192 )}193 <span id={labelId} aria-live="polite" className="text-[13.5px] font-semibold text-ink tracking-[-0.01em]">194 {monthFmt.format(month)}195 </span>196 {m === months - 1 ? (197 <FieldKey label="next month" disabled={nextDisabled} onClick={() => flip(1)}>198 <path d="M9 5l7 7-7 7" />199 </FieldKey>200 ) : (201 <span className="w-7" />202 )}203 </div>204
205 <div role="grid" aria-labelledby={labelId}>206 <div role="row" className="grid grid-cols-7">207 {weekdays.map((w) => (208 <span209 key={w.long}210 role="columnheader"211 aria-label={w.long}212 className="grid h-7 place-items-center text-[11px] font-medium text-ink-faint"213 >214 {w.short}215 </span>216 ))}217 </div>218 <div219 key={monthIndex(month)}220 className={direction === "right" ? "animate-tab-slide-right" : "animate-tab-slide-left"}221 >222 {weeks.map((week, wi) => (223 <div role="row" key={wi} className="grid grid-cols-7">224 {week.map((d) => {225 const inMonth = d.getMonth() === month.getMonth();226 // With two months the spill-over days would show twice227 if (!inMonth && months > 1) {228 return <div role="gridcell" key={d.getTime()} className="h-[38px]" />;229 }230 const isEndpoint = range ? sameDay(d, from) || sameDay(d, to) : sameDay(d, value);231 const inBand = !!(from && to && d > from && d < to);232 const bandLeft = !!(from && to && sameDay(d, to) && !sameDay(from, to));233 const bandRight = !!(from && to && sameDay(d, from) && !sameDay(from, to));234 const isToday = sameDay(d, today);235 const isOff = disabled(d);236 const isFocus = sameDay(d, focused) && inMonth;237 return (238 <div239 role="gridcell"240 key={d.getTime()}241 aria-selected={isEndpoint || inBand}242 className="relative grid h-[38px] place-items-center"243 >244 {/* The span of a range, cut into the face like a well */}245 {(inBand || bandLeft || bandRight) && (246 <span247 aria-hidden248 className="absolute inset-y-[3px]"249 style={{250 left: bandLeft || inBand ? 0 : "50%",251 right: bandRight || inBand ? 0 : "50%",252 background: "var(--well)",253 boxShadow: "var(--well-shadow)",254 }}255 />256 )}257 <button258 type="button"259 ref={isFocus ? focusRef : undefined}260 tabIndex={isFocus ? 0 : -1}261 disabled={isOff}262 aria-label={dayLabel.format(d)}263 aria-current={isToday ? "date" : undefined}264 onClick={() => {265 move(d, false);266 if (!isOff) onChange?.(d);267 }}268 onMouseEnter={() => onDayHover?.(d)}269 className={[270 "relative grid h-9 w-9 place-items-center rounded-[10px] text-[13px] tabular-nums",271 "transition-[background-color,color,box-shadow] duration-150",272 "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink",273 "disabled:cursor-not-allowed disabled:text-ink-ghost disabled:line-through",274 isEndpoint275 ? "font-semibold text-ink-inverse"276 : [277 "cursor-pointer hover:bg-surface-active",278 !inMonth ? "text-ink-ghost" : isToday ? "font-semibold text-ink-strong" : "text-ink-soft",279 ].join(" "),280 ].join(" ")}281 style={282 isEndpoint283 ? {284 background: "var(--face-inverse)",285 boxShadow: "var(--face-inverse-shadow)",286 textShadow: "var(--emboss-inverse)",287 }288 : undefined289 }290 >291 {d.getDate()}292 {isToday && (293 <span294 aria-hidden295 className={[296 "absolute bottom-[5px] left-1/2 h-[3px] w-[3px] -translate-x-1/2 rounded-full",297 isEndpoint ? "bg-ink-inverse" : "bg-ink",298 ].join(" ")}299 />300 )}301 </button>302 </div>303 );304 })}305 </div>306 ))}307 </div>308 </div>309 </div>310 );311 })}312 </div>313 );314}315
316/* ── Pieces both pickers share ────────────────────────────────────── */317export const CalendarIcon = ({ className = "" }: { className?: string }) => (318 <svg aria-hidden width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" className={className}>319 <rect x="3" y="5" width="18" height="16" rx="2.5" />320 <path strokeLinecap="round" d="M3 10h18M8 3v4M16 3v4" />321 </svg>322);323
324export function ClearKey({ label, onClear }: { label: string; onClear: () => void }) {325 return (326 <button327 type="button"328 aria-label={label}329 onClick={onClear}330 className="absolute right-2.5 top-1/2 z-10 grid h-6 w-6 -translate-y-1/2 cursor-pointer place-items-center rounded-full text-ink-faint transition-colors hover:bg-surface-active hover:text-ink-soft"331 >332 <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">333 <path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />334 </svg>335 </button>336 );337}338
339/* ── DatePicker ───────────────────────────────────────────────────── */340interface DatePickerProps {341 value?: Date | null;342 defaultValue?: Date | null;343 onChange?: (date: Date | null) => void;344 label?: string;345 hint?: string;346 error?: string;347 placeholder?: string;348 min?: Date;349 max?: Date;350 isDateDisabled?: (date: Date) => boolean;351 weekStartsOn?: 0 | 1;352 locale?: string;353 /** How the chosen date reads in the field */354 format?: Intl.DateTimeFormatOptions;355 /** Submit the date with a form, as yyyy-mm-dd */356 name?: string;357 /** Show a small key in the field that empties it */358 clearable?: boolean;359 disabled?: boolean;360 fullWidth?: boolean;361 className?: string;362}363
364const DEFAULT_FORMAT: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" };365
366/* The closed picker is a sunken field like Select; the calendar opens367 under it on a floating panel. Picking a day closes it and hands368 focus back to the field. */369export function DatePicker({370 value,371 defaultValue = null,372 onChange,373 label,374 hint,375 error,376 placeholder = "pick a date",377 min,378 max,379 isDateDisabled,380 weekStartsOn,381 locale = "en-US",382 format = DEFAULT_FORMAT,383 name,384 clearable = false,385 disabled = false,386 fullWidth = false,387 className = "",388}: DatePickerProps) {389 const uid = useId();390 const ref = useRef<HTMLDivElement>(null);391 const buttonRef = useRef<HTMLButtonElement>(null);392 const [internal, setInternal] = useState<Date | null>(defaultValue);393 const selected = value !== undefined ? value : internal;394 const { open, mounted, toggle, show, close, setFloating, panelStyle } = useFloatingPanel(ref, {395 onEscape: () => buttonRef.current?.focus(),396 });397 const messageId = error || hint ? `${uid}-message` : undefined;398 const text = selected ? new Intl.DateTimeFormat(locale, format).format(selected) : null;399
400 const set = (d: Date | null) => {401 setInternal(d);402 onChange?.(d);403 };404
405 return (406 <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>407 {label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}408 <div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-56", className].join(" ")}>409 {/* The field's error styling keys off aria-invalid, as Input's does */}410 {/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */}411 <button412 ref={buttonRef}413 type="button"414 disabled={disabled}415 onClick={toggle}416 onKeyDown={(e) => {417 if (!open && e.key === "ArrowDown") {418 e.preventDefault();419 show();420 }421 }}422 aria-haspopup="dialog"423 aria-expanded={open}424 aria-controls={open ? `${uid}-panel` : undefined}425 aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}426 aria-invalid={error ? true : undefined}427 aria-describedby={messageId}428 data-open={open || undefined}429 className={[430 "field group/date relative isolate flex w-full cursor-pointer items-center gap-2.5 rounded-xl py-3 pl-3.5 text-left text-[13.5px]",431 clearable && selected ? "pr-10" : "pr-4",432 "focus:outline-none disabled:opacity-40 disabled:pointer-events-none",433 ].join(" ")}434 >435 <CalendarIcon436 className={[437 "shrink-0 transition-[color,transform] duration-300 ease-[var(--ease-spring)]",438 open ? "scale-110 text-ink-soft" : "text-ink-faint group-hover/date:text-ink-body",439 ].join(" ")}440 />441 <span442 id={`${uid}-value`}443 className={["truncate tabular-nums", text ? "font-medium text-ink" : "text-ink-faint"].join(" ")}444 >445 {text ?? placeholder}446 </span>447 </button>448
449 {clearable && selected && !disabled && (450 <ClearKey451 label="clear date"452 onClear={() => {453 set(null);454 buttonRef.current?.focus();455 }}456 />457 )}458
459 {name && <input type="hidden" name={name} value={selected ? toISODate(selected) : ""} />}460 </div>461
462 {mounted && (463 <Portal>464 <div465 ref={setFloating}466 id={`${uid}-panel`}467 role="dialog"468 aria-label={label ? `choose ${label}` : "choose a date"}469 className="rounded-[16px] p-1.5"470 style={panelStyle}471 >472 <div className="rounded-[11px] bg-panel-face p-2.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>473 <Calendar474 value={selected}475 onChange={(d) => {476 set(d);477 close();478 buttonRef.current?.focus();479 }}480 min={min}481 max={max}482 isDateDisabled={isDateDisabled}483 weekStartsOn={weekStartsOn}484 locale={locale}485 autoFocus486 />487 </div>488 </div>489 </Portal>490 )}491 <FieldMessage id={messageId} error={error} hint={hint} />492 </div>493 );494}components/ui/Floating.tsxtsx
1"use client";2
3import React, { RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";4import { createPortal } from "react-dom";5
6/* ── Portal ───────────────────────────────────────────────────────── */7/* Anything that floats over the page is rendered at the end of <body>.8 Left where it was declared, a panel lives inside whatever stacking9 context its parents made (a transform, a filter, an isolate), and a10 later sibling on the page can paint straight over it, whatever its11 z-index says. */12const noop = () => () => {};13
14export function Portal({ children }: { children: React.ReactNode }) {15 const client = useSyncExternalStore(noop, () => true, () => false);16 return client ? createPortal(children, document.body) : null;17}18
19/* Layers, lowest first: dialogs, then toasts, then anything anchored to20 a control (so a select inside a modal opens over the modal), then21 tooltips over all of it. */22export const LAYER = { dialog: 90, toast: 95, floating: 100, tooltip: 110 } as const;23
24/* ── Anchored position ────────────────────────────────────────────── */25export type Side = "top" | "bottom" | "left" | "right";26export type Align = "start" | "center" | "end";27
28const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };29const PAD = 8;30
31interface AnchorOptions {32 open: boolean;33 side?: Side;34 align?: Align;35 /** Space between the anchor and the panel */36 gap?: number;37 /** Make the panel at least as wide as the anchor (selects, comboboxes) */38 matchWidth?: boolean;39}40
41/* Places a fixed-position panel next to its anchor: on the side asked42 for unless the other side has more room, then nudged back inside the43 viewport. Follows the anchor while the page scrolls or resizes. The44 position is written straight onto the element, so scrolling never45 re-renders the component. */46export function useAnchoredPosition(47 anchor: RefObject<HTMLElement | null>,48 { open, side = "bottom", align = "start", gap = 8, matchWidth = false }: AnchorOptions,49) {50 const [placed, setPlaced] = useState<Side>(side);51 const floating = useRef<HTMLElement | null>(null);52
53 const update = useCallback(() => {54 const a = anchor.current?.getBoundingClientRect();55 const el = floating.current;56 if (!a || !el) return;57 if (matchWidth) el.style.minWidth = `${a.width}px`;58
59 // offset sizes ignore the enter transform, so these are the settled size60 const w = el.offsetWidth;61 const h = el.offsetHeight;62 const vw = document.documentElement.clientWidth;63 const vh = window.innerHeight;64 const room: Record<Side, number> = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };65 const vertical = side === "top" || side === "bottom";66 const need = (vertical ? h : w) + gap + PAD;67
68 let s = side;69 if (room[s] < need && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];70
71 let top: number;72 let left: number;73 if (s === "top" || s === "bottom") {74 top = s === "bottom" ? a.bottom + gap : a.top - gap - h;75 left = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2;76 } else {77 left = s === "right" ? a.right + gap : a.left - gap - w;78 top = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2;79 }80 left = Math.max(PAD, Math.min(left, vw - w - PAD));81 top = Math.max(PAD, Math.min(top, vh - h - PAD));82
83 el.style.top = `${Math.round(top)}px`;84 el.style.left = `${Math.round(left)}px`;85 el.dataset.side = s;86 setPlaced(s);87 }, [anchor, side, align, gap, matchWidth]);88
89 /* Attach this as the panel's ref: it's placed before its first paint */90 const setFloating = useCallback(91 (el: HTMLElement | null) => {92 floating.current = el;93 if (el) update();94 },95 [update],96 );97
98 useEffect(() => {99 if (!open) return;100 let frame = 0;101 const schedule = () => {102 cancelAnimationFrame(frame);103 frame = requestAnimationFrame(update);104 };105 window.addEventListener("scroll", schedule, true);106 window.addEventListener("resize", schedule);107 // The panel can change size while open (a filtered list, a new month)108 const ro = new ResizeObserver(schedule);109 if (floating.current) ro.observe(floating.current);110 if (anchor.current) ro.observe(anchor.current);111 return () => {112 cancelAnimationFrame(frame);113 window.removeEventListener("scroll", schedule, true);114 window.removeEventListener("resize", schedule);115 ro.disconnect();116 };117 }, [open, update, anchor]);118
119 return { setFloating, side: placed, floatingRef: floating, update };120}121
122/* Where the panel grows from, and where it slides in from, for each side */123export const ORIGIN: Record<Side, string> = {124 top: "bottom",125 bottom: "top",126 left: "right",127 right: "left",128};129export const ENTER_FROM: Record<Side, string> = {130 top: "translateY(4px)",131 bottom: "translateY(-4px)",132 left: "translateX(4px)",133 right: "translateX(-4px)",134};components/ui/FieldMessage.tsxtsx
1"use client";2
3import React from "react";4
5/* The label above a field and the line under it, shared by every form6 control so they read as one family. */7
8export function FieldLabel({9 id,10 htmlFor,11 children,12}: {13 id?: string;14 htmlFor?: string;15 children: React.ReactNode;16}) {17 const className =18 "text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";19 return htmlFor ? (20 <label id={id} htmlFor={htmlFor} className={className}>21 {children}22 </label>23 ) : (24 <span id={id} className={className}>25 {children}26 </span>27 );28}29
30/* An error wins over a hint; either is announced through `id` */31export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {32 if (error) {33 return (34 <p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">35 <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">36 <circle cx="12" cy="12" r="10" />37 <path strokeLinecap="round" d="M12 8v4m0 4h.01" />38 </svg>39 {error}40 </p>41 );42 }43 if (hint) {44 return (45 <p id={id} className="text-[11.5px] text-ink-muted mt-0.5">46 {hint}47 </p>48 );49 }50 return null;51}52
53/* A small raised key that sits inside a field or a panel: steppers,54 month arrows, clear buttons. It sinks flush while held. */55export function FieldKey({56 label,57 disabled,58 onClick,59 onPress,60 onRelease,61 focusable = true,62 size = 28,63 children,64}: {65 label: string;66 disabled?: boolean;67 onClick?: () => void;68 /** Pointer went down (for hold-to-repeat) */69 onPress?: () => void;70 onRelease?: () => void;71 focusable?: boolean;72 size?: number;73 children: React.ReactNode;74}) {75 const [pressed, setPressed] = React.useState(false);76 const release = () => {77 setPressed(false);78 onRelease?.();79 };80 return (81 <button82 type="button"83 aria-label={label}84 tabIndex={focusable ? undefined : -1}85 disabled={disabled}86 onClick={onClick}87 onPointerDown={(e) => {88 if (e.button !== 0) return;89 if (!focusable) e.preventDefault(); // keep focus in the field90 setPressed(true);91 onPress?.();92 }}93 onPointerUp={release}94 onPointerLeave={() => pressed && release()}95 onPointerCancel={release}96 className="grid shrink-0 cursor-pointer place-items-center rounded-[9px] text-ink-muted transition-[box-shadow,color,opacity] duration-150 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink disabled:opacity-35 disabled:pointer-events-none"97 style={{98 width: size,99 height: size,100 background: "var(--face)",101 boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",102 }}103 >104 <svg105 aria-hidden106 width="12"107 height="12"108 fill="none"109 stroke="currentColor"110 strokeWidth="2.25"111 strokeLinecap="round"112 strokeLinejoin="round"113 viewBox="0 0 24 24"114 style={{115 transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",116 transition: "transform 150ms var(--ease-spring)",117 }}118 >119 {children}120 </svg>121 </button>122 );123}components/ui/useFloatingPanel.tstsx
1"use client";2
3import { CSSProperties, RefObject, useCallback, useEffect, useRef, useState } from "react";4import { Align, ENTER_FROM, LAYER, ORIGIN, Side, useAnchoredPosition } from "./Floating";5
6interface FloatingPanelOptions {7 side?: Side;8 align?: Align;9 gap?: number;10 matchWidth?: boolean;11 /** Called after Escape closes the panel, to hand focus back */12 onEscape?: () => void;13}14
15/* The open/close choreography every field-anchored panel shares (select,16 date picker, colour picker, combobox): the panel is portalled to the17 body and pinned next to its anchor, stays mounted while it folds away,18 unfolds only once painted, and a click outside or Escape puts it back.19 Render the panel inside <Portal> with ref={setFloating} and20 style={panelStyle}. */21export function useFloatingPanel(anchor: RefObject<HTMLElement | null>, options: FloatingPanelOptions = {}) {22 const { side = "bottom", align = "start", gap = 8, matchWidth = false, onEscape } = options;23 const [open, setOpen] = useState(false);24 const [mounted, setMounted] = useState(false);25 const [entered, setEntered] = useState(false);26 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);27 const escape = useRef(onEscape);28 useEffect(() => {29 escape.current = onEscape;30 });31
32 const position = useAnchoredPosition(anchor, { open: mounted, side, align, gap, matchWidth });33 const { floatingRef } = position;34
35 const close = useCallback(() => {36 setOpen(false);37 setEntered(false);38 if (timer.current) clearTimeout(timer.current);39 timer.current = setTimeout(() => setMounted(false), 200);40 }, []);41
42 const show = useCallback(() => {43 if (timer.current) clearTimeout(timer.current);44 setOpen(true);45 setMounted(true);46 requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));47 }, []);48
49 useEffect(() => () => {50 if (timer.current) clearTimeout(timer.current);51 }, []);52
53 useEffect(() => {54 if (!open) return;55 const onDown = (e: PointerEvent) => {56 const t = e.target as Node;57 if (anchor.current?.contains(t) || floatingRef.current?.contains(t)) return;58 close();59 };60 const onKey = (e: KeyboardEvent) => {61 if (e.key === "Escape") {62 e.stopPropagation();63 close();64 escape.current?.();65 }66 };67 document.addEventListener("pointerdown", onDown);68 document.addEventListener("keydown", onKey, true);69 return () => {70 document.removeEventListener("pointerdown", onDown);71 document.removeEventListener("keydown", onKey, true);72 };73 }, [open, anchor, floatingRef, close]);74
75 /* Unfolds out of the field: a clip reveals it from the anchor's side76 while it fades and settles. The clip reaches past the panel so its77 shadow is never cut off. */78 const s = position.side;79 const hidden: Record<Side, string> = {80 bottom: "inset(-48px -48px 55% -48px)",81 top: "inset(55% -48px -48px -48px)",82 left: "inset(-48px -48px -48px 55%)",83 right: "inset(-48px 55% -48px -48px)",84 };85 const panelStyle: CSSProperties = {86 position: "fixed",87 top: 0,88 left: 0,89 zIndex: LAYER.floating,90 background: "var(--panel-bezel)",91 boxShadow: "var(--overlay-shadow)",92 transformOrigin: ORIGIN[s],93 opacity: entered ? 1 : 0,94 transform: entered ? "none" : `${ENTER_FROM[s]} scale(0.98)`,95 clipPath: entered ? "inset(-48px -48px -48px -48px)" : hidden[s],96 transition: entered97 ? "opacity 180ms ease-out, transform 360ms var(--ease-out-soft), clip-path 360ms var(--ease-out-soft)"98 : "opacity 160ms ease-in, transform 200ms ease-in, clip-path 200ms ease-in",99 };100
101 return {102 open,103 mounted,104 entered,105 show,106 close,107 toggle: open ? close : show,108 side: s,109 setFloating: position.setFloating,110 panelStyle,111 };112}usage
tsx
import { TimePicker } from "@blurrow/ui";tsx
<TimePicker label="reminder" value={time} onChange={setTime} />examples
12-hour, with limits
pickup
between 8 am and 6 pm
props
straight from the source, so they're always up to date.
propsts
interface TimePickerProps { /** 24-hour "HH:mm", like "14:30" */ value?: string | null; defaultValue?: string | null; onChange?: (time: string | null) => void; /** Minutes between choices in the minute column */ step?: number; /** Show 1–12 with am / pm instead of 0–23 */ hour12?: boolean; /** Earliest and latest times, as "HH:mm" */ min?: string; max?: string; label?: string; hint?: string; error?: string; placeholder?: string; clearable?: boolean; disabled?: boolean; fullWidth?: boolean; name?: string; className?: string;}