{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "time-picker",
  "title": "Time Picker",
  "type": "registry:ui",
  "description": "Standalone time input — hours, minutes, optional seconds, and 12h/24h modes. Pairs with Date Picker for full datetime entry.",
  "categories": [
    "date-time"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/time-picker/time-picker.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { Clock, X } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\nimport { autoUpdate, computePosition, type Align, type Side } from '../../lib/position'\nimport { buttonVariants } from '../button/button.variants'\n\nexport type TimeFormat = 'HH:mm' | 'HH:mm:ss' | 'hh:mm A'\n\nexport interface TimeParts {\n  hour24: number\n  minute: number\n  second: number\n}\n\nexport interface TimePreset {\n  label: string\n  value: string\n}\n\nexport interface TimeRangePreset {\n  label: string\n  value: [string, string]\n}\n\nexport type TimePickerSize = 'small' | 'middle' | 'large'\nexport type TimePickerStatus = 'error' | 'warning'\n\n// Boolean props whose React default is `true`: `attr=\"false\"` turns them off.\nconst trueByDefault = { fromAttribute: (v: string | null) => v !== 'false', toAttribute: () => null }\n// Optional booleans: absent → undefined (so `allowClear` can fall back to `clearable`, like React).\nconst optBoolean = {\n  fromAttribute: (v: string | null) => (v === null ? undefined : v !== 'false'),\n  toAttribute: () => null,\n}\n\nfunction parse(v: string): TimeParts | null {\n  if (!v) return null\n  const m = /^(\\d{1,2}):(\\d{2})(?::(\\d{2}))?$/.exec(v.trim())\n  if (!m) return null\n  const h = Number(m[1])\n  const min = Number(m[2])\n  const s = m[3] ? Number(m[3]) : 0\n  if (\n    Number.isNaN(h) ||\n    Number.isNaN(min) ||\n    Number.isNaN(s) ||\n    h < 0 ||\n    h > 23 ||\n    min < 0 ||\n    min > 59 ||\n    s < 0 ||\n    s > 59\n  )\n    return null\n  return { hour24: h, minute: min, second: s }\n}\n\nfunction toMinutes(p: TimeParts) {\n  return p.hour24 * 60 + p.minute + p.second / 60\n}\n\nfunction format24(p: TimeParts, format: TimeFormat) {\n  if (format === 'HH:mm:ss') {\n    return `${String(p.hour24).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}:${String(p.second).padStart(2, '0')}`\n  }\n  return `${String(p.hour24).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}`\n}\n\nfunction formatDisplay(h: number, m: number, s: number, format: TimeFormat) {\n  if (format === 'hh:mm A') {\n    const h12 = h % 12 === 0 ? 12 : h % 12\n    const p = h >= 12 ? 'PM' : 'AM'\n    return `${String(h12).padStart(2, '0')}:${String(m).padStart(2, '0')} ${p}`\n  }\n  if (format === 'HH:mm:ss') {\n    return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`\n  }\n  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`\n}\n\nconst sizeClasses: Record<TimePickerSize, string> = {\n  small: 'h-8 text-xs px-2.5 py-1',\n  middle: 'h-9 text-sm px-3 py-1.5',\n  large: 'h-11 text-base px-4 py-2',\n}\n\nconst statusClasses: Record<string, string> = {\n  error: 'border-destructive focus-visible:ring-destructive',\n  warning: 'border-warning focus-visible:ring-warning',\n}\n\n// React wraps each column in a ScrollArea (h-56); the Lit columns scroll\n// natively with the same height and a thin overlay-style scrollbar.\nconst columnScrollClasses =\n  'h-56 overflow-y-auto [scrollbar-width:thin] [scrollbar-color:var(--border)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border'\n\nconst optionClasses =\n  'hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 text-center text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent'\nconst optionActiveClasses = 'bg-primary text-primary-foreground hover:bg-primary'\nconst periodClasses =\n  'hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 text-center text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none'\n\n// React's PopoverContent classes with the picker's `w-auto p-0` overrides\n// merged in (what cn() produces), plus the native-popover positioning set.\nconst panelClasses = cn(\n  'bg-popover text-popover-foreground motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:ease-emphasized motion-safe:data-[state=open]:blur-in-2 motion-safe:data-[state=closed]:blur-out-2 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 z-50 w-auto origin-(--radix-popover-content-transform-origin) rounded-md border p-0 shadow-md outline-hidden motion-safe:data-[state=closed]:duration-[var(--dur-exit)] motion-safe:data-[state=open]:duration-200',\n  'fixed inset-auto m-0 text-start',\n)\n\n// Radix's --radix-popover-content-transform-origin, from the placed side/align.\nconst alignPct: Record<Align, string> = { start: '0%', center: '50%', end: '100%' }\nfunction transformOrigin(side: Side, align: Align) {\n  const a = alignPct[align]\n  return { top: `${a} 100%`, bottom: `${a} 0%`, left: `100% ${a}`, right: `0% ${a}` }[side]\n}\n\n/**\n * <uip-time-columns> — the registry TimeColumns (the hour/minute/second/\n * AM-PM option grid) as a web component. Also nested inside the pickers.\n *\n * Option classes are React's verbatim. `value` is 24h `HH:mm` or `HH:mm:ss`\n * (empty = no selection). 12-hour columns opt in via `format=\"hh:mm A\"` or\n * `use-12-hours`. `disabled-hours` / `disabled-minutes` / `disabled-seconds`\n * are function properties (no attribute form). React's `visible` scrolls the\n * active rows into view when true.\n *\n * Events (bubble, composed): `value-change` (detail: { value }), `input` +\n * `change` (value on `.value`). Parts: `base`, `column`, `option`.\n */\nexport class UipTimeColumns extends LitElement {\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    value: {},\n    use24Hour: { type: Boolean, attribute: 'use-24-hour' },\n    use12Hours: { type: Boolean, attribute: 'use-12-hours' },\n    minuteStep: { type: Number, attribute: 'minute-step' },\n    hourStep: { type: Number, attribute: 'hour-step' },\n    secondStep: { type: Number, attribute: 'second-step' },\n    minTime: { attribute: 'min-time' },\n    maxTime: { attribute: 'max-time' },\n    format: {},\n    disabledHours: { attribute: false },\n    disabledMinutes: { attribute: false },\n    disabledSeconds: { attribute: false },\n    hideDisabledOptions: { type: Boolean, attribute: 'hide-disabled-options' },\n    visible: { type: Boolean, converter: trueByDefault },\n  }\n\n  value = ''\n  use24Hour = false\n  use12Hours = false\n  minuteStep = 5\n  hourStep = 1\n  secondStep = 1\n  minTime?: string\n  maxTime?: string\n  format: TimeFormat = 'HH:mm'\n  disabledHours?: () => number[]\n  disabledMinutes?: (selectedHour: number) => number[]\n  disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[]\n  hideDisabledOptions = false\n  visible = true\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'time-columns')\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    if (isServer) return\n    if ((changed.has('visible') || changed.has('value')) && this.visible) {\n      // Auto-scroll active rows into view (React's visible effect).\n      requestAnimationFrame(() => {\n        this.renderRoot.querySelectorAll('[data-column]').forEach((col) => {\n          col.querySelector('[data-active=\"true\"]')?.scrollIntoView({ block: 'center' })\n        })\n      })\n    }\n  }\n\n  private emit(v: string) {\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: v }, bubbles: true, composed: true }))\n  }\n\n  render() {\n    const parts = parse(this.value)\n    const showSeconds = this.format === 'HH:mm:ss'\n    // Default to 24h columns. Opt into 12h via format=\"hh:mm A\" or use12Hours.\n    const effective12Hour = this.format === 'hh:mm A' ? true : this.use12Hours ? true : this.use24Hour ? false : false\n    const hour12 = parts ? (parts.hour24 % 12 === 0 ? 12 : parts.hour24 % 12) : null\n    const period = parts && parts.hour24 >= 12 ? 'PM' : 'AM'\n    const minBound = parse(this.minTime ?? '') ?? null\n    const maxBound = parse(this.maxTime ?? '') ?? null\n    const withinBounds = (p: TimeParts) => {\n      const m = toMinutes(p)\n      if (minBound && m < toMinutes(minBound)) return false\n      if (maxBound && m > toMinutes(maxBound)) return false\n      return true\n    }\n    const disabledHoursSet = new Set(this.disabledHours ? this.disabledHours() : [])\n    const disabledMinutesSet = new Set(this.disabledMinutes ? this.disabledMinutes(parts?.hour24 ?? 0) : [])\n    const disabledSecondsSet = new Set(\n      this.disabledSeconds ? this.disabledSeconds(parts?.hour24 ?? 0, parts?.minute ?? 0) : [],\n    )\n    const cur = () => parts ?? { hour24: 0, minute: 0, second: 0 }\n    const isHourDisabledItem = (h: number) => {\n      if (disabledHoursSet.has(h)) return true\n      const c = cur()\n      if (effective12Hour) {\n        const isPM = period === 'PM'\n        return !withinBounds({ hour24: (h % 12) + (isPM ? 12 : 0), minute: c.minute, second: c.second })\n      }\n      return !withinBounds({ hour24: h, minute: c.minute, second: c.second })\n    }\n    const isMinuteDisabledItem = (m: number) => {\n      if (disabledMinutesSet.has(m)) return true\n      const c = cur()\n      return !withinBounds({ hour24: c.hour24, minute: m, second: c.second })\n    }\n    const isSecondDisabledItem = (s: number) => {\n      if (disabledSecondsSet.has(s)) return true\n      const c = cur()\n      return !withinBounds({ hour24: c.hour24, minute: c.minute, second: s })\n    }\n    const hourStepN = Math.max(1, this.hourStep)\n    const hours12List = Array.from({ length: 12 }, (_, i) => i + 1)\n      .filter((h) => (h - 1) % hourStepN === 0)\n      .filter((h) => !this.hideDisabledOptions || !isHourDisabledItem(h))\n    const hours24List = Array.from({ length: 24 }, (_, i) => i)\n      .filter((h) => h % hourStepN === 0)\n      .filter((h) => !this.hideDisabledOptions || !isHourDisabledItem(h))\n    const minStepN = Math.max(1, Math.min(60, this.minuteStep))\n    const minutesList = Array.from({ length: Math.ceil(60 / minStepN) }, (_, i) => i * minStepN).filter(\n      (m) => !this.hideDisabledOptions || !isMinuteDisabledItem(m),\n    )\n    const secStepN = Math.max(1, Math.min(60, this.secondStep))\n    const secondsList = Array.from({ length: Math.ceil(60 / secStepN) }, (_, i) => i * secStepN).filter(\n      (s) => !this.hideDisabledOptions || !isSecondDisabledItem(s),\n    )\n    const commit = (p: TimeParts) => {\n      if (!withinBounds(p)) return\n      this.emit(format24(p, this.format))\n    }\n    const pickHour12 = (h: number) => {\n      const c = cur()\n      const isPM = period === 'PM'\n      commit({ hour24: (h % 12) + (isPM ? 12 : 0), minute: c.minute, second: c.second })\n    }\n    const pickHour24 = (h: number) => {\n      const c = cur()\n      commit({ hour24: h, minute: c.minute, second: c.second })\n    }\n    const pickMinute = (m: number) => {\n      const c = cur()\n      commit({ hour24: c.hour24, minute: m, second: c.second })\n    }\n    const pickSecond = (s: number) => {\n      const c = cur()\n      commit({ hour24: c.hour24, minute: c.minute, second: s })\n    }\n    const pickPeriod = (p: 'AM' | 'PM') => {\n      const c = cur()\n      const h12 = c.hour24 % 12\n      commit({ hour24: h12 + (p === 'PM' ? 12 : 0), minute: c.minute, second: c.second })\n    }\n    const isHourActive = (h: number) => {\n      if (!parts) return false\n      return effective12Hour ? hour12 === h : parts.hour24 === h\n    }\n    const isHourDisabled = (h: number) => (!this.hideDisabledOptions ? isHourDisabledItem(h) : false)\n    const isMinuteDisabled = (m: number) => (!this.hideDisabledOptions ? isMinuteDisabledItem(m) : false)\n    const isSecondDisabled = (s: number) => (!this.hideDisabledOptions ? isSecondDisabledItem(s) : false)\n\n    const hours = effective12Hour ? hours12List : hours24List\n    return html`<div part=\"base\" class=\"flex divide-x\" data-uipkge=\"\" data-slot=\"time-columns\">\n      <div part=\"column\" data-column=\"hour\" class=${columnScrollClasses}>\n        <div class=\"flex w-14 flex-col p-1\">\n          ${hours.map(\n            (h) => html`<button\n              part=\"option\"\n              type=\"button\"\n              data-active=${isHourActive(h)}\n              ?disabled=${isHourDisabled(h)}\n              class=${cn(optionClasses, isHourActive(h) ? optionActiveClasses : '')}\n              @click=${() => (effective12Hour ? pickHour12(h) : pickHour24(h))}\n            >\n              ${String(h).padStart(2, '0')}\n            </button>`,\n          )}\n        </div>\n      </div>\n\n      <div part=\"column\" data-column=\"minute\" class=${columnScrollClasses}>\n        <div class=\"flex w-14 flex-col p-1\">\n          ${minutesList.map(\n            (m) => html`<button\n              part=\"option\"\n              type=\"button\"\n              data-active=${parts?.minute === m}\n              ?disabled=${isMinuteDisabled(m)}\n              class=${cn(optionClasses, parts?.minute === m ? optionActiveClasses : '')}\n              @click=${() => pickMinute(m)}\n            >\n              ${String(m).padStart(2, '0')}\n            </button>`,\n          )}\n        </div>\n      </div>\n\n      ${showSeconds\n        ? html`<div part=\"column\" data-column=\"second\" class=${columnScrollClasses}>\n            <div class=\"flex w-14 flex-col p-1\">\n              ${secondsList.map(\n                (s) => html`<button\n                  part=\"option\"\n                  type=\"button\"\n                  data-active=${parts?.second === s}\n                  ?disabled=${isSecondDisabled(s)}\n                  class=${cn(optionClasses, parts?.second === s ? optionActiveClasses : '')}\n                  @click=${() => pickSecond(s)}\n                >\n                  ${String(s).padStart(2, '0')}\n                </button>`,\n              )}\n            </div>\n          </div>`\n        : nothing}\n      ${effective12Hour\n        ? html`<div part=\"column\" data-column=\"period\" class=\"flex w-12 flex-col p-1\">\n            ${(['AM', 'PM'] as const).map(\n              (p) => html`<button\n                part=\"option\"\n                type=\"button\"\n                class=${cn(periodClasses, period === p ? optionActiveClasses : '')}\n                @click=${() => pickPeriod(p)}\n              >\n                ${p}\n              </button>`,\n            )}\n          </div>`\n        : nothing}\n    </div>`\n  }\n}\n\n/** Shared panel behaviour for the pickers: open state, native-popover panel, positioning, dismissal. */\nclass TimePickerPanel extends LitElement {\n  static properties = {\n    open: { type: Boolean, reflect: true },\n    state: { state: true },\n    placedSide: { state: true },\n    pos: { state: true },\n  }\n\n  open = false\n  protected state: 'open' | 'closed' = 'closed'\n  protected placedSide: Side = 'bottom'\n  protected pos: Record<string, string> = {}\n  private stopAutoUpdate?: () => void\n  private returnFocus = true\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.teardown()\n  }\n\n  protected get trigger(): HTMLElement | undefined {\n    return this.renderRoot.querySelector<HTMLElement>('[part=trigger]') ?? undefined\n  }\n\n  protected get panel(): HTMLElement | undefined {\n    return this.renderRoot.querySelector<HTMLElement>('[part=content]') ?? undefined\n  }\n\n  show() {\n    this.setOpen(true)\n  }\n\n  hide() {\n    this.setOpen(false)\n  }\n\n  toggle() {\n    this.setOpen(!this.open)\n  }\n\n  protected setOpen(open: boolean) {\n    if (this.open === open) return\n    this.open = open\n    this.dispatchEvent(new CustomEvent('open-change', { detail: { open }, bubbles: true, composed: true }))\n  }\n\n  private onDocPointerDown = (e: PointerEvent) => {\n    if (e.composedPath().includes(this)) return\n    this.returnFocus = false\n    this.setOpen(false)\n  }\n\n  private onDocKeyDown = (e: KeyboardEvent) => {\n    if (e.key !== 'Escape' || !this.open) return\n    e.stopPropagation()\n    this.setOpen(false)\n  }\n\n  private onFocusOut = (e: FocusEvent) => {\n    const to = e.relatedTarget as Node | null\n    if (!this.open || !to) return\n    if (this.contains(to) || this.renderRoot.contains(to)) return\n    this.returnFocus = false\n    this.setOpen(false)\n  }\n\n  protected syncPanel(changed: Map<string, unknown>) {\n    if (isServer) return\n    const trigger = this.trigger\n    if (trigger) {\n      trigger.setAttribute('aria-haspopup', 'dialog')\n      trigger.setAttribute('aria-expanded', String(this.open))\n      trigger.setAttribute('data-state', this.state)\n    }\n    if (!changed.has('open')) return\n    const panel = this.panel\n    if (!panel) return\n    if (this.open) {\n      this.returnFocus = true\n      if (!panel.matches(':popover-open')) panel.showPopover()\n      addEventListener('pointerdown', this.onDocPointerDown, true)\n      addEventListener('keydown', this.onDocKeyDown, true)\n      this.addEventListener('focusout', this.onFocusOut)\n      this.updateComplete.then(() => {\n        if (!this.open) return\n        if (trigger && !this.stopAutoUpdate) {\n          this.stopAutoUpdate = autoUpdate(trigger, panel, () => this.place())\n          Promise.all(panel.getAnimations().map((a) => a.finished)).then(() => this.open && this.place(), () => {})\n        }\n        // Focus the first selected option so keyboard users land in the grid.\n        const active = panel.querySelector<HTMLElement>('[data-active=\"true\"]')\n        ;(active ?? panel).focus()\n      })\n    } else if (changed.get('open') === true) {\n      this.teardown()\n      if (this.returnFocus) trigger?.focus()\n      let hidden = false\n      const done = () => {\n        if (hidden || this.open) return\n        hidden = true\n        if (panel.matches(':popover-open')) panel.hidePopover()\n      }\n      setTimeout(done, 400)\n      const anims = panel.getAnimations()\n      if (anims.length) Promise.all(anims.map((a) => a.finished)).then(done, done)\n      else done()\n    }\n  }\n\n  private teardown() {\n    this.stopAutoUpdate?.()\n    this.stopAutoUpdate = undefined\n    removeEventListener('pointerdown', this.onDocPointerDown, true)\n    removeEventListener('keydown', this.onDocKeyDown, true)\n    this.removeEventListener('focusout', this.onFocusOut)\n  }\n\n  private place() {\n    const trigger = this.trigger\n    const panel = this.panel\n    if (!trigger || !panel) return\n    const { style, side, align } = computePosition(trigger, panel, { side: 'bottom', align: 'start', sideOffset: 4 })\n    this.pos = { ...style, '--radix-popover-content-transform-origin': transformOrigin(side, align) }\n    this.placedSide = side\n  }\n\n  /** Snap \"now\" to the configured steps (React's pickNow math). */\n  protected nowValue(secondStep: number, minuteStep: number, format: TimeFormat) {\n    const d = new Date()\n    const sStep = Math.max(1, secondStep)\n    const snappedS = Math.round(d.getSeconds() / sStep) * sStep\n    const secCarry = Math.floor(snappedS / 60)\n    const s = snappedS % 60\n    const mStep = Math.max(1, minuteStep)\n    const snappedM = Math.round((d.getMinutes() + secCarry) / mStep) * mStep\n    const minCarry = Math.floor(snappedM / 60)\n    const min = snappedM % 60\n    const h = (d.getHours() + minCarry) % 24\n    return formatDisplay(h, min, s, 'HH:mm:ss').slice(0, format === 'HH:mm:ss' ? 8 : 5)\n  }\n}\n\nconst presetsConverter = {\n  fromAttribute: (v: string | null) => {\n    if (v === null || v.trim() === '') return undefined\n    try {\n      const parsed = JSON.parse(v)\n      return Array.isArray(parsed) ? parsed : undefined\n    } catch {\n      return undefined\n    }\n  },\n  toAttribute: () => null,\n}\n\n/**\n * <uip-time-picker> — the registry TimePicker as a web component.\n *\n *   <uip-time-picker default-value=\"09:30\" presets='[{\"label\":\"Noon\",\"value\":\"12:00\"}]'></uip-time-picker>\n *\n * Trigger, preset, header, and option class strings are React's verbatim. The\n * panel is a native popover (align start, like React's PopoverContent) with\n * the same presets list, Time/Now header, and a nested <uip-time-columns>.\n * `value` is controlled (24h `HH:mm` / `HH:mm:ss`); `default-value` starts an\n * uncontrolled instance. `presets` is a property or a JSON attribute.\n * `disabled-hours` / `disabled-minutes` / `disabled-seconds` are function\n * properties. React's `readOnly` is the native `readonly` attribute;\n * `clearable`/`allow-clear` default true (`=\"false\"` to disable).\n *\n * Events (bubble, composed): `value-change` (detail: { value } — React's\n * `onValueChange`), `input` + `change` (value on `.value`), `open-change`\n * (detail: { open }). Parts: `trigger`, `content`, `preset`, `now`.\n */\nexport class UipTimePicker extends TimePickerPanel {\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: inline-flex; }`]\n\n  static properties = {\n    ...TimePickerPanel.properties,\n    value: {},\n    defaultValue: { attribute: 'default-value' },\n    placeholder: {},\n    disabled: { type: Boolean, reflect: true },\n    readOnly: { type: Boolean, attribute: 'readonly', reflect: true },\n    clearable: { type: Boolean, converter: trueByDefault },\n    allowClear: { type: Boolean, attribute: 'allow-clear', converter: optBoolean },\n    minuteStep: { type: Number, attribute: 'minute-step' },\n    hourStep: { type: Number, attribute: 'hour-step' },\n    secondStep: { type: Number, attribute: 'second-step' },\n    use24Hour: { type: Boolean, attribute: 'use-24-hour' },\n    use12Hours: { type: Boolean, attribute: 'use-12-hours' },\n    minTime: { attribute: 'min-time' },\n    maxTime: { attribute: 'max-time' },\n    format: {},\n    disabledHours: { attribute: false },\n    disabledMinutes: { attribute: false },\n    disabledSeconds: { attribute: false },\n    hideDisabledOptions: { type: Boolean, attribute: 'hide-disabled-options' },\n    presets: { converter: presetsConverter },\n    size: { reflect: true },\n    status: { reflect: true },\n    internal: { state: true },\n  }\n\n  value?: string\n  defaultValue = ''\n  placeholder = 'Pick a time'\n  disabled = false\n  readOnly = false\n  clearable = true\n  allowClear?: boolean\n  minuteStep = 5\n  hourStep = 1\n  secondStep = 1\n  use24Hour = false\n  use12Hours = false\n  minTime?: string\n  maxTime?: string\n  format: TimeFormat = 'HH:mm'\n  disabledHours?: () => number[]\n  disabledMinutes?: (selectedHour: number) => number[]\n  disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[]\n  hideDisabledOptions = false\n  presets: TimePreset[] = []\n  size: TimePickerSize = 'middle'\n  status?: TimePickerStatus\n  private internal = ''\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'time-picker')\n    this.internal = this.defaultValue\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('open')) this.state = this.open ? 'open' : 'closed'\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    this.syncPanel(changed)\n  }\n\n  private get currentValue() {\n    return this.value !== undefined ? this.value : this.internal\n  }\n\n  private emitTime(v: string) {\n    if (this.value === undefined) this.internal = v\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: v }, bubbles: true, composed: true }))\n  }\n\n  private pickNow() {\n    if (this.readOnly) return\n    this.emitTime(this.nowValue(this.secondStep, this.minuteStep, this.format))\n  }\n\n  private applyPreset(preset: TimePreset) {\n    if (this.readOnly) return\n    this.emitTime(preset.value)\n    this.setOpen(false)\n  }\n\n  private clear(e: Event) {\n    e.stopPropagation()\n    if (this.disabled || this.readOnly) return\n    this.emitTime('')\n  }\n\n  render() {\n    const currentValue = this.currentValue\n    const parsed = parse(currentValue)\n    const display = parsed ? formatDisplay(parsed.hour24, parsed.minute, parsed.second, this.format) : ''\n    const effectiveAllowClear = this.allowClear !== undefined ? this.allowClear : this.clearable\n    const triggerClasses = cn(\n      'min-w-[160px] justify-start gap-2 text-left font-normal',\n      !parsed && 'text-muted-foreground',\n      sizeClasses[this.size],\n      this.status && statusClasses[this.status],\n    )\n    return html`\n      <button\n        part=\"trigger\"\n        type=\"button\"\n        data-uipkge=\"\"\n        data-slot=\"time-picker\"\n        ?disabled=${this.disabled}\n        class=${cn(buttonVariants({ variant: 'outline' }), triggerClasses, 'grow')}\n        @click=${() => {\n          if (!this.disabled) this.toggle()\n        }}\n      >\n        ${icon(Clock, 'clock', 'size-4 shrink-0')}\n        <span class=\"flex-1 truncate\">${display || this.placeholder}</span>\n        ${effectiveAllowClear && parsed && !this.disabled && !this.readOnly\n          ? html`<!-- span (not button): nested interactive elements inside Button are invalid HTML -->\n              <span\n                role=\"button\"\n                tabindex=\"-1\"\n                class=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring -mr-1 inline-flex size-5 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none\"\n                aria-label=\"Clear time\"\n                @click=${this.clear}\n                @mousedown=${(e: Event) => e.preventDefault()}\n              >\n                ${icon(X, 'x', 'size-3.5')}\n              </span>`\n          : nothing}\n      </button>\n      <div\n        part=\"content\"\n        role=\"dialog\"\n        tabindex=\"-1\"\n        popover=\"manual\"\n        data-uipkge=\"\"\n        data-slot=\"time-picker-content\"\n        data-state=${this.state}\n        data-side=${this.placedSide}\n        data-align=\"start\"\n        style=${styleMap(this.pos)}\n        class=${panelClasses}\n      >\n        ${this.presets.length > 0\n          ? html`<div class=\"flex flex-col gap-0.5 border-b p-2\">\n              ${this.presets.map(\n                (p) => html`<button\n                  part=\"preset\"\n                  type=\"button\"\n                  class=\"hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1.5 text-left text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none\"\n                  @click=${() => this.applyPreset(p)}\n                >\n                  ${p.label}\n                </button>`,\n              )}\n            </div>`\n          : nothing}\n        <div class=\"flex items-center justify-between border-b px-3 py-2\">\n          <span class=\"text-muted-foreground text-xs tracking-widest uppercase\">Time</span>\n          <button\n            part=\"now\"\n            type=\"button\"\n            class=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring text-xs focus-visible:ring-2 focus-visible:outline-none\"\n            @click=${() => this.pickNow()}\n          >\n            Now\n          </button>\n        </div>\n        <uip-time-columns\n          value=${currentValue}\n          ?use-24-hour=${this.use24Hour}\n          ?use-12-hours=${this.use12Hours}\n          minute-step=${this.minuteStep}\n          hour-step=${this.hourStep}\n          second-step=${this.secondStep}\n          min-time=${this.minTime ?? nothing}\n          max-time=${this.maxTime ?? nothing}\n          format=${this.format}\n          ?hide-disabled-options=${this.hideDisabledOptions}\n          .visible=${this.open}\n          .disabledHours=${this.disabledHours}\n          .disabledMinutes=${this.disabledMinutes}\n          .disabledSeconds=${this.disabledSeconds}\n          @value-change=${(e: CustomEvent) => this.emitTime(e.detail.value)}\n        ></uip-time-columns>\n      </div>\n    `\n  }\n}\n\n/**\n * <uip-time-range-picker> — the registry TimeRangePicker as a web component.\n *\n * `value` is a `[start, end]` pair (`\"09:00,17:00\"` attribute, array property,\n * `null` = empty); the panel shows Start/End <uip-time-columns> side by side\n * with Now (Start) / Now (End) shortcuts. Otherwise the same API, classes,\n * and events as <uip-time-picker> (`value-change` detail: { value } with the\n * pair, plus `open-change`; no `input`/`change` — the value is a pair).\n */\nexport class UipTimeRangePicker extends TimePickerPanel {\n  static styles = [tailwind, css`:host { display: inline-flex; }`]\n\n  static properties = {\n    ...TimePickerPanel.properties,\n    value: {\n      converter: {\n        fromAttribute: (v: string | null): [string, string] | null | undefined => {\n          if (v === null) return undefined\n          if (v.trim() === '') return null\n          const [a, b] = v.split(/\\s*,\\s*/)\n          return [a ?? '', b ?? '']\n        },\n        toAttribute: () => null,\n      },\n    },\n    placeholder: {},\n    disabled: { type: Boolean, reflect: true },\n    readOnly: { type: Boolean, attribute: 'readonly', reflect: true },\n    clearable: { type: Boolean, converter: trueByDefault },\n    allowClear: { type: Boolean, attribute: 'allow-clear', converter: optBoolean },\n    minuteStep: { type: Number, attribute: 'minute-step' },\n    hourStep: { type: Number, attribute: 'hour-step' },\n    secondStep: { type: Number, attribute: 'second-step' },\n    use24Hour: { type: Boolean, attribute: 'use-24-hour' },\n    use12Hours: { type: Boolean, attribute: 'use-12-hours' },\n    minTime: { attribute: 'min-time' },\n    maxTime: { attribute: 'max-time' },\n    format: {},\n    disabledHours: { attribute: false },\n    disabledMinutes: { attribute: false },\n    disabledSeconds: { attribute: false },\n    hideDisabledOptions: { type: Boolean, attribute: 'hide-disabled-options' },\n    presets: { converter: presetsConverter },\n    size: { reflect: true },\n    status: { reflect: true },\n    internal: { state: true },\n  }\n\n  value?: [string, string] | null\n  placeholder = 'Pick a time range'\n  disabled = false\n  readOnly = false\n  clearable = true\n  allowClear?: boolean\n  minuteStep = 5\n  hourStep = 1\n  secondStep = 1\n  use24Hour = false\n  use12Hours = false\n  minTime?: string\n  maxTime?: string\n  format: TimeFormat = 'HH:mm'\n  disabledHours?: () => number[]\n  disabledMinutes?: (selectedHour: number) => number[]\n  disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[]\n  hideDisabledOptions = false\n  presets: TimeRangePreset[] = []\n  size: TimePickerSize = 'middle'\n  status?: TimePickerStatus\n  private internal: [string, string] | null = null\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'time-range-picker')\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('open')) this.state = this.open ? 'open' : 'closed'\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    this.syncPanel(changed)\n  }\n\n  private get currentValue() {\n    return this.value !== undefined ? this.value : this.internal\n  }\n\n  private emitRange(start: string, end: string) {\n    const next: [string, string] = [start, end]\n    if (this.value === undefined) this.internal = next\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: next }, bubbles: true, composed: true }))\n  }\n\n  private emitStart(v: string) {\n    this.emitRange(v, this.currentValue?.[1] || v)\n  }\n\n  private emitEnd(v: string) {\n    this.emitRange(this.currentValue?.[0] || v, v)\n  }\n\n  private pickNow(which: 'start' | 'end') {\n    if (this.readOnly) return\n    const v = this.nowValue(this.secondStep, this.minuteStep, this.format)\n    if (which === 'start') this.emitStart(v)\n    else this.emitEnd(v)\n  }\n\n  private applyPreset(preset: TimeRangePreset) {\n    if (this.readOnly) return\n    if (this.value === undefined) this.internal = preset.value\n    this.dispatchEvent(\n      new CustomEvent('value-change', { detail: { value: preset.value }, bubbles: true, composed: true }),\n    )\n    this.setOpen(false)\n  }\n\n  private clear(e: Event) {\n    e.stopPropagation()\n    if (this.disabled || this.readOnly) return\n    if (this.value === undefined) this.internal = null\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: null }, bubbles: true, composed: true }))\n  }\n\n  private columnsProps(which: 'start' | 'end') {\n    const v = which === 'start' ? (this.currentValue?.[0] ?? '') : (this.currentValue?.[1] ?? '')\n    return {\n      value: v,\n      onPick: which === 'start' ? this.emitStart.bind(this) : this.emitEnd.bind(this),\n    }\n  }\n\n  render() {\n    const startValue = this.currentValue?.[0] ?? ''\n    const endValue = this.currentValue?.[1] ?? ''\n    const startParsed = parse(startValue)\n    const endParsed = parse(endValue)\n    const display = (() => {\n      const hasStart = startParsed !== null\n      const hasEnd = endParsed !== null\n      if (!hasStart && !hasEnd) return ''\n      const startStr = hasStart ? formatDisplay(startParsed.hour24, startParsed.minute, startParsed.second, this.format) : ''\n      const endStr = hasEnd ? formatDisplay(endParsed.hour24, endParsed.minute, endParsed.second, this.format) : ''\n      if (hasStart && hasEnd) return `${startStr} ~ ${endStr}`\n      return startStr || endStr\n    })()\n    const effectiveAllowClear = this.allowClear !== undefined ? this.allowClear : this.clearable\n    const triggerClasses = cn(\n      'min-w-[200px] justify-start gap-2 text-left font-normal',\n      !display && 'text-muted-foreground',\n      sizeClasses[this.size],\n      this.status && statusClasses[this.status],\n    )\n    const cols = (which: 'start' | 'end') => {\n      const { value, onPick } = this.columnsProps(which)\n      return html`<uip-time-columns\n        value=${value}\n        ?use-24-hour=${this.use24Hour}\n        ?use-12-hours=${this.use12Hours}\n        minute-step=${this.minuteStep}\n        hour-step=${this.hourStep}\n        second-step=${this.secondStep}\n        min-time=${this.minTime ?? nothing}\n        max-time=${this.maxTime ?? nothing}\n        format=${this.format}\n        ?hide-disabled-options=${this.hideDisabledOptions}\n        .visible=${this.open}\n        .disabledHours=${this.disabledHours}\n        .disabledMinutes=${this.disabledMinutes}\n        .disabledSeconds=${this.disabledSeconds}\n        @value-change=${(e: CustomEvent) => onPick(e.detail.value)}\n      ></uip-time-columns>`\n    }\n    return html`\n      <button\n        part=\"trigger\"\n        type=\"button\"\n        data-uipkge=\"\"\n        data-slot=\"time-range-picker\"\n        ?disabled=${this.disabled}\n        class=${cn(buttonVariants({ variant: 'outline' }), triggerClasses, 'grow')}\n        @click=${() => {\n          if (!this.disabled) this.toggle()\n        }}\n      >\n        ${icon(Clock, 'clock', 'size-4 shrink-0')}\n        <span class=\"flex-1 truncate\">${display || this.placeholder}</span>\n        ${effectiveAllowClear && display && !this.disabled && !this.readOnly\n          ? html`<!-- span (not button): nested interactive elements inside Button are invalid HTML -->\n              <span\n                role=\"button\"\n                tabindex=\"-1\"\n                class=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring -mr-1 inline-flex size-5 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none\"\n                aria-label=\"Clear time range\"\n                @click=${this.clear}\n                @mousedown=${(e: Event) => e.preventDefault()}\n              >\n                ${icon(X, 'x', 'size-3.5')}\n              </span>`\n          : nothing}\n      </button>\n      <div\n        part=\"content\"\n        role=\"dialog\"\n        tabindex=\"-1\"\n        popover=\"manual\"\n        data-uipkge=\"\"\n        data-slot=\"time-range-picker-content\"\n        data-state=${this.state}\n        data-side=${this.placedSide}\n        data-align=\"start\"\n        style=${styleMap(this.pos)}\n        class=${panelClasses}\n      >\n        ${this.presets.length > 0\n          ? html`<div class=\"flex flex-col gap-0.5 border-b p-2\">\n              ${this.presets.map(\n                (p) => html`<button\n                  part=\"preset\"\n                  type=\"button\"\n                  class=\"hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1.5 text-left text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none\"\n                  @click=${() => this.applyPreset(p)}\n                >\n                  ${p.label}\n                </button>`,\n              )}\n            </div>`\n          : nothing}\n        <div class=\"flex items-center justify-between border-b px-3 py-2\">\n          <span class=\"text-muted-foreground text-xs tracking-widest uppercase\">Time Range</span>\n          <div class=\"flex gap-2\">\n            <button\n              type=\"button\"\n              class=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring text-xs focus-visible:ring-2 focus-visible:outline-none\"\n              @click=${() => this.pickNow('start')}\n            >\n              Now (Start)\n            </button>\n            <button\n              type=\"button\"\n              class=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring text-xs focus-visible:ring-2 focus-visible:outline-none\"\n              @click=${() => this.pickNow('end')}\n            >\n              Now (End)\n            </button>\n          </div>\n        </div>\n        <div class=\"flex\">\n          <div class=\"flex flex-col\">\n            <div class=\"text-muted-foreground border-b px-3 py-1.5 text-center text-xs font-medium\">Start</div>\n            ${cols('start')}\n          </div>\n          <div class=\"bg-border w-px\"></div>\n          <div class=\"flex flex-col\">\n            <div class=\"text-muted-foreground border-b px-3 py-1.5 text-center text-xs font-medium\">End</div>\n            ${cols('end')}\n          </div>\n        </div>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-time-picker') || customElements.define('uip-time-picker', UipTimePicker)\ncustomElements.get('uip-time-columns') || customElements.define('uip-time-columns', UipTimeColumns)\ncustomElements.get('uip-time-range-picker') || customElements.define('uip-time-range-picker', UipTimeRangePicker)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-time-picker': UipTimePicker\n    'uip-time-columns': UipTimeColumns\n    'uip-time-range-picker': UipTimeRangePicker\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/time-picker/time-picker.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/button.json",
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
