{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "availability-editor",
  "type": "registry:ui",
  "title": "Availability Editor",
  "description": "Weekly hours with split shifts, copy to other days and overlap checks.",
  "categories": [
    "scheduling"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/availability-editor.tsx",
      "content": "\"use client\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Select } from \"@base-ui/react/select\";\nimport { Switch } from \"@base-ui/react/switch\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, Copy, Plus, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ------------------------------------------------------------------ */\n/* The data. Times are \"HH:mm\" wall-clock strings, \"24:00\" for the end  */\n/* of the day, so the value round-trips through JSON and APIs as is.    */\n/* ------------------------------------------------------------------ */\n\nexport type DayKey = \"sun\" | \"mon\" | \"tue\" | \"wed\" | \"thu\" | \"fri\" | \"sat\";\nexport type TimeRange = { start: string; end: string };\nexport type DayHours = { enabled: boolean; ranges: TimeRange[] };\nexport type WeeklyHours = Record<DayKey, DayHours>;\n\nexport const DAY_KEYS: DayKey[] = [\"sun\", \"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\"];\nconst DAY = 24 * 60;\n\nconst toMinutes = (t: string) => {\n  const [h, m] = t.split(\":\").map(Number);\n  return h * 60 + (m || 0);\n};\nconst toTime = (min: number) => `${String(Math.floor(min / 60)).padStart(2, \"0\")}:${String(min % 60).padStart(2, \"0\")}`;\n\n/** Nine to five on weekdays, weekends off. A sensible first value. */\nexport function defaultWeeklyHours(range: TimeRange = { start: \"09:00\", end: \"17:00\" }): WeeklyHours {\n  return Object.fromEntries(DAY_KEYS.map((d) => [d, { enabled: d !== \"sun\" && d !== \"sat\", ranges: [{ ...range }] }])) as WeeklyHours;\n}\n\nexport type RangeIssue = { day: DayKey; index: number; kind: \"overlap\" | \"order\"; with?: TimeRange };\n\n/** Every problem in a week: ranges that overlap another, or end before they start. Empty means valid. */\nexport function validateWeeklyHours(value: WeeklyHours): RangeIssue[] {\n  const issues: RangeIssue[] = [];\n  for (const day of DAY_KEYS) {\n    const hours = value[day];\n    if (!hours?.enabled) continue;\n    const sorted = hours.ranges.map((r, index) => ({ r, index, s: toMinutes(r.start), e: toMinutes(r.end) })).sort((a, b) => a.s - b.s || a.e - b.e);\n    let reach: (typeof sorted)[number] | null = null;\n    for (const item of sorted) {\n      if (item.e <= item.s) issues.push({ day, index: item.index, kind: \"order\" });\n      else if (reach && item.s < reach.e) issues.push({ day, index: item.index, kind: \"overlap\", with: reach.r });\n      if (item.e > item.s && (!reach || item.e > reach.e)) reach = item;\n    }\n  }\n  return issues;\n}\n\n/** Minutes of availability in a week, counting overlapping ranges once. */\nexport function weeklyMinutes(value: WeeklyHours) {\n  let total = 0;\n  for (const day of DAY_KEYS) {\n    const hours = value[day];\n    if (!hours?.enabled) continue;\n    const spans = hours.ranges.map((r) => [toMinutes(r.start), toMinutes(r.end)]).filter(([s, e]) => e > s).sort((a, b) => a[0] - b[0]);\n    let end = -1;\n    for (const [s, e] of spans) {\n      total += Math.max(0, e - Math.max(s, end));\n      end = Math.max(end, e);\n    }\n  }\n  return total;\n}\n\n/* ------------------------------------------------------------------ */\n\nconst noop = () => () => {};\nfunction useLocale(locale?: string) {\n  const browser = useSyncExternalStore(noop, () => navigator.language, () => \"en-US\");\n  return locale ?? browser;\n}\nfunction weekStartOf(locale: string): number {\n  try {\n    const l = new Intl.Locale(locale) as Intl.Locale & { getWeekInfo?: () => { firstDay: number }; weekInfo?: { firstDay: number } };\n    const info = l.getWeekInfo?.() ?? l.weekInfo;\n    if (info) return info.firstDay % 7;\n    return [\"US\", \"CA\", \"JP\", \"BR\", \"MX\", \"IN\", \"IL\", \"KR\", \"PH\", \"AU\"].includes(l.maximize().region ?? \"\") ? 0 : 1;\n  } catch {\n    return 1;\n  }\n}\nfunction prefers12h(locale: string) {\n  try {\n    const cycle = new Intl.DateTimeFormat(locale, { hour: \"numeric\" }).resolvedOptions().hourCycle;\n    return cycle === \"h11\" || cycle === \"h12\";\n  } catch {\n    return false;\n  }\n}\nconst span = (minutes: number) => {\n  const h = Math.floor(minutes / 60);\n  const m = minutes % 60;\n  return h && m ? `${h}h ${m}m` : h ? `${h}h` : `${m}m`;\n};\n\nexport type AvailabilityEditorProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  value?: WeeklyHours;\n  defaultValue?: WeeklyHours;\n  onValueChange?: (value: WeeklyHours) => void;\n  /** Minutes between the times offered. */\n  step?: 5 | 10 | 15 | 20 | 30 | 60;\n  /** The range a day gets when it's switched on with nothing kept from before. */\n  defaultRange?: TimeRange;\n  /** BCP 47 tag for day names, the clock and the week start. Defaults to the browser's. */\n  locale?: string;\n  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n  /** 12-hour clock. Defaults to the locale's habit. */\n  hour12?: boolean;\n  /** Hide the weekly total in the header. */\n  hideTotal?: boolean;\n  disabled?: boolean;\n  /** Portal target for the time lists and the copy popover. */\n  container?: Popover.Portal.Props[\"container\"];\n};\n\ntype Flash = { days: DayKey[]; id: number };\n\nexport function AvailabilityEditor({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  step = 15,\n  defaultRange = { start: \"09:00\", end: \"17:00\" },\n  locale: localeProp,\n  weekStartsOn,\n  hour12: hour12Prop,\n  hideTotal = false,\n  disabled = false,\n  container,\n  className,\n  ...rest\n}: AvailabilityEditorProps) {\n  const reduce = !!useReducedMotion();\n  const uid = useId();\n  const locale = useLocale(localeProp);\n  const hour12 = hour12Prop ?? prefers12h(locale);\n  const [initial] = useState(() => defaultValue ?? defaultWeeklyHours(defaultRange));\n  const [value, setValue] = useControllableState<WeeklyHours>({ value: valueProp, defaultValue: initial, onChange: onValueChange });\n\n  // Stable keys for ranges, so removing the middle one animates the right row. Rebuilt by index if\n  // the value's shape changes from outside.\n  const [ids, setIds] = useState<Partial<Record<DayKey, string[]>>>({});\n  const seq = useRef(0);\n  const idsFor = (day: DayKey) => {\n    const list = ids[day];\n    return list && list.length === value[day].ranges.length ? list : value[day].ranges.map((_, i) => `${day}-${i}`);\n  };\n  // Days the previous edit touched from elsewhere (Copy), washed once so the change is seen.\n  const [flash, setFlash] = useState<Flash | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n  const rootRef = useRef<HTMLDivElement>(null);\n\n  const weekStart = weekStartsOn ?? weekStartOf(locale);\n  const days = useMemo(() => {\n    const long = new Intl.DateTimeFormat(locale, { weekday: \"long\" });\n    return Array.from({ length: 7 }, (_, i) => {\n      const index = (weekStart + i) % 7;\n      // Jan 4 2026 is a Sunday.\n      return { key: DAY_KEYS[index], name: long.format(new Date(2026, 0, 4 + index)) };\n    });\n  }, [locale, weekStart]);\n  const nameOf = (key: DayKey) => days.find((d) => d.key === key)?.name ?? key;\n\n  const timeFmt = useMemo(() => new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\", hour12, timeZone: \"UTC\" }), [locale, hour12]);\n  const label = (min: number) => (min >= DAY && !hour12 ? \"24:00\" : timeFmt.format(new Date(Date.UTC(2026, 0, 1, 0, min % DAY))));\n\n  const issues = useMemo(() => validateWeeklyHours(value), [value]);\n  const issueAt = (day: DayKey, index: number) => issues.find((i) => i.day === day && i.index === index);\n  const total = weeklyMinutes(value);\n\n  const focusLater = (selector: string) => requestAnimationFrame(() => rootRef.current?.querySelector<HTMLElement>(selector)?.focus());\n\n  const setDay = (day: DayKey, next: DayHours, nextIds?: string[]) => {\n    setValue((v) => ({ ...v, [day]: next }));\n    if (nextIds) setIds((m) => ({ ...m, [day]: nextIds }));\n  };\n\n  const toggle = (day: DayKey, on: boolean) => {\n    const hours = value[day];\n    // Switching off keeps the ranges, so switching back on restores them.\n    const ranges = on && hours.ranges.length === 0 ? [{ ...defaultRange }] : hours.ranges;\n    setDay(day, { enabled: on, ranges }, ranges === hours.ranges ? undefined : ranges.map(() => `${day}-n${++seq.current}`));\n  };\n\n  const addRange = (day: DayKey) => {\n    const hours = value[day];\n    const lastEnd = Math.max(0, ...hours.ranges.map((r) => toMinutes(r.end)));\n    if (lastEnd + step > DAY) return;\n    // An hour's break after the last range, an hour long, pulled back to fit before midnight.\n    const start = Math.min(lastEnd + (hours.ranges.length ? 60 : 0), DAY - 60 < lastEnd ? lastEnd : DAY - 60);\n    const end = Math.min(start + 60, DAY);\n    const id = `${day}-n${++seq.current}`;\n    setDay(day, { enabled: true, ranges: [...hours.ranges, { start: toTime(start), end: toTime(end) }] }, [...idsFor(day), id]);\n    focusLater(`[data-range=\"${id}\"] [data-edge=\"start\"]`);\n  };\n\n  const removeRange = (day: DayKey, index: number) => {\n    const hours = value[day];\n    const list = idsFor(day);\n    const ranges = hours.ranges.filter((_, i) => i !== index);\n    // Removing the last range turns the day off, and keeps a range ready for when it comes back on.\n    if (ranges.length === 0) {\n      setDay(day, { enabled: false, ranges: hours.ranges });\n      focusLater(`[data-day=\"${day}\"] [role=\"switch\"]`);\n      return;\n    }\n    setDay(day, { ...hours, ranges }, list.filter((_, i) => i !== index));\n    const neighbor = list[index - 1] ?? list[index + 1];\n    focusLater(`[data-range=\"${neighbor}\"] [data-edge=\"end\"]`);\n  };\n\n  const setEdge = (day: DayKey, index: number, edge: \"start\" | \"end\", time: string) => {\n    const hours = value[day];\n    const ranges = hours.ranges.map((r, i) => {\n      if (i !== index) return r;\n      if (edge === \"end\") return { ...r, end: time };\n      // Moving the start past the end carries the end along, keeping the length.\n      const s = toMinutes(time);\n      const length = Math.max(step, toMinutes(r.end) - toMinutes(r.start));\n      return { start: time, end: toMinutes(r.end) > s ? r.end : toTime(Math.min(DAY, s + length)) };\n    });\n    setDay(day, { ...hours, ranges });\n  };\n\n  const copyTo = (from: DayKey, targets: DayKey[]) => {\n    if (!targets.length) return;\n    const source = value[from];\n    setValue((v) => {\n      const next = { ...v };\n      for (const t of targets) next[t] = { enabled: source.enabled, ranges: source.ranges.map((r) => ({ ...r })) };\n      return next;\n    });\n    setIds((m) => {\n      const next = { ...m };\n      for (const t of targets) next[t] = source.ranges.map(() => `${t}-n${++seq.current}`);\n      return next;\n    });\n    setFlash({ days: targets, id: ++seq.current });\n    setAnnounce(`Copied ${nameOf(from)}’s hours to ${targets.length === 1 ? nameOf(targets[0]) : `${targets.length} days`}`);\n  };\n\n  const hours = total / 60;\n\n  return (\n    <div\n      ref={rootRef}\n      data-disabled={disabled || undefined}\n      data-invalid={issues.length > 0 || undefined}\n      aria-labelledby={`${uid}-title`}\n      role=\"group\"\n      className={cn(\n        \"@container flex w-full max-w-[520px] flex-col rounded-xl border border-line bg-raised text-fg shadow-[var(--shadow)]\",\n        disabled && \"pointer-events-none opacity-50\",\n        className,\n      )}\n      {...rest}\n    >\n      <div className=\"flex h-12 items-center justify-between gap-3 border-b border-line px-4\">\n        <h3 id={`${uid}-title`} className=\"text-[14px] font-medium tracking-[-0.015em]\">\n          Weekly hours\n        </h3>\n        {!hideTotal && (\n          <p className=\"flex items-baseline gap-1 text-[12.5px] text-fg-3\">\n            <NumberFlow value={hours} format={{ maximumFractionDigits: 2 }} locales={locale} className=\"font-medium text-fg-2 tabular\" />\n            <span>{hours === 1 ? \"hour\" : \"hours\"} a week</span>\n          </p>\n        )}\n      </div>\n\n      <ul className=\"flex flex-col py-1.5\">\n        {days.map(({ key, name }) => {\n          const day = value[key];\n          const list = idsFor(key);\n          const lastEnd = Math.max(0, ...day.ranges.map((r) => toMinutes(r.end)));\n          const full = lastEnd + step > DAY;\n          const flashIndex = flash?.days.indexOf(key) ?? -1;\n          return (\n            <li\n              key={key}\n              data-day={key}\n              data-enabled={day.enabled || undefined}\n              className=\"relative grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 px-4 py-1.5 @min-[460px]:grid-cols-[7.5rem_minmax(0,1fr)_auto]\"\n            >\n              {flashIndex >= 0 && flash && (\n                <motion.span\n                  key={flash.id}\n                  aria-hidden\n                  initial={{ opacity: 1 }}\n                  animate={{ opacity: 0 }}\n                  transition={{ duration: reduce ? 0.4 : 0.9, ease: ease.outQuart, delay: reduce ? 0 : flashIndex * 0.04 }}\n                  className=\"pointer-events-none absolute inset-x-1.5 inset-y-0.5 rounded-lg bg-fg/[0.06]\"\n                />\n              )}\n              <label className=\"relative col-start-1 row-start-1 flex h-8 w-fit min-w-0 cursor-default items-center gap-2.5 select-none\">\n                <DaySwitch checked={day.enabled} onCheckedChange={(on) => toggle(key, on)} disabled={disabled} />\n                <span className={cn(\"text-[13px] font-medium transition-colors duration-150\", day.enabled ? \"text-fg\" : \"text-fg-3\")}>{name}</span>\n              </label>\n\n              <div className=\"col-span-2 col-start-1 row-start-2 min-w-0 pl-[42px] @min-[460px]:col-span-1 @min-[460px]:col-start-2 @min-[460px]:row-start-1 @min-[460px]:pl-0\">\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  {day.enabled ? (\n                    <motion.ul\n                      key=\"ranges\"\n                      aria-label={`${name} hours`}\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, x: -4, filter: \"blur(2px)\" }}\n                      animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n                      exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                      transition={{ duration: 0.2, ease: ease.out }}\n                      className=\"flex flex-col\"\n                    >\n                      <AnimatePresence initial={false}>\n                        {day.ranges.map((range, i) => {\n                          const issue = issueAt(key, i);\n                          return (\n                            <RangeRow\n                              key={list[i]}\n                              id={list[i]}\n                              first={i === 0}\n                              range={range}\n                              step={step}\n                              hour12={hour12}\n                              label={label}\n                              issue={issue ? (issue.kind === \"order\" ? \"Ends before it starts\" : `Overlaps ${label(toMinutes(issue.with!.start))} – ${label(toMinutes(issue.with!.end))}`) : null}\n                              dayName={name}\n                              reduce={reduce}\n                              container={container}\n                              onEdge={(edge, t) => setEdge(key, i, edge, t)}\n                              onRemove={() => removeRange(key, i)}\n                            />\n                          );\n                        })}\n                      </AnimatePresence>\n                    </motion.ul>\n                  ) : (\n                    <motion.p\n                      key=\"off\"\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, x: -4 }}\n                      animate={{ opacity: 1, x: 0 }}\n                      exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                      transition={{ duration: 0.2, ease: ease.out }}\n                      // On a phone the switch already says it; the row stays one line tall.\n                      className=\"hidden h-8 items-center text-[13px] text-fg-4 @min-[460px]:flex\"\n                    >\n                      Unavailable\n                    </motion.p>\n                  )}\n                </AnimatePresence>\n              </div>\n\n              <div className=\"col-start-2 row-start-1 flex h-8 items-center gap-0.5 @min-[460px]:col-start-3\">\n                <IconButton label={full ? `No time left on ${name}` : `Add hours to ${name}`} onClick={() => addRange(key)} disabled={disabled || full}>\n                  <Plus className=\"transition-transform duration-200 ease-out-expo group-active/icon:rotate-90\" />\n                </IconButton>\n                <CopyHours\n                  from={key}\n                  fromName={name}\n                  days={days.filter((d) => d.key !== key)}\n                  disabled={disabled}\n                  container={container}\n                  onApply={(targets) => copyTo(key, targets)}\n                />\n              </div>\n            </li>\n          );\n        })}\n      </ul>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n\nfunction RangeRow({\n  id,\n  first,\n  range,\n  step,\n  hour12,\n  label,\n  issue,\n  dayName,\n  reduce,\n  container,\n  onEdge,\n  onRemove,\n}: {\n  id: string;\n  first: boolean;\n  range: TimeRange;\n  step: number;\n  hour12: boolean;\n  label: (min: number) => string;\n  issue: string | null;\n  dayName: string;\n  reduce: boolean;\n  container?: Popover.Portal.Props[\"container\"];\n  onEdge: (edge: \"start\" | \"end\", time: string) => void;\n  onRemove: () => void;\n}) {\n  const errorId = useId();\n  const s = toMinutes(range.start);\n  const starts = useMemo(() => Array.from({ length: DAY / step }, (_, i) => i * step), [step]);\n  const ends = useMemo(() => Array.from({ length: DAY / step }, (_, i) => (i + 1) * step).filter((m) => m > s), [step, s]);\n  const invalid = !!issue;\n\n  return (\n    <motion.li\n      data-range={id}\n      // Grows open from nothing and closes back down, so the rows below slide rather than jump.\n      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -4, filter: \"blur(2px)\" }}\n      animate={{ opacity: 1, height: \"auto\", y: 0, filter: \"blur(0px)\" }}\n      exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, height: 0, filter: \"blur(2px)\", transition: { duration: 0.18, ease: ease.inOut } }}\n      transition={{ duration: 0.24, ease: ease.out }}\n      // The negative margin gives focus rings room inside the clip that the height animation needs.\n      className=\"-mx-1 -mb-1 flex flex-col overflow-hidden px-1 pb-1\"\n    >\n      <div className={cn(\"flex items-center gap-1.5\", !first && \"pt-1.5\")}>\n        <TimeSelect\n          edge=\"start\"\n          value={s}\n          options={starts}\n          label={label}\n          hour12={hour12}\n          invalid={invalid}\n          describedBy={invalid ? errorId : undefined}\n          name={`${dayName}, start`}\n          container={container}\n          onChange={(m) => onEdge(\"start\", toTime(m))}\n        />\n        <span aria-hidden className=\"w-2 text-center text-[13px] text-fg-4\">\n          –\n        </span>\n        <TimeSelect\n          edge=\"end\"\n          value={toMinutes(range.end)}\n          options={ends}\n          label={label}\n          hour12={hour12}\n          invalid={invalid}\n          describedBy={invalid ? errorId : undefined}\n          name={`${dayName}, end`}\n          hint={(m) => span(m - s)}\n          container={container}\n          onChange={(m) => onEdge(\"end\", toTime(m))}\n        />\n        <IconButton label={`Remove ${label(s)} – ${label(toMinutes(range.end))} on ${dayName}`} onClick={onRemove} quiet>\n          <X size={14} />\n        </IconButton>\n      </div>\n      <AnimatePresence initial={false}>\n        {issue && (\n          <motion.p\n            id={errorId}\n            initial={{ opacity: 0, height: 0 }}\n            animate={{ opacity: 1, height: \"auto\" }}\n            exit={{ opacity: 0, height: 0, transition: { duration: 0.12 } }}\n            transition={{ duration: 0.2, ease: ease.out }}\n            className=\"overflow-hidden text-[12px] leading-4 text-danger\"\n          >\n            <span className=\"block pt-1\">{issue}</span>\n          </motion.p>\n        )}\n      </AnimatePresence>\n    </motion.li>\n  );\n}\n\n/** A time from a list at `step` minutes. A short list under the trigger, scrolled to the current time, rather than a column the height of the screen. */\nfunction TimeSelect({\n  edge,\n  value,\n  options,\n  label,\n  hour12,\n  invalid,\n  describedBy,\n  name,\n  hint,\n  container,\n  onChange,\n}: {\n  edge: \"start\" | \"end\";\n  value: number;\n  options: number[];\n  label: (min: number) => string;\n  hour12: boolean;\n  invalid: boolean;\n  describedBy?: string;\n  name: string;\n  hint?: (min: number) => string;\n  container?: Popover.Portal.Props[\"container\"];\n  onChange: (min: number) => void;\n}) {\n  const reduce = !!useReducedMotion();\n  const items = useMemo(() => options.map((m) => ({ value: m, label: label(m) })), [options, label]);\n  const { list, setList, y, height, opacity } = useGlide(reduce);\n  // Open with the current time in the middle of the list, so earlier and later are both a short\n  // scroll away. Two frames, so it lands after the list's own scroll-into-view.\n  useEffect(() => {\n    if (!list) return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => {\n      inner = requestAnimationFrame(() => {\n        const el = list.querySelector<HTMLElement>(\"[data-selected]\");\n        if (el) list.scrollTop = el.offsetTop - list.clientHeight / 2 + el.offsetHeight / 2;\n      });\n    });\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [list]);\n  return (\n    <Select.Root<number>\n      items={items}\n      value={value}\n      onValueChange={(v) => v !== null && onChange(v)}\n    >\n      <Select.Trigger\n        data-edge={edge}\n        aria-label={name}\n        aria-invalid={invalid || undefined}\n        aria-describedby={describedBy}\n        className={cn(\n          \"group/trigger relative flex h-8 items-center rounded-lg border border-line-2 bg-raised px-2.5 text-left text-[13px] tabular text-fg shadow-[var(--shadow)] select-none\",\n          hour12 ? \"w-[6rem]\" : \"w-[4.75rem]\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97] active:duration-75\",\n          \"data-popup-open:border-fg-4 data-popup-open:bg-hover\",\n          \"aria-invalid:border-danger/70 aria-invalid:hover:border-danger\",\n          \"pointer-coarse:after:absolute pointer-coarse:after:-inset-y-1.5 pointer-coarse:after:inset-x-0\",\n        )}\n      >\n        <Select.Value className=\"truncate\">{(v: number) => label(v)}</Select.Value>\n      </Select.Trigger>\n      <Select.Portal container={container}>\n        <Select.Positioner alignItemWithTrigger={false} side=\"bottom\" align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none select-none\">\n          <Select.Popup\n            className={cn(\n              \"relative min-w-(--anchor-width) origin-(--transform-origin) overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n              \"data-[side=none]:data-starting-style:scale-100 data-[side=none]:data-ending-style:scale-100\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <Select.List ref={setList} className=\"relative max-h-[min(var(--available-height),16rem)] scroll-py-1 overflow-y-auto overscroll-contain p-1 outline-none\">\n              <motion.div aria-hidden style={{ y, height, opacity }} className=\"pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-line\" />\n              {items.map((item) => (\n                <Select.Item\n                  key={item.value}\n                  value={item.value}\n                  className=\"group/item relative z-[1] flex h-8 cursor-default items-center gap-3 rounded-lg px-2.5 text-[13px] tabular text-fg-2 outline-none select-none data-highlighted:text-fg data-selected:text-fg pointer-coarse:h-10\"\n                >\n                  <Select.ItemText className=\"flex-1 whitespace-nowrap\">{item.label}</Select.ItemText>\n                  {hint && <span className=\"font-mono text-2xs text-fg-4\">{hint(item.value)}</span>}\n                  <Select.ItemIndicator className=\"-mr-1 flex text-fg\">\n                    <Check size={14} />\n                  </Select.ItemIndicator>\n                </Select.Item>\n              ))}\n            </Select.List>\n          </Select.Popup>\n        </Select.Positioner>\n      </Select.Portal>\n    </Select.Root>\n  );\n}\n\n/** Copy one day's hours onto others. Every other day starts ticked, so \"to all days\" is one press. */\nfunction CopyHours({\n  from,\n  fromName,\n  days,\n  disabled,\n  container,\n  onApply,\n}: {\n  from: DayKey;\n  fromName: string;\n  days: { key: DayKey; name: string }[];\n  disabled: boolean;\n  container?: Popover.Portal.Props[\"container\"];\n  onApply: (targets: DayKey[]) => void;\n}) {\n  const reduce = !!useReducedMotion();\n  const [open, setOpen] = useState(false);\n  const [picked, setPicked] = useState<DayKey[]>([]);\n  const [done, setDone] = useState<number | null>(null);\n  const all = picked.length === days.length;\n  const some = picked.length > 0 && !all;\n\n  // The icon holds a tick for a moment after copying.\n  useEffect(() => {\n    if (done === null) return;\n    const t = window.setTimeout(() => setDone(null), 1400);\n    return () => window.clearTimeout(t);\n  }, [done]);\n\n  const toggleDay = (key: DayKey, on: boolean) => setPicked((p) => (on ? [...p, key] : p.filter((k) => k !== key)));\n\n  return (\n    <Popover.Root\n      open={open}\n      onOpenChange={(next) => {\n        if (next) setPicked(days.map((d) => d.key));\n        setOpen(next);\n      }}\n    >\n      <Popover.Trigger\n        disabled={disabled}\n        aria-label={`Copy ${fromName}’s hours to other days`}\n        data-from={from}\n        className={cn(iconButton, \"data-popup-open:bg-hover data-popup-open:text-fg\")}\n      >\n        <span className=\"relative grid size-4 place-items-center\">\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={done !== null ? `done-${done}` : \"copy\"}\n              className=\"absolute inset-0 grid place-items-center\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n              transition={reduce ? { duration: 0.12 } : spring.pop}\n            >\n              {done !== null ? <Check size={15} /> : <Copy size={15} />}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </Popover.Trigger>\n      <Popover.Portal container={container}>\n        <Popover.Positioner side=\"bottom\" align=\"end\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            className={cn(\n              \"w-56 origin-(--transform-origin) rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <Popover.Title className=\"px-2.5 pt-2 pb-1.5 font-mono text-2xs tracking-[0.08em] text-fg-3 uppercase\">Copy {fromName} to</Popover.Title>\n            <div className=\"flex flex-col\">\n              <CheckRow\n                label=\"All days\"\n                checked={all}\n                indeterminate={some}\n                onChange={(on) => setPicked(on ? days.map((d) => d.key) : [])}\n                strong\n              />\n              <div aria-hidden className=\"mx-2.5 my-1 h-px bg-line\" />\n              {days.map((d) => (\n                <CheckRow key={d.key} label={d.name} checked={picked.includes(d.key)} onChange={(on) => toggleDay(d.key, on)} />\n              ))}\n            </div>\n            <div className=\"p-1 pt-2\">\n              <button\n                type=\"button\"\n                disabled={!picked.length}\n                onClick={() => {\n                  onApply(days.filter((d) => picked.includes(d.key)).map((d) => d.key));\n                  setDone((d) => (d ?? 0) + 1);\n                  setOpen(false);\n                }}\n                className={cn(\n                  \"grid h-8 w-full place-items-center rounded-lg bg-fg text-[12.5px] font-medium text-frame\",\n                  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                  \"transition-[background-color,opacity,scale] duration-150 hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n                  \"disabled:pointer-events-none disabled:opacity-40\",\n                )}\n              >\n                <span className=\"tabular\">{picked.length ? `Copy to ${picked.length === 1 ? \"1 day\" : `${picked.length} days`}` : \"Pick a day\"}</span>\n              </button>\n            </div>\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n\nfunction CheckRow({ label, checked, indeterminate, strong, onChange }: { label: string; checked: boolean; indeterminate?: boolean; strong?: boolean; onChange: (on: boolean) => void }) {\n  return (\n    <label className=\"group/row flex h-8 cursor-default items-center gap-2.5 rounded-lg px-2.5 text-[13px] select-none hover:bg-hover pointer-coarse:h-10\">\n      <Checkbox.Root\n        checked={checked}\n        indeterminate={indeterminate}\n        onCheckedChange={onChange}\n        className={cn(\n          \"grid size-4 shrink-0 place-items-center rounded-[5px] border border-line-2 bg-raised text-frame\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,border-color,scale] duration-150 group-active/row:scale-[0.9] group-hover/row:border-fg-4\",\n          \"data-checked:border-fg data-checked:bg-fg data-indeterminate:border-fg data-indeterminate:bg-fg\",\n        )}\n      >\n        <Checkbox.Indicator keepMounted className=\"grid place-items-center transition-[opacity,scale] duration-150 ease-out-expo data-unchecked:scale-50 data-unchecked:opacity-0\">\n          {indeterminate ? (\n            <svg width={10} height={10} viewBox=\"0 0 10 10\" aria-hidden>\n              <path d=\"M2.5 5h5\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" />\n            </svg>\n          ) : (\n            <Check size={12} strokeWidth={1.8} />\n          )}\n        </Checkbox.Indicator>\n      </Checkbox.Root>\n      <span className={cn(strong ? \"font-medium text-fg\" : \"text-fg-2 group-hover/row:text-fg\")}>{label}</span>\n    </label>\n  );\n}\n\n/** The day switch. The thumb stretches toward where it will go while pressed. */\nfunction DaySwitch({ checked, onCheckedChange, disabled }: { checked: boolean; onCheckedChange: (on: boolean) => void; disabled: boolean }) {\n  return (\n    <Switch.Root\n      checked={checked}\n      onCheckedChange={onCheckedChange}\n      disabled={disabled}\n      className={cn(\n        \"group/switch relative inline-flex h-[18px] w-8 shrink-0 items-center rounded-full p-0.5\",\n        \"bg-fg/15 data-checked:bg-fg\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color] duration-200 ease-out\",\n        \"before:absolute before:-inset-x-1.5 before:-inset-y-3 before:content-[''] pointer-fine:before:hidden\",\n      )}\n    >\n      <Switch.Thumb\n        className={cn(\n          \"block h-3.5 w-3.5 rounded-full bg-fg-2 data-checked:bg-frame\",\n          \"transition-[translate,width,background-color] duration-200 ease-out-expo\",\n          \"data-checked:translate-x-3.5 group-active/switch:w-[17px] group-active/switch:data-checked:translate-x-[11px]\",\n          \"motion-reduce:transition-none\",\n        )}\n      />\n    </Switch.Root>\n  );\n}\n\nconst iconButton = cn(\n  \"group/icon relative grid size-7 place-items-center rounded-md text-fg-3 outline-none\",\n  \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n  \"disabled:pointer-events-none disabled:opacity-35\",\n  \"after:absolute after:-inset-2 after:content-[''] pointer-fine:after:hidden\",\n);\n\nfunction IconButton({ label, onClick, disabled, quiet, children }: { label: string; onClick: () => void; disabled?: boolean; quiet?: boolean; children: React.ReactNode }) {\n  return (\n    <button type=\"button\" aria-label={label} title={label} onClick={onClick} disabled={disabled} className={cn(iconButton, quiet && \"text-fg-4 hover:text-fg-2\")}>\n      {children}\n    </button>\n  );\n}\n\n// One highlight for the time list: glides after the pointer, jumps for arrow keys.\nfunction useGlide(reduce: boolean) {\n  const [list, setList] = useState<HTMLDivElement | null>(null);\n  const y = useMotionValue(0);\n  const height = useMotionValue(32);\n  const opacity = useMotionValue(0);\n\n  useEffect(() => {\n    if (!list) return;\n    let keyboard = false;\n    let shown = false;\n    let running: AnimationPlaybackControls[] = [];\n    const stop = () => {\n      running.forEach((c) => c.stop());\n      running = [];\n    };\n    const place = () => {\n      const el = list.querySelector<HTMLElement>(\"[data-highlighted]\");\n      stop();\n      if (!el) {\n        shown = false;\n        running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.12 }));\n        return;\n      }\n      const top = el.offsetTop;\n      if (!shown || keyboard || reduce) {\n        y.jump(top);\n        height.jump(el.offsetHeight);\n        opacity.jump(1);\n      } else {\n        running.push(animate(y, top, spring.follow), animate(height, el.offsetHeight, spring.follow));\n        opacity.jump(1);\n      }\n      shown = true;\n    };\n    const onKey = () => (keyboard = true);\n    const onPointer = () => (keyboard = false);\n    const observer = new MutationObserver(place);\n    observer.observe(list, { subtree: true, attributes: true, attributeFilter: [\"data-highlighted\"] });\n    document.addEventListener(\"keydown\", onKey, true);\n    list.addEventListener(\"pointermove\", onPointer);\n    place();\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"keydown\", onKey, true);\n      list.removeEventListener(\"pointermove\", onPointer);\n      stop();\n    };\n  }, [list, reduce, y, height, opacity]);\n\n  return { list, setList, y, height, opacity };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/availability-editor"
}
