{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"time-field","title":"Time Field","description":"Segmented, locale-aware time entry with an optional compact hour and minute selector. Search terms: hours, minutes, form.","dependencies":["@internationalized/date@3.12.3","lucide-react@1.18.0","motion@12.43.0","react-aria-components@1.20.0"],"registryDependencies":["@siteplane/base","@siteplane/button","@siteplane/input","@siteplane/input-group","@siteplane/list-box","@siteplane/popover","@siteplane/scroll-area","@siteplane/motion"],"files":[{"path":"packages/ui/src/components/time-field/time-field.tsx","content":"\"use client\";\n\nimport {\n  getLocalTimeZone,\n  Time,\n  today,\n  toCalendarDateTime,\n} from \"@internationalized/date\";\nimport { ClockIcon } from \"lucide-react\";\nimport { animate } from \"motion/react\";\nimport * as React from \"react\";\nimport {\n  DateInput as DateInputPrimitive,\n  DateSegment as DateSegmentPrimitive,\n  TimeField as TimeFieldPrimitive,\n  type TimeFieldState,\n  useLocale,\n} from \"react-aria-components\";\n\nimport { isInvalidAriaValue } from \"../../system/form/aria-invalid\";\nimport { useFormReset } from \"../../system/form/use-form-reset\";\nimport { normalizeFormattedText } from \"../../system/locale/formatted-text\";\nimport { getMotionTransitionFromCss } from \"../../system/motion/css-transition\";\nimport {\n  type SiteplaneControlSize,\n  useResolvedControlSize,\n} from \"../../system/provider/siteplane-ui-provider\";\nimport {\n  formControlTextClassNames,\n  type FormControlTextSize,\n  type FormControlVariant,\n} from \"../../system/styles/surface\";\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { Button } from \"../button\";\nimport {\n  type FieldControlBridge,\n  hasClearedSegment,\n  type SegmentedValue,\n  useFieldControlBridge,\n  useFieldControlProxy,\n  useFieldErrorSegments,\n  useSegmentedValue,\n} from \"../field/field-control-bridge\";\nimport { Input } from \"../input\";\nimport { ExternalInputOwner } from \"../input/input\";\nimport { InputGroup, InputGroupAddon } from \"../input-group\";\nimport { ListBox, ListBoxItem } from \"../list-box\";\nimport { Popover, PopoverPopup, PopoverTrigger } from \"../popover\";\nimport { ScrollArea } from \"../scroll-area\";\n\nimport \"./time-field.css\";\n\nexport type TimeFieldValue = {\n  hour: number;\n  minute: number;\n};\n\n/** A 12-hour clock with AM and PM, or a 24-hour clock. */\nexport type TimeFieldHourCycle = 12 | 24;\n\nexport interface TimeFieldProps {\n  \"aria-describedby\"?: string | undefined;\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"];\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string | undefined;\n  /** Where the segments sit in a field wider than they are. */\n  align?: \"start\" | \"center\";\n  className?: string;\n  defaultValue?: TimeFieldValue;\n  disabled?: boolean;\n  form?: string;\n  /**\n   * The clock the segments and the selector show: `24` (00 to 23), or `12`\n   * with the locale's AM and PM. The value stays in 24-hour time either way.\n   */\n  hourCycle?: TimeFieldHourCycle;\n  hourLabel?: string;\n  hoursLabel?: string;\n  iconMode?: \"action-end\" | \"decorative-start\" | \"none\";\n  /**\n   * The error a required field shows while its time is incomplete, such as\n   * after a segment is cleared. Default: \"Enter a complete time.\"\n   */\n  incompleteLabel?: string;\n  minuteLabel?: string;\n  minutesLabel?: string;\n  minuteStep?: number;\n  name?: string;\n  /** Reports the time the user set, or `undefined` when the user cleared it. */\n  onValueChange?: (value: TimeFieldValue | undefined) => void;\n  openPickerLabel?: string;\n  readOnly?: boolean;\n  required?: boolean;\n  size?: SiteplaneControlSize;\n  textSize?: FormControlTextSize;\n  value?: TimeFieldValue | null;\n  variant?: FormControlVariant;\n}\n\nconst timeFieldActionSizes = {\n  md: \"icon-xs\",\n  lg: \"icon-xs\",\n  sm: \"icon-2xs\",\n  xl: \"icon-sm\",\n} as const satisfies Record<\n  SiteplaneControlSize,\n  \"icon-2xs\" | \"icon-xs\" | \"icon-sm\"\n>;\n\nconst hourOptions = Array.from({ length: 24 }, (_, hour) =>\n  String(hour).padStart(2, \"0\"),\n);\n\n/**\n * The names of the hour options on a 12-hour clock, such as \"09 PM\" for 21,\n * in the locale's day period and with the leading zero the segments show.\n * Unset on a 24-hour clock, whose options are the hours themselves. The Time\n * Field and the Date Time Picker selectors share it.\n */\nexport function useHourOptionLabels(\n  hourCycle: TimeFieldHourCycle,\n): Record<string, string> | undefined {\n  const { locale } = useLocale();\n\n  return React.useMemo(() => {\n    if (hourCycle !== 12) return undefined;\n    // The segments' own rule: React Aria turns a 12-hour cycle into hour12.\n    const format = new Intl.DateTimeFormat(locale, {\n      hour: \"2-digit\",\n      hour12: true,\n    });\n\n    return Object.fromEntries(\n      hourOptions.map((option) => [\n        option,\n        normalizeFormattedText(\n          format.format(new Date(2000, 0, 1, Number(option))),\n        ),\n      ]),\n    );\n  }, [hourCycle, locale]);\n}\n\n/**\n * A literal that separates two segments, the colon or the space before AM,\n * cancels their padding (time-field.css), so it sits as close to them as in\n * text.\n */\nfunction isSeparatorLiteral(text: string): boolean {\n  return text.includes(\":\") || /^\\s+$/.test(text);\n}\n\nfunction normalizeTimeFieldValue(value: TimeFieldValue): TimeFieldValue {\n  return {\n    hour: Math.min(Math.max(Math.trunc(value.hour) || 0, 0), 23),\n    minute: Math.min(Math.max(Math.trunc(value.minute) || 0, 0), 59),\n  };\n}\n\nfunction toTime(value: TimeFieldValue | null | undefined): Time | null {\n  if (!value) return null;\n  const normalized = normalizeTimeFieldValue(value);\n  return new Time(normalized.hour, normalized.minute);\n}\n\nfunction fromTime(value: Time): TimeFieldValue {\n  return { hour: value.hour, minute: value.minute };\n}\n\nfunction formatTimeFieldValue(value: Time | null): string {\n  if (!value) return \"\";\n  return `${String(value.hour).padStart(2, \"0\")}:${String(value.minute).padStart(2, \"0\")}`;\n}\n\nfunction setTimeFieldStateValue(\n  state: TimeFieldState,\n  value: Time | null,\n): void {\n  if (!value) {\n    state.setValue(null);\n    return;\n  }\n\n  const dateValue = state.value;\n  state.setValue(\n    dateValue\n      ? dateValue.set({\n          hour: value.hour,\n          millisecond: value.millisecond,\n          minute: value.minute,\n          second: value.second,\n        })\n      : toCalendarDateTime(today(getLocalTimeZone()), value),\n  );\n}\n\nexport function TimeField(props: TimeFieldProps): React.ReactElement {\n  return <TimeFieldRoot {...props} />;\n}\n\nfunction TimeFieldRoot({\n  \"aria-describedby\": ariaDescribedBy,\n  \"aria-invalid\": ariaInvalid,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  align = \"start\",\n  className,\n  defaultValue,\n  disabled,\n  form,\n  hourCycle = 24,\n  hourLabel,\n  hoursLabel = \"Hours\",\n  iconMode = \"action-end\",\n  incompleteLabel = \"Enter a complete time.\",\n  minuteLabel,\n  minutesLabel = \"Minutes\",\n  minuteStep = 5,\n  name,\n  onValueChange,\n  openPickerLabel = \"Open time selector\",\n  readOnly,\n  required,\n  size,\n  textSize,\n  value,\n  variant = \"default\",\n}: TimeFieldProps): React.ReactElement {\n  const controlled = value !== undefined;\n  const normalizedDefaultValue = defaultValue\n    ? normalizeTimeFieldValue(defaultValue)\n    : undefined;\n  const resolvedSize = useResolvedControlSize(size);\n  const resolvedTextSize =\n    textSize ??\n    (resolvedSize === \"lg\" || resolvedSize === \"xl\" ? \"base\" : undefined);\n  const resolvedMinuteStep = Math.min(\n    Math.max(Math.trunc(minuteStep) || 5, 1),\n    60,\n  );\n  const minuteOptions = React.useMemo(\n    () =>\n      Array.from({ length: Math.ceil(60 / resolvedMinuteStep) }, (_, index) =>\n        String(index * resolvedMinuteStep).padStart(2, \"0\"),\n      ),\n    [resolvedMinuteStep],\n  );\n  const field = useFieldControlBridge({\n    \"aria-describedby\": ariaDescribedBy,\n    \"aria-labelledby\": ariaLabelledBy,\n    disabled,\n    name,\n  });\n  // React Aria validates the time unless the application controls the invalid\n  // state through aria-invalid or an invalid Field.\n  const controlledInvalid =\n    ariaInvalid !== undefined\n      ? isInvalidAriaValue(ariaInvalid)\n      : field.invalid || undefined;\n  // Segment names built from aria-label; a FieldLabel or aria-labelledby\n  // names the segments through React Aria instead.\n  const namedByLabel = ariaLabel !== undefined && !field.labelledBy;\n  // A cleared field, or a cleared segment, is reported as undefined, like\n  // DateField.\n  const segmented = useSegmentedValue<Time>({\n    controlled,\n    defaultValue: toTime(normalizedDefaultValue),\n    incompleteMessage: incompleteLabel,\n    onValueChange: (nextValue) =>\n      onValueChange?.(nextValue ? fromTime(nextValue) : undefined),\n    required,\n    serialize: formatTimeFieldValue,\n    value: toTime(value ?? null),\n  });\n\n  // React Aria's optional props reject `undefined` under\n  // exactOptionalPropertyTypes, so unset values are omitted.\n  return (\n    <TimeFieldPrimitive<Time>\n      {...(field.describedBy === undefined\n        ? {}\n        : { \"aria-describedby\": field.describedBy })}\n      {...(field.labelledBy || ariaLabel === undefined\n        ? {}\n        : { \"aria-label\": ariaLabel })}\n      {...(field.labelledBy === undefined\n        ? {}\n        : { \"aria-labelledby\": field.labelledBy })}\n      className=\"sp-time-field sp-base-time-field contents\"\n      data-foundation=\"time-field\"\n      data-slot=\"time-field\"\n      {...(controlled\n        ? { value: segmented.ownerValue }\n        : { defaultValue: toTime(normalizedDefaultValue) })}\n      granularity=\"minute\"\n      hourCycle={hourCycle}\n      isDisabled={field.disabled}\n      {...(controlledInvalid === undefined\n        ? {}\n        : { isInvalid: controlledInvalid })}\n      {...(readOnly === undefined ? {} : { isReadOnly: readOnly })}\n      {...(required === undefined ? {} : { isRequired: required })}\n      onChange={segmented.onOwnerChange}\n      shouldForceLeadingZeros\n      validate={segmented.validate}\n    >\n      {({ state }) => (\n        <TimeFieldContent\n          align={align}\n          className={className}\n          controlled={controlled}\n          controlledInvalid={controlledInvalid}\n          dayPeriodLabel={namedByLabel ? `${ariaLabel}, AM/PM` : undefined}\n          defaultValue={normalizedDefaultValue}\n          field={field}\n          form={form}\n          hourCycle={hourCycle}\n          hourLabel={\n            hourLabel ?? (namedByLabel ? `${ariaLabel}, hour` : undefined)\n          }\n          hoursLabel={hoursLabel}\n          iconMode={iconMode}\n          minuteLabel={\n            minuteLabel ?? (namedByLabel ? `${ariaLabel}, minute` : undefined)\n          }\n          minutesLabel={minutesLabel}\n          minuteOptions={minuteOptions}\n          openPickerLabel={openPickerLabel}\n          readOnly={readOnly}\n          required={required}\n          resolvedSize={resolvedSize}\n          resolvedTextSize={resolvedTextSize}\n          segmented={segmented}\n          stateValue={(state as TimeFieldState).timeValue as Time | null}\n          textSize={textSize}\n          timeFieldState={state as TimeFieldState}\n          variant={variant}\n        />\n      )}\n    </TimeFieldPrimitive>\n  );\n}\n\nfunction TimeFieldContent({\n  align,\n  className,\n  controlled,\n  controlledInvalid,\n  dayPeriodLabel,\n  defaultValue,\n  field,\n  form,\n  hourCycle,\n  hourLabel,\n  hoursLabel,\n  iconMode,\n  minuteLabel,\n  minutesLabel,\n  minuteOptions,\n  openPickerLabel,\n  readOnly,\n  required,\n  resolvedSize,\n  resolvedTextSize,\n  segmented,\n  stateValue,\n  textSize,\n  timeFieldState,\n  variant,\n}: {\n  align: \"center\" | \"start\";\n  className: string | undefined;\n  controlled: boolean;\n  controlledInvalid: boolean | undefined;\n  dayPeriodLabel: string | undefined;\n  defaultValue: TimeFieldValue | undefined;\n  field: FieldControlBridge;\n  form: string | undefined;\n  hourCycle: TimeFieldHourCycle;\n  hourLabel: string | undefined;\n  hoursLabel: string;\n  iconMode: \"action-end\" | \"decorative-start\" | \"none\";\n  minuteLabel: string | undefined;\n  minutesLabel: string;\n  minuteOptions: string[];\n  openPickerLabel: string;\n  readOnly: boolean | undefined;\n  required: boolean | undefined;\n  resolvedSize: SiteplaneControlSize;\n  resolvedTextSize: FormControlTextSize | undefined;\n  segmented: SegmentedValue<Time>;\n  stateValue: Time | null;\n  textSize: FormControlTextSize | undefined;\n  timeFieldState: TimeFieldState;\n  variant: FormControlVariant;\n}): React.ReactElement {\n  const [open, setOpen] = React.useState(false);\n  const hourOptionLabels = useHourOptionLabels(hourCycle);\n  const hoursListBoxRef = React.useRef<HTMLDivElement>(null);\n  const segmentsRef = React.useRef<HTMLDivElement>(null);\n  const hiddenInputRef = useFormReset<HTMLInputElement>(form, () => {\n    if (!controlled)\n      setTimeFieldStateValue(timeFieldState, toTime(defaultValue));\n  });\n  // A cleared segment leaves the field without a value, although React Aria\n  // still holds the last complete time.\n  const cleared = hasClearedSegment(timeFieldState);\n  const fieldValue = cleared ? null : stateValue;\n  const serializedValue = formatTimeFieldValue(fieldValue);\n  React.useLayoutEffect(() => {\n    segmented.sync({ cleared, effective: fieldValue, value: stateValue });\n  });\n  const invalid =\n    controlledInvalid ??\n    (timeFieldState.displayValidation.isInvalid || field.valid === false);\n  const proxyProps = useFieldControlProxy({\n    controlled: controlledInvalid !== undefined,\n    disabled: field.disabled,\n    displayValidation: timeFieldState.displayValidation,\n    focusTarget: () =>\n      segmentsRef.current?.querySelector<HTMLElement>(\n        '[role=\"spinbutton\"], [role=\"textbox\"]',\n      ) ?? null,\n    form,\n    name: field.name,\n    onRevalidate: timeFieldState.commitValidation,\n    realtimeValidation: timeFieldState.realtimeValidation,\n    required,\n    value: serializedValue,\n  });\n  useFieldErrorSegments(segmentsRef, {\n    controlled: controlledInvalid !== undefined,\n    ownerInvalid: timeFieldState.isInvalid,\n  });\n  const optionTextClassName = resolvedTextSize\n    ? formControlTextClassNames[resolvedTextSize]\n    : \"text-base sm:text-sm\";\n  // The segments read in the locale's direction, which React Aria's arrow\n  // keys follow, whatever the page's: an en-US time keeps its hours before\n  // its minutes in a right-to-left page. The dir attribute also isolates the\n  // row from the text around it; the field keeps the page's direction and\n  // pads its own insets (time-field.css), as the Date Field does.\n  const { direction } = useLocale();\n  const control = (\n    <InputGroup\n      aria-label={undefined}\n      // As wide as the segments by default, like the Date Time Picker; a width\n      // utility in className, such as w-full, replaces it. The default width\n      // never shrinks below the segments in a flex row (shrink-0 while w-max).\n      className={cn(\n        \"sp-time-field-control w-max [&.w-max]:shrink-0\",\n        align === \"center\" && \"sp-time-field-control-center\",\n        className,\n      )}\n      data-icon-mode={iconMode}\n      data-invalid={invalid || undefined}\n      data-readonly={(readOnly && !field.disabled) || undefined}\n      data-size={resolvedSize}\n      data-time-field-align={align}\n      // The segments take the field text scale, as Input does: 14 px, or\n      // 16 px at xl; textSize sets one size for every control size.\n      textSize={textSize ?? (resolvedSize === \"xl\" ? \"base\" : undefined)}\n      variant={variant}\n    >\n      {iconMode === \"decorative-start\" ? (\n        <InputGroupAddon\n          align=\"inline-start\"\n          aria-hidden=\"true\"\n          className=\"sp-time-field-icon-addon pointer-events-none\"\n        >\n          <ClockIcon\n            className=\"sp-time-field-decorative-icon\"\n            data-slot=\"time-field-decorative-icon\"\n          />\n        </InputGroupAddon>\n      ) : null}\n      <DateInputPrimitive\n        className=\"sp-time-field-input\"\n        data-slot=\"time-field-input\"\n        dir={direction}\n        ref={segmentsRef}\n      >\n        {(segment) => (\n          <TimeFieldSegment\n            label={\n              segment.type === \"hour\"\n                ? hourLabel\n                : segment.type === \"minute\"\n                  ? minuteLabel\n                  : segment.type === \"dayPeriod\"\n                    ? dayPeriodLabel\n                    : undefined\n            }\n            segment={segment}\n          />\n        )}\n      </DateInputPrimitive>\n      {iconMode === \"action-end\" ? (\n        <InputGroupAddon align=\"inline-end\">\n          <PopoverTrigger\n            data-slot=\"time-field-trigger\"\n            render={\n              <Button\n                aria-label={openPickerLabel}\n                disabled={field.disabled || readOnly}\n                profile=\"field-action\"\n                size={timeFieldActionSizes[resolvedSize]}\n              />\n            }\n          >\n            <ClockIcon aria-hidden=\"true\" />\n          </PopoverTrigger>\n        </InputGroupAddon>\n      ) : null}\n    </InputGroup>\n  );\n\n  return (\n    <>\n      {iconMode === \"action-end\" ? (\n        <Popover onOpenChange={setOpen} open={open}>\n          {control}\n          <PopoverPopup\n            align=\"end\"\n            className={cn(\"sp-time-field-popover\", optionTextClassName)}\n            initialFocus={hoursListBoxRef}\n            sideOffset={6}\n            viewportPadding=\"compact\"\n          >\n            <div\n              className=\"sp-time-field-selector sp-time-selector\"\n              data-slot=\"time-field-selector\"\n            >\n              <TimeFieldOptionColumn\n                label={hoursLabel}\n                listBoxRef={hoursListBoxRef}\n                onValueChange={(hour) => {\n                  setTimeFieldStateValue(\n                    timeFieldState,\n                    new Time(Number(hour), stateValue?.minute ?? 0),\n                  );\n                }}\n                open={open}\n                optionLabels={hourOptionLabels}\n                options={hourOptions}\n                slot=\"time-field-hours\"\n                value={\n                  stateValue\n                    ? String(stateValue.hour).padStart(2, \"0\")\n                    : undefined\n                }\n              />\n              <TimeFieldOptionColumn\n                label={minutesLabel}\n                onValueChange={(minute) => {\n                  setTimeFieldStateValue(\n                    timeFieldState,\n                    new Time(stateValue?.hour ?? 0, Number(minute)),\n                  );\n                  setOpen(false);\n                }}\n                open={open}\n                options={minuteOptions}\n                slot=\"time-field-minutes\"\n                value={\n                  stateValue\n                    ? String(stateValue.minute).padStart(2, \"0\")\n                    : undefined\n                }\n              />\n            </div>\n          </PopoverPopup>\n        </Popover>\n      ) : (\n        control\n      )}\n      <Input\n        disabled={field.disabled}\n        form={form}\n        name={field.name}\n        readOnly\n        ref={hiddenInputRef}\n        required={required}\n        type=\"hidden\"\n        value={serializedValue}\n      />\n      {proxyProps ? (\n        <ExternalInputOwner>\n          <Input nativeInput unstyled {...proxyProps} />\n        </ExternalInputOwner>\n      ) : null}\n    </>\n  );\n}\n\nfunction TimeFieldSegment({\n  label,\n  segment,\n}: {\n  label: string | undefined;\n  segment: React.ComponentProps<typeof DateSegmentPrimitive>[\"segment\"];\n}): React.ReactElement {\n  const ref = React.useRef<HTMLSpanElement>(null);\n\n  // React Aria re-renders its own segment name, so the custom name is applied\n  // after every render.\n  React.useLayoutEffect(() => {\n    if (label) ref.current?.setAttribute(\"aria-label\", label);\n  });\n\n  // React Aria builds the segments with formatToParts, which gives Node's\n  // narrow no-break space before AM and PM where the browser has a plain\n  // space; the segment shows the text the server and the browser agree on, or\n  // the prerendered field fails hydration (React error #418).\n  return (\n    <DateSegmentPrimitive\n      className=\"sp-time-field-segment sp-time-segment\"\n      data-literal-kind={\n        segment.type === \"literal\" && isSeparatorLiteral(segment.text)\n          ? \"separator\"\n          : undefined\n      }\n      data-slot=\"time-field-segment\"\n      ref={ref}\n      segment={segment}\n    >\n      {({ text }) => normalizeFormattedText(text)}\n    </DateSegmentPrimitive>\n  );\n}\n\nexport function TimeFieldOptionColumn({\n  label,\n  listBoxRef: providedListBoxRef,\n  onValueChange,\n  open,\n  optionLabels,\n  options,\n  slot,\n  value,\n}: {\n  label: string;\n  listBoxRef?: React.RefObject<HTMLDivElement | null>;\n  onValueChange: (value: string) => void;\n  open: boolean;\n  /** The names the options show, where they differ from their values. */\n  optionLabels?: Record<string, string> | undefined;\n  options: string[];\n  slot: \"time-field-hours\" | \"time-field-minutes\";\n  value: string | undefined;\n}): React.ReactElement {\n  const localListBoxRef = React.useRef<HTMLDivElement | null>(null);\n  const listBoxRef = providedListBoxRef ?? localListBoxRef;\n  const pendingScrollStartRef = React.useRef<number | null>(null);\n  const previousValueRef = React.useRef(value);\n  const scrollAnimationRef = React.useRef<ReturnType<typeof animate> | null>(\n    null,\n  );\n\n  React.useLayoutEffect(() => {\n    const valueChanged = previousValueRef.current !== value;\n    previousValueRef.current = value;\n\n    if (!open) {\n      scrollAnimationRef.current?.stop();\n      return;\n    }\n\n    let frame = 0;\n    const listBox = listBoxRef.current;\n    if (!listBox) return;\n\n    const centerSelectedOption = () => {\n      const selected = listBox.querySelector<HTMLElement>(\"[data-selected]\");\n      const viewport = selected?.closest<HTMLElement>(\n        '[data-slot=\"scroll-area-viewport\"]',\n      );\n      if (!selected || !viewport) {\n        return false;\n      }\n\n      const selectedRect = selected.getBoundingClientRect();\n      const viewportRect = viewport.getBoundingClientRect();\n      const targetScrollTop = Math.min(\n        viewport.scrollHeight - viewport.clientHeight,\n        Math.max(\n          0,\n          viewport.scrollTop +\n            selectedRect.top -\n            viewportRect.top -\n            (viewport.clientHeight - selectedRect.height) / 2,\n        ),\n      );\n      const shouldAnimate =\n        valueChanged &&\n        document.documentElement.dataset.motionReduce !== \"true\" &&\n        !viewport.closest('[data-sp-motion=\"reduced\"]') &&\n        !window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n      const scrollStart = pendingScrollStartRef.current ?? viewport.scrollTop;\n      pendingScrollStartRef.current = null;\n\n      scrollAnimationRef.current?.stop();\n\n      if (!shouldAnimate) {\n        viewport.scrollTop = targetScrollTop;\n        return true;\n      }\n\n      viewport.scrollTop = scrollStart;\n      scrollAnimationRef.current = animate(scrollStart, targetScrollTop, {\n        ...getMotionTransitionFromCss(\n          viewport,\n          \"--motion-duration-moderate\",\n          \"--motion-ease-standard\",\n        ),\n        onUpdate: (scrollTop) => {\n          viewport.scrollTop = scrollTop;\n        },\n      });\n      return true;\n    };\n\n    const observer = new MutationObserver(() => {\n      if (centerSelectedOption()) observer.disconnect();\n    });\n    observer.observe(listBox, {\n      attributeFilter: [\"data-selected\"],\n      attributes: true,\n      childList: true,\n      subtree: true,\n    });\n    frame = window.requestAnimationFrame(() => {\n      if (centerSelectedOption()) observer.disconnect();\n    });\n    return () => {\n      observer.disconnect();\n      window.cancelAnimationFrame(frame);\n    };\n  }, [open, value]);\n\n  React.useEffect(() => () => scrollAnimationRef.current?.stop(), []);\n\n  const captureScrollStart = () => {\n    const viewport = listBoxRef.current?.closest<HTMLElement>(\n      '[data-slot=\"scroll-area-viewport\"]',\n    );\n    pendingScrollStartRef.current = viewport?.scrollTop ?? null;\n  };\n\n  return (\n    <div\n      className={cn(slotClass(slot), \"sp-time-field-column\")}\n      data-slot={slot}\n    >\n      <span className=\"sp-time-field-column-label\">{label}</span>\n      <ScrollArea\n        className=\"sp-time-field-scroll-area\"\n        scrollFade\n        scrollbar=\"hidden\"\n      >\n        <ListBox\n          aria-label={label}\n          className=\"sp-time-field-listbox\"\n          onPointerDownCapture={captureScrollStart}\n          onValueChange={onValueChange}\n          ref={listBoxRef}\n          selectionAppearance=\"row\"\n          size=\"md\"\n          value={value}\n        >\n          {options.map((option) => (\n            <ListBoxItem\n              className=\"sp-time-field-option justify-center font-normal\"\n              data-slot=\"time-field-option\"\n              key={option}\n              multiline={false}\n              value={option}\n            >\n              {optionLabels?.[option] ?? option}\n            </ListBoxItem>\n          ))}\n        </ListBox>\n      </ScrollArea>\n    </div>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/time-field/time-field.tsx"},{"path":"packages/ui/src/components/time-field/time-field.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  /* The heights are the Input scale (28, 32, 36 and 48 px), so a time field is as\n   * tall as the Input, Select, Button and DateField of the same size. The width is\n   * the segments' own, like the Date Time Picker and the Number Field; a width\n   * utility in className (w-full in a form column) replaces it. */\n  .sp-time-field-control {\n    --time-field-control-height: var(--sp-rw-control-md);\n    --time-field-segment-start: calc(\n      var(--sp-rw-space-3) - var(--input-segment-padding-inline)\n    );\n    max-width: 100%;\n    height: var(--time-field-control-height);\n  }\n\n  .sp-time-field-control[data-icon-mode=\"action-end\"] {\n    padding-inline-end: var(--time-field-control-height);\n  }\n\n  .sp-time-field-control[data-size=\"sm\"] {\n    --time-field-control-height: var(--sp-rw-control-sm);\n    --time-field-segment-start: calc(\n      var(--sp-rw-space-2-5) - var(--input-segment-padding-inline)\n    );\n  }\n\n  .sp-time-field-control[data-size=\"lg\"] {\n    --time-field-control-height: var(--sp-rw-control-lg);\n  }\n\n  .sp-time-field-control[data-size=\"xl\"] {\n    --time-field-control-height: var(--sp-rw-control-xl);\n    --time-field-segment-start: calc(\n      var(--sp-rw-space-4) - var(--input-segment-padding-inline)\n    );\n  }\n\n  .sp-time-field-control-center {\n    justify-content: center;\n  }\n\n  .sp-time-field-input {\n    display: inline-flex;\n    align-items: center;\n    height: 100%;\n    outline: none;\n  }\n\n  /* The insets of the segments belong to the field, which takes the page's\n   * direction, while the segment row takes the locale's (its dir): the field\n   * pads its start, and its end where no selector button ends it. Without the\n   * button the segments keep the inset of the start at the end too, so a field\n   * as wide as its segments is balanced. */\n  .sp-time-field-control:not(.sp-time-field-control-center) {\n    padding-inline-start: var(--time-field-segment-start);\n  }\n\n  .sp-time-field-control:not(.sp-time-field-control-center):not(\n      [data-icon-mode=\"action-end\"]\n    ) {\n    padding-inline-end: var(--time-field-segment-start);\n  }\n\n  /* The leading icon is a regular InputGroup addon at the field's start; the\n   * first segment's text starts where an InputGroup input's text starts after\n   * the same icon. */\n  .sp-time-field-control:has(> .sp-time-field-icon-addon):not(\n      .sp-time-field-control-center\n    ) {\n    padding-inline-start: 0;\n  }\n\n  .sp-time-field-control:not(.sp-time-field-control-center)\n    > .sp-time-field-icon-addon {\n    margin-inline-end: calc(\n      var(--sp-composite-control-padding-inline-start) -\n        var(--input-segment-padding-inline)\n    );\n  }\n\n  .sp-time-segment {\n    outline: none;\n  }\n\n  .sp-time-segment:not([data-type=\"literal\"]) {\n    display: inline-flex;\n    flex: none;\n    align-items: center;\n    justify-content: center;\n    min-width: calc(var(--spacing) * 7);\n    border-radius: calc(var(--radius-md) + var(--input-segment-padding-block));\n    background-clip: content-box;\n    caret-color: var(--color-primary);\n    cursor: text;\n    font-variant-numeric: tabular-nums;\n    padding-block: var(--input-segment-padding-block);\n    padding-inline: var(--input-segment-padding-inline);\n    text-align: center;\n  }\n\n  /* A separator, the colon or the space before AM or PM, cancels the padding\n   * of the segments beside it. Each literal is a flex item of its own, whose\n   * lone space would collapse to nothing, so it keeps its white space. */\n  .sp-time-segment[data-literal-kind=\"separator\"] {\n    margin-inline: calc(var(--spacing) * -1);\n    white-space: pre;\n  }\n\n  .sp-time-segment[data-focused]:not([data-type=\"literal\"]) {\n    background-color: var(--input-segment-background);\n  }\n\n  .sp-time-segment[data-placeholder]:not([data-type=\"literal\"]) {\n    color: var(--color-muted-foreground);\n  }\n\n  .sp-time-segment::selection {\n    background-color: transparent;\n  }\n\n  .sp-time-field-control > .sp-input-group-addon[data-align=\"inline-end\"] {\n    position: absolute;\n    inset-block: 0;\n    inset-inline-end: 0;\n    width: var(--time-field-control-height);\n    padding-inline: 0 !important;\n  }\n\n  .sp-time-field-popover {\n    width: 10rem;\n  }\n\n  .sp-time-selector {\n    display: grid;\n    grid-template-columns: repeat(2, minmax(0, 1fr));\n    gap: var(--spacing);\n  }\n\n  .sp-time-field-column {\n    display: grid;\n    min-width: 0;\n    gap: calc(var(--spacing) * 2);\n  }\n\n  .sp-time-field-column-label {\n    color: var(--color-muted-foreground);\n    font-size: var(--text-sm);\n    font-weight: var(--sp-rw-font-weight-label);\n    text-align: center;\n  }\n\n  .sp-time-field-scroll-area {\n    height: 10rem;\n  }\n\n  .sp-time-field-listbox {\n    display: flex;\n    flex-direction: column;\n    gap: calc(var(--spacing) * 0.5);\n    width: 100%;\n    outline: none;\n  }\n\n  .sp-time-field-option {\n    flex: none;\n    padding-inline: calc(var(--spacing) * 2 - 1px);\n    scroll-margin-block: 4rem;\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/time-field/time-field.css"},{"path":"packages/ui/src/components/time-field/index.ts","content":"export { TimeField } from \"./time-field\";\nexport type {\n  TimeFieldHourCycle,\n  TimeFieldProps,\n  TimeFieldValue,\n} from \"./time-field\";\n","type":"registry:ui","target":"~/src/siteplane/components/time-field/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/time-field.md","categories":["Date & color"],"meta":{"avoidWhen":["Avoid when a date must be edited and submitted with the time; use DateTimePicker instead."],"description":"Segmented, locale-aware time entry with an optional compact hour and minute selector.","knownLimitations":["Supports hour and minute values only, in 24-hour time with HH:mm form serialization; hourCycle={12} changes the display, not the value. No seconds."],"providerNotes":["SiteplaneLocaleProvider supplies the React Aria locale; TimeField has no locale prop and keeps a 24-hour hour/minute contract. Translate hourLabel, minuteLabel, hoursLabel, minutesLabel and openPickerLabel explicitly."],"releaseStatus":"stable","searchTerms":["time","field","hours","minutes","form"],"useWhen":["Use when users should type a time by segment and may also choose hour and minute values."]}}