{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"date-picker","type":"registry:component","title":"Date picker","description":"One date, typed or picked: the field reads \"tomorrow\", \"next fri\" or \"4 nov\", and the calendar slides by month while one mark glides between days.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/date-picker.tsx","type":"registry:component","content":"// Hairline UI · date-picker · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Field, fieldSurface, useFieldIds, useShake } from \"./field\";\nimport { enterStyle, themeVars, useDismiss, useFloating, usePresence } from \"./floating\";\nimport { TextMorph } from \"./text-morph\";\nimport { EASE_OUT, EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * DATE PICKER: one date, typed or picked\n *\n *   type     the field reads what people write: \"tomorrow\",\n *            \"next fri\", \"in 3 days\", \"4 nov\", \"2026-11-04\"; Enter\n *            or leaving the field settles it into \"Wed 4 Nov 2026\",\n *            and anything it can't read shakes and says so\n *   pick     the calendar rises off the field; months slide in from\n *            the side you moved toward while the month name morphs;\n *            the chosen day's mark glides from day to day\n *   keys     arrows move a day or a week, Page Up/Down a month,\n *            Home/End the week's ends, Enter picks, Escape closes\n *\n * Values are plain \"YYYY-MM-DD\" strings: no time zones to trip on.\n * ───────────────────────────────────────────────────────── */\n\nexport interface DatePickerProps {\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string | null) => void;\n  label?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  placeholder?: string;\n  /** Earliest and latest allowed days, \"YYYY-MM-DD\". */\n  min?: string;\n  max?: string;\n  /** 1 = Monday (default), 0 = Sunday. */\n  weekStartsOn?: 0 | 1;\n  locale?: string;\n  id?: string;\n  disabled?: boolean;\n  className?: string;\n}\n\n// ── Plain-date helpers (UTC under the hood so no zone shifts a day) ──\nconst toISO = (d: Date) => d.toISOString().slice(0, 10);\nconst fromISO = (s: string) => new Date(`${s}T00:00:00Z`);\nconst addDays = (s: string, n: number) => {\n  const d = fromISO(s);\n  d.setUTCDate(d.getUTCDate() + n);\n  return toISO(d);\n};\nconst addMonths = (s: string, n: number) => {\n  const d = fromISO(s);\n  const day = d.getUTCDate();\n  d.setUTCDate(1);\n  d.setUTCMonth(d.getUTCMonth() + n);\n  const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();\n  d.setUTCDate(Math.min(day, last));\n  return toISO(d);\n};\nconst monthOf = (s: string) => s.slice(0, 7);\nconst localToday = () => {\n  const n = new Date();\n  return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, \"0\")}-${String(n.getDate()).padStart(2, \"0\")}`;\n};\nconst subscribeDay = (cb: () => void) => {\n  const t = window.setInterval(cb, 60_000);\n  return () => window.clearInterval(t);\n};\n\nconst MONTHS = [\"jan\", \"feb\", \"mar\", \"apr\", \"may\", \"jun\", \"jul\", \"aug\", \"sep\", \"oct\", \"nov\", \"dec\"];\nconst DAYS = [\"sun\", \"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\"];\n\n/** Reads a typed date relative to `today`; null if it can't. */\nexport function parseDate(input: string, today: string): string | null {\n  const t = input.trim().toLowerCase().replace(/\\s+/g, \" \");\n  if (!t) return null;\n  if (t === \"today\" || t === \"now\") return today;\n  if (t === \"tomorrow\" || t === \"tmr\") return addDays(today, 1);\n  if (t === \"yesterday\") return addDays(today, -1);\n  let m = t.match(/^(?:in )?\\+?(\\d+) ?(d|day|days|w|wk|week|weeks|m|mo|month|months)(?: from now)?$/);\n  if (m) {\n    const n = Number(m[1]);\n    return m[2].startsWith(\"m\") ? addMonths(today, n) : addDays(today, m[2].startsWith(\"w\") ? n * 7 : n);\n  }\n  m = t.match(/^(next |this )?(sun|mon|tue|wed|thu|fri|sat)[a-z]*$/);\n  if (m) {\n    const want = DAYS.indexOf(m[2]);\n    const now = fromISO(today).getUTCDay();\n    let diff = (want - now + 7) % 7;\n    if (diff === 0 || m[1] === \"next \") diff = diff === 0 ? 7 : diff;\n    return addDays(today, diff);\n  }\n  m = t.match(/^(\\d{4})-(\\d{1,2})-(\\d{1,2})$/);\n  if (m) return valid(+m[1], +m[2], +m[3]);\n  // \"4 nov\", \"4 november 2027\", \"nov 4\", \"november 4, 2027\"\n  m = t.match(/^(\\d{1,2})(?:st|nd|rd|th)? ([a-z]{3})[a-z]*,? ?(\\d{4})?$/) ?? null;\n  if (m) return withYear(+m[1], MONTHS.indexOf(m[2]) + 1, m[3], today);\n  m = t.match(/^([a-z]{3})[a-z]* (\\d{1,2})(?:st|nd|rd|th)?,? ?(\\d{4})?$/);\n  if (m) return withYear(+m[2], MONTHS.indexOf(m[1]) + 1, m[3], today);\n  // 4/11/2026 reads day first\n  m = t.match(/^(\\d{1,2})[/.](\\d{1,2})(?:[/.](\\d{2,4}))?$/);\n  if (m) return withYear(+m[1], +m[2], m[3] ? (m[3].length === 2 ? `20${m[3]}` : m[3]) : undefined, today);\n  return null;\n}\nfunction valid(y: number, mo: number, d: number) {\n  if (mo < 1 || mo > 12 || d < 1) return null;\n  const date = new Date(Date.UTC(y, mo - 1, d));\n  return date.getUTCMonth() === mo - 1 ? toISO(date) : null;\n}\nfunction withYear(d: number, mo: number, y: string | undefined, today: string) {\n  if (mo < 1) return null;\n  if (y) return valid(+y, mo, d);\n  const thisYear = valid(+today.slice(0, 4), mo, d);\n  // No year: the next time that date comes round.\n  return thisYear && thisYear < today ? valid(+today.slice(0, 4) + 1, mo, d) : thisYear;\n}\n\nfunction Arrow({ back }: { back?: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n      <path d={back ? \"M10 3.5 5.5 8l4.5 4.5\" : \"M6 3.5 10.5 8 6 12.5\"} stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nexport function DatePicker({\n  value,\n  defaultValue = null,\n  onValueChange,\n  label,\n  hint,\n  error,\n  placeholder = \"Pick a date or type one\",\n  min,\n  max,\n  weekStartsOn = 1,\n  locale = \"en-GB\",\n  id,\n  disabled,\n  className = \"\",\n}: DatePickerProps) {\n  const reduced = useInteractionReducedMotion();\n  const ids = useFieldIds(id);\n  const today = React.useSyncExternalStore(subscribeDay, localToday, () => null);\n  const [own, setOwn] = React.useState<string | null>(defaultValue);\n  const current = value !== undefined ? value : own;\n  const [draft, setDraft] = React.useState<string | null>(null);\n  const [badInput, setBadInput] = React.useState(false);\n  const [open, setOpen] = React.useState(false);\n  const [view, setView] = React.useState(() => monthOf(current ?? defaultValue ?? \"2026-01-01\"));\n  const [focusDay, setFocusDay] = React.useState<string | null>(null);\n  const [dir, setDir] = React.useState(0);\n  const anchor = React.useRef<HTMLDivElement>(null);\n  const surface = React.useRef<HTMLDivElement>(null);\n  const grid = React.useRef<HTMLDivElement>(null);\n  const { mounted, shown } = usePresence(open, 180);\n  const place = useFloating({ open: mounted, anchor, floating: surface, side: \"bottom\", align: \"start\", offset: 6 });\n  const [vars, setVars] = React.useState<React.CSSProperties>({});\n  const shakeRef = useShake<HTMLDivElement>(badInput || !!error);\n  const [mark, setMark] = React.useState<{ x: number; y: number } | null>(null);\n  const [markReady, setMarkReady] = React.useState(false);\n\n  const fmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"short\", day: \"numeric\", month: \"short\", year: \"numeric\", timeZone: \"UTC\" }), [locale]);\n  const monthFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\", timeZone: \"UTC\" }), [locale]);\n  const dayFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"long\", day: \"numeric\", month: \"long\", year: \"numeric\", timeZone: \"UTC\" }), [locale]);\n  const headFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"narrow\", timeZone: \"UTC\" }), [locale]);\n\n  const allowed = (d: string) => (!min || d >= min) && (!max || d <= max);\n  const commit = (next: string | null) => {\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  const settle = () => {\n    if (draft === null) return;\n    const text = draft.trim();\n    if (!text) {\n      setDraft(null);\n      setBadInput(false);\n      return commit(null);\n    }\n    const parsed = parseDate(text, today ?? localToday());\n    if (!parsed || !allowed(parsed)) return setBadInput(true);\n    setDraft(null);\n    setBadInput(false);\n    setView(monthOf(parsed));\n    commit(parsed);\n  };\n\n  const openCalendar = () => {\n    const start = current ?? today ?? localToday();\n    setView(monthOf(start));\n    setFocusDay(start);\n    setDir(0);\n    setOpen(true);\n  };\n  const close = (refocus: boolean) => {\n    setOpen(false);\n    if (refocus) document.getElementById(ids.id)?.focus();\n  };\n  useDismiss(open, (reason) => close(reason === \"escape\"), [anchor, surface]);\n\n  React.useLayoutEffect(() => {\n    if (mounted) setVars(themeVars(anchor.current));\n  }, [mounted]);\n\n  // Days of the shown month, padded to whole weeks.\n  const days = React.useMemo(() => {\n    const first = fromISO(`${view}-01`);\n    const lead = (first.getUTCDay() - weekStartsOn + 7) % 7;\n    const start = addDays(`${view}-01`, -lead);\n    const count = Math.ceil((lead + new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()) / 7) * 7;\n    return Array.from({ length: count }, (_, i) => addDays(start, i));\n  }, [view, weekStartsOn]);\n\n  const goMonth = (n: number) => {\n    setDir(n);\n    setView((v) => monthOf(addMonths(`${v}-01`, n)));\n    setFocusDay((f) => (f ? addMonths(f, n) : f));\n  };\n\n  // The chosen day's mark glides within a month; a new month slides in from its side.\n  React.useLayoutEffect(() => {\n    const g = grid.current;\n    if (!g) return;\n    const measure = () => {\n      const el = current ? g.querySelector<HTMLElement>(`[data-day=\"${current}\"]`) : null;\n      setMark((p) => (el ? (p && p.x === el.offsetLeft && p.y === el.offsetTop ? p : { x: el.offsetLeft, y: el.offsetTop }) : null));\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(g);\n    return () => ro.disconnect();\n  }, [current, view, mounted]);\n  React.useEffect(() => {\n    if (!mark || markReady) return;\n    const f = requestAnimationFrame(() => setMarkReady(true));\n    return () => cancelAnimationFrame(f);\n  }, [mark, markReady]);\n  React.useLayoutEffect(() => {\n    if (!dir || reduced) return;\n    grid.current?.animate(\n      [\n        { opacity: 0, transform: `translateX(${dir * 24}px)`, filter: \"blur(4px)\" },\n        { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n      ],\n      { duration: 360, easing: EASE_OUT },\n    );\n  }, [view, dir, reduced]);\n\n  // Keep keyboard focus on the focused day once the calendar is up.\n  React.useEffect(() => {\n    if (!open || !shown || !focusDay) return;\n    grid.current?.querySelector<HTMLElement>(`[data-day=\"${focusDay}\"]`)?.focus({ preventScroll: true });\n  }, [open, shown, focusDay, view]);\n\n  const onGridKey = (e: React.KeyboardEvent) => {\n    if (!focusDay) return;\n    const moves: Record<string, () => string> = {\n      ArrowLeft: () => addDays(focusDay, -1),\n      ArrowRight: () => addDays(focusDay, 1),\n      ArrowUp: () => addDays(focusDay, -7),\n      ArrowDown: () => addDays(focusDay, 7),\n      PageUp: () => addMonths(focusDay, -1),\n      PageDown: () => addMonths(focusDay, 1),\n      Home: () => addDays(focusDay, -((fromISO(focusDay).getUTCDay() - weekStartsOn + 7) % 7)),\n      End: () => addDays(focusDay, 6 - ((fromISO(focusDay).getUTCDay() - weekStartsOn + 7) % 7)),\n    };\n    if (e.key in moves) {\n      e.preventDefault();\n      const next = moves[e.key]();\n      if (monthOf(next) !== view) {\n        setDir(next > focusDay ? 1 : -1);\n        setView(monthOf(next));\n      }\n      setFocusDay(next);\n    } else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      if (allowed(focusDay)) {\n        commit(focusDay);\n        close(true);\n      }\n    }\n  };\n\n  const shownText = draft ?? (current ? fmt.format(fromISO(current)) : \"\");\n  const describedBy = [hint && !error && !badInput ? ids.hint : null, error || badInput ? ids.error : null].filter(Boolean).join(\" \") || undefined;\n\n  return (\n    <Field id={ids.id} label={label} hint={hint} error={error ?? (badInput ? \"Couldn't read that date. Try “tomorrow” or “4 Nov”.\" : undefined)} className={className}>\n      <div ref={anchor}>\n        <div ref={shakeRef} className={`${fieldSurface({ invalid: !!error || badInput, disabled })} h-9 text-[16px] sm:text-[13.5px]`}>\n          <input\n            id={ids.id}\n            value={shownText}\n            placeholder={placeholder}\n            disabled={disabled}\n            aria-invalid={error || badInput ? true : undefined}\n            aria-describedby={describedBy}\n            onChange={(e) => {\n              setDraft(e.target.value);\n              setBadInput(false);\n            }}\n            onBlur={(e) => {\n              if (!surface.current?.contains(e.relatedTarget as Node)) settle();\n            }}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") {\n                e.preventDefault();\n                settle();\n              } else if (e.key === \"ArrowDown\" && e.altKey) {\n                e.preventDefault();\n                openCalendar();\n              }\n            }}\n            className=\"h-full min-w-0 flex-1 bg-transparent pl-4 pr-1 outline-none placeholder:text-muted-foreground/70\"\n          />\n          <button\n            type=\"button\"\n            aria-label=\"Choose date\"\n            aria-haspopup=\"dialog\"\n            aria-expanded={open}\n            disabled={disabled}\n            onClick={() => (open ? close(false) : openCalendar())}\n            className={`mr-1 flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}\n          >\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n              <rect x=\"2.5\" y=\"3.5\" width=\"11\" height=\"10\" rx=\"2\" stroke=\"currentColor\" strokeWidth=\"1.4\" />\n              <path d=\"M2.5 6.5h11M5.5 2v3M10.5 2v3\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" />\n            </svg>\n          </button>\n        </div>\n      </div>\n      {mounted &&\n        createPortal(\n          <div\n            ref={surface}\n            role=\"dialog\"\n            aria-label=\"Choose date\"\n            className=\"fixed left-0 top-0 z-[55] w-[284px] rounded-[18px] bg-popover p-3 text-popover-foreground shadow-[0_0_0_1px_var(--border)]\"\n            style={{ ...vars, left: place.x, top: place.y, transformOrigin: place.origin, ...enterStyle(shown, place.side, reduced) }}\n          >\n            <div className=\"mb-2 flex items-center justify-between pl-2\">\n              <span className=\"text-[13.5px] font-medium text-foreground\" aria-live=\"polite\">\n                <TextMorph>{monthFmt.format(fromISO(`${view}-01`))}</TextMorph>\n              </span>\n              <div className=\"flex\">\n                {[-1, 1].map((n) => (\n                  <button\n                    key={n}\n                    type=\"button\"\n                    aria-label={n < 0 ? \"Previous month\" : \"Next month\"}\n                    onClick={() => goMonth(n)}\n                    className={`flex size-7 items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}\n                  >\n                    <Arrow back={n < 0} />\n                  </button>\n                ))}\n              </div>\n            </div>\n            <div className=\"grid grid-cols-7 pb-1 text-center text-[11px] text-muted-foreground\" aria-hidden=\"true\">\n              {days.slice(0, 7).map((d) => (\n                <span key={d}>{headFmt.format(fromISO(d))}</span>\n              ))}\n            </div>\n            <div ref={grid} role=\"grid\" aria-label={monthFmt.format(fromISO(`${view}-01`))} onKeyDown={onGridKey} className=\"relative grid grid-cols-7 gap-y-0.5\">\n              <span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute left-0 top-0 size-9 rounded-full bg-primary\"\n                style={{\n                  transform: `translate3d(${mark?.x ?? 0}px, ${mark?.y ?? 0}px, 0)`,\n                  opacity: mark ? 1 : 0,\n                  transition: reduced || !markReady ? \"none\" : `transform 420ms ${EASE_THROW}, opacity 200ms ${EASE_OUT}`,\n                }}\n              />\n              {days.map((d) => {\n                const inMonth = monthOf(d) === view;\n                const on = d === current;\n                const ok = allowed(d);\n                return (\n                  <button\n                    key={d}\n                    type=\"button\"\n                    role=\"gridcell\"\n                    data-day={d}\n                    aria-selected={on}\n                    aria-label={dayFmt.format(fromISO(d))}\n                    aria-current={d === today ? \"date\" : undefined}\n                    tabIndex={d === focusDay ? 0 : -1}\n                    disabled={!ok}\n                    onClick={() => {\n                      commit(d);\n                      setDraft(null);\n                      setBadInput(false);\n                      close(true);\n                    }}\n                    className={`relative z-10 mx-auto flex size-9 items-center justify-center rounded-full text-[13px] tabular-nums cursor-pointer transition-colors duration-200 disabled:cursor-default disabled:opacity-30 ${INTERACTION_FOCUS} focus-visible:ring-offset-0 ${\n                      on ? \"text-primary-foreground\" : inMonth ? \"text-foreground hover:bg-accent\" : \"text-muted-foreground/50 hover:bg-accent\"\n                    } ${d === today && !on ? \"shadow-[inset_0_0_0_1px_var(--border)]\" : \"\"}`}\n                  >\n                    {Number(d.slice(8))}\n                  </button>\n                );\n              })}\n            </div>\n            <div className=\"mt-2 flex justify-between border-t border-border pt-2\">\n              <button type=\"button\" onClick={() => { const t = today ?? localToday(); commit(t); setDraft(null); close(true); }} className={`h-7 rounded-full px-2.5 text-[12.5px] font-medium text-foreground cursor-pointer hover:bg-accent ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>\n                Today\n              </button>\n              {current && (\n                <button type=\"button\" onClick={() => { commit(null); setDraft(null); close(true); }} className={`h-7 rounded-full px-2.5 text-[12.5px] text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>\n                  Clear\n                </button>\n              )}\n            </div>\n          </div>,\n          document.body,\n        )}\n    </Field>\n  );\n}\n\nexport default DatePicker;\n"},{"path":"registry/field.tsx","type":"registry:component","content":"// Hairline UI · date-picker · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * FIELD: the column every text control sits in\n *\n *   label    above, with an optional aside on the right (a count)\n *   hint     under the control while all is well\n *   error    takes the hint's place: the hint folds away, the\n *            error rises in out of a blur, the control shakes once\n *\n * Shared by input, textarea, password, search and select so the\n * whole family has one surface, one focus ring and one error.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FieldProps {\n  id: string;\n  label?: React.ReactNode;\n  /** Right side of the label row, e.g. a character count. */\n  aside?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  required?: boolean;\n  className?: string;\n  children: React.ReactNode;\n}\n\nexport type FieldSize = \"sm\" | \"md\";\n\n/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */\nexport function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {\n  const ring = invalid\n    ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]\"\n    : \"shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]\";\n  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? \"rounded-full\" : \"rounded-[14px]\"} ${ring} ${\n    disabled ? \"pointer-events-none opacity-50\" : \"\"\n  }`;\n}\n\n/** ids for the control's label, hint and error. */\nexport function useFieldIds(id?: string) {\n  const auto = React.useId();\n  const base = id ?? auto;\n  return { id: base, hint: `${base}-hint`, error: `${base}-error` };\n}\n\n/** One short shake whenever `on` turns true (an error arrives). */\nexport function useShake<T extends HTMLElement>(on: boolean) {\n  const ref = React.useRef<T>(null);\n  const reduced = useInteractionReducedMotion();\n  React.useEffect(() => {\n    if (!on || reduced) return;\n    ref.current?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n      { duration: 380, easing: \"ease-out\" },\n    );\n  }, [on, reduced]);\n  return ref;\n}\n\n/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */\nexport function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {\n  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n  Object.getOwnPropertyDescriptor(proto, \"value\")?.set?.call(el, value);\n  el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n}\n\n/** A line under the control that folds open and closed; keeps its last words while closing. */\nfunction Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {\n  const [kept, setKept] = React.useState(children);\n  if (show && children !== kept) setKept(children);\n  return (\n    <div\n      className=\"grid\"\n      style={{ gridTemplateRows: show ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE_OUT}` }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">\n        <p\n          id={id}\n          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}\n          style={{\n            opacity: show ? 1 : 0,\n            filter: show ? \"blur(0px)\" : \"blur(4px)\",\n            transform: show ? \"none\" : \"translateY(-4px)\",\n            transition: reduced ? \"none\" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,\n          }}\n        >\n          {show ? children : kept}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport function Field({ id, label, aside, hint, error, required, className = \"\", children }: FieldProps) {\n  const reduced = useInteractionReducedMotion();\n  const ids = { hint: `${id}-hint`, error: `${id}-error` };\n  return (\n    <div className={`flex w-full flex-col ${className}`}>\n      {(label || aside) && (\n        <div className=\"flex items-baseline justify-between gap-3 px-3 pb-1.5\">\n          {label && (\n            <label htmlFor={id} className=\"text-[13px] font-medium text-foreground\">\n              {label}\n              {required && (\n                <span aria-hidden=\"true\" className=\"ml-0.5 text-muted-foreground\">\n                  *\n                </span>\n              )}\n            </label>\n          )}\n          {aside && <span className=\"text-[12px] tabular-nums text-muted-foreground\">{aside}</span>}\n        </div>\n      )}\n      {children}\n      <Line show={!error && !!hint} id={ids.hint} tone=\"text-muted-foreground\" reduced={reduced}>\n        {hint}\n      </Line>\n      <Line show={!!error} id={ids.error} tone=\"text-red-600 dark:text-red-400\" reduced={reduced}>\n        {error}\n      </Line>\n    </div>\n  );\n}\n\nexport default Field;\n"},{"path":"registry/floating.ts","type":"registry:component","content":"// Hairline UI · date-picker · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * FLOATING: what every popover, menu and tooltip shares\n *\n *   place     beside an anchor on a side, lined up start, centre\n *             or end; flips to the other side when it doesn't fit\n *             and shifts to stay `padding` inside the viewport;\n *             follows scroll and resize\n *   presence  mounts on open, shows a frame later (so the entry\n *             transitions run), hides, then unmounts after `exit`\n *   dismiss   Escape and a press outside both close it\n *   theme     the portal carries the anchor's theme tokens, so a\n *             light specimen on a dark page stays light\n * ───────────────────────────────────────────────────────── */\n\nexport type Side = \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type Align = \"start\" | \"center\" | \"end\";\n\nconst TOKENS = [\"--background\", \"--foreground\", \"--popover\", \"--popover-foreground\", \"--border\", \"--accent\", \"--accent-foreground\", \"--primary\", \"--primary-foreground\", \"--muted\", \"--muted-foreground\", \"--ring\", \"--card\"];\n\n/** The anchor's theme tokens as inline styles for something portalled out of its tree. */\nexport function themeVars(anchor: Element | null): React.CSSProperties {\n  if (!anchor) return {};\n  const style = getComputedStyle(anchor);\n  const vars: Record<string, string> = {};\n  for (const t of TOKENS) {\n    const v = style.getPropertyValue(t);\n    if (v) vars[t] = v;\n  }\n  return vars as React.CSSProperties;\n}\n\nconst OPPOSITE: Record<Side, Side> = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" };\n\nexport interface Placement {\n  x: number;\n  y: number;\n  side: Side;\n  /** Where the surface grows from, for transform-origin. */\n  origin: string;\n  ready: boolean;\n}\n\n/** Positions `floating` (position: fixed) next to `anchor` while `open`. Pass a point instead of an anchor for context menus. */\nexport function useFloating({\n  open,\n  anchor,\n  point,\n  floating,\n  side = \"bottom\",\n  align = \"center\",\n  offset = 8,\n  padding = 8,\n}: {\n  open: boolean;\n  anchor?: React.RefObject<Element | null>;\n  point?: { x: number; y: number } | null;\n  floating: React.RefObject<HTMLElement | null>;\n  side?: Side;\n  align?: Align;\n  offset?: number;\n  padding?: number;\n}): Placement {\n  const [place, setPlace] = React.useState<Placement>({ x: -9999, y: -9999, side, origin: \"center\", ready: false });\n\n  React.useLayoutEffect(() => {\n    if (!open) return;\n    let frame = 0;\n    const measure = () => {\n      const el = floating.current;\n      if (!el) return;\n      const a = point\n        ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 }\n        : anchor?.current?.getBoundingClientRect();\n      if (!a) return;\n      const w = el.offsetWidth;\n      const h = el.offsetHeight;\n      const vw = window.innerWidth;\n      const vh = window.innerHeight;\n      const room: Record<Side, number> = { top: a.top - padding, bottom: vh - a.bottom - padding, left: a.left - padding, right: vw - a.right - padding };\n      const need = side === \"top\" || side === \"bottom\" ? h + offset : w + offset;\n      const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;\n      let x: number;\n      let y: number;\n      if (s === \"top\" || s === \"bottom\") {\n        y = s === \"bottom\" ? a.bottom + offset : a.top - offset - h;\n        x = align === \"start\" ? a.left : align === \"end\" ? a.right - w : a.left + a.width / 2 - w / 2;\n        if (point) x = a.left;\n      } else {\n        x = s === \"right\" ? a.right + offset : a.left - offset - w;\n        y = align === \"start\" ? a.top : align === \"end\" ? a.bottom - h : a.top + a.height / 2 - h / 2;\n      }\n      x = Math.max(padding, Math.min(vw - padding - w, x));\n      y = Math.max(padding, Math.min(vh - padding - h, y));\n      // Grow from the point on the anchor's edge nearest the surface.\n      const ox = s === \"left\" ? \"100%\" : s === \"right\" ? \"0%\" : `${Math.max(0, Math.min(w, a.left + a.width / 2 - x))}px`;\n      const oy = s === \"top\" ? \"100%\" : s === \"bottom\" ? \"0%\" : `${Math.max(0, Math.min(h, a.top + a.height / 2 - y))}px`;\n      const next = { x: Math.round(x), y: Math.round(y), side: s, origin: `${ox} ${oy}`, ready: true };\n      setPlace((p) => (p.x === next.x && p.y === next.y && p.side === next.side && p.origin === next.origin && p.ready ? p : next));\n    };\n    const schedule = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(measure);\n    };\n    measure();\n    const ro = new ResizeObserver(schedule);\n    if (floating.current) ro.observe(floating.current);\n    window.addEventListener(\"scroll\", schedule, { capture: true, passive: true });\n    window.addEventListener(\"resize\", schedule);\n    return () => {\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      window.removeEventListener(\"scroll\", schedule, { capture: true });\n      window.removeEventListener(\"resize\", schedule);\n    };\n  }, [open, anchor, point, floating, side, align, offset, padding]);\n\n  return open ? place : { ...place, ready: false };\n}\n\n/** Mount → show next frame → hide → unmount after `exit` ms. */\nexport function usePresence(open: boolean, exit = 160) {\n  const [mounted, setMounted] = React.useState(open);\n  const [shown, setShown] = React.useState(false);\n  if (open && !mounted) setMounted(true);\n  React.useEffect(() => {\n    if (open) {\n      let inner = 0;\n      const outer = requestAnimationFrame(() => {\n        inner = requestAnimationFrame(() => setShown(true));\n      });\n      return () => {\n        cancelAnimationFrame(outer);\n        cancelAnimationFrame(inner);\n      };\n    }\n    const timer = window.setTimeout(() => {\n      setMounted(false);\n      setShown(false);\n    }, exit);\n    return () => window.clearTimeout(timer);\n  }, [open, exit]);\n  return { mounted: mounted || open, shown: open && shown };\n}\n\n/** Escape and presses outside `refs` call onDismiss while `open`. */\nexport function useDismiss(open: boolean, onDismiss: (reason: \"escape\" | \"outside\") => void, refs: React.RefObject<Element | null>[]) {\n  const latest = React.useRef({ onDismiss, refs });\n  React.useEffect(() => {\n    latest.current = { onDismiss, refs };\n  });\n  React.useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      e.stopPropagation();\n      latest.current.onDismiss(\"escape\");\n    };\n    const onDown = (e: PointerEvent) => {\n      const target = e.target as Node;\n      if (latest.current.refs.some((r) => r.current?.contains(target))) return;\n      latest.current.onDismiss(\"outside\");\n    };\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"pointerdown\", onDown, true);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"pointerdown\", onDown, true);\n    };\n  }, [open]);\n}\n\n/** The entry pose for a surface on `side`: it comes 6px from the anchor, 0.96 and blurred. */\nexport function enterStyle(shown: boolean, side: Side, reduced: boolean, ease = \"cubic-bezier(0.16,1,0.3,1)\"): React.CSSProperties {\n  const d = 6;\n  const shift = side === \"top\" ? `0 ${d}px` : side === \"bottom\" ? `0 -${d}px` : side === \"left\" ? `${d}px 0` : `-${d}px 0`;\n  return {\n    opacity: shown ? 1 : 0,\n    translate: shown || reduced ? \"0 0\" : shift,\n    scale: shown || reduced ? \"1\" : \"0.96\",\n    filter: shown || reduced ? \"blur(0px)\" : \"blur(4px)\",\n    transition: reduced\n      ? \"opacity 120ms ease-out\"\n      : shown\n        ? `opacity 220ms ${ease}, translate 320ms ${ease}, scale 320ms ${ease}, filter 220ms ${ease}`\n        : `opacity 140ms ${ease}, translate 180ms ${ease}, scale 180ms ${ease}, filter 140ms ${ease}`,\n  };\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/date-picker. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}