time-picker

PreviousNext
Docs
diceuiui

Preview

Loading preview…
ui/time-picker.tsx
"use client";

import { Slot } from "@radix-ui/react-slot";
import { Clock } from "lucide-react";
import * as React from "react";
import {
  Popover,
  PopoverAnchor,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/registry/default/components/visually-hidden-input";
import { useAsRef } from "@/registry/default/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/registry/default/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/registry/default/hooks/use-lazy-ref";

const ROOT_NAME = "TimePicker";
const LABEL_NAME = "TimePickerLabel";
const INPUT_GROUP_NAME = "TimePickerInputGroup";
const INPUT_NAME = "TimePickerInput";
const TRIGGER_NAME = "TimePickerTrigger";
const CONTENT_NAME = "TimePickerContent";
const COLUMN_NAME = "TimePickerColumn";
const COLUMN_ITEM_NAME = "TimePickerColumnItem";
const HOUR_NAME = "TimePickerHour";
const MINUTE_NAME = "TimePickerMinute";
const SECOND_NAME = "TimePickerSecond";
const PERIOD_NAME = "TimePickerPeriod";
const CLEAR_NAME = "TimePickerClear";

const DEFAULT_STEP = 1;
const DEFAULT_SEGMENT_PLACEHOLDER = "--";
const DEFAULT_LOCALE = undefined;
const SEGMENTS: Segment[] = ["hour", "minute", "second", "period"];
const PERIODS = ["AM", "PM"] as const;

type Segment = "hour" | "minute" | "second" | "period";
type SegmentFormat = "numeric" | "2-digit";
type Period = (typeof PERIODS)[number];

interface DivProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}

interface ButtonProps extends React.ComponentProps<"button"> {
  asChild?: boolean;
}

type PopoverContentProps = React.ComponentProps<typeof PopoverContent>;

type InputGroupElement = React.ComponentRef<typeof TimePickerInputGroup>;
type InputElement = React.ComponentRef<typeof TimePickerInput>;
type TriggerElement = React.ComponentRef<typeof TimePickerTrigger>;
type ColumnElement = React.ComponentRef<typeof TimePickerColumn>;
type ColumnItemElement = React.ComponentRef<typeof TimePickerColumnItem>;

interface TimeValue {
  hour?: number;
  minute?: number;
  second?: number;
  period?: Period;
}

interface ItemData {
  value: number | string;
  ref: React.RefObject<ColumnItemElement | null>;
  selected: boolean;
}

interface ColumnData {
  id: string;
  ref: React.RefObject<ColumnElement | null>;
  getSelectedItemRef: () => React.RefObject<ColumnItemElement | null> | null;
  getItems: () => ItemData[];
}

function focusFirst(
  candidates: React.RefObject<ColumnItemElement | null>[],
  preventScroll = false,
) {
  const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;
  for (const candidateRef of candidates) {
    const candidate = candidateRef.current;
    if (!candidate) continue;
    if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;
    candidate.focus({ preventScroll });
    if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;
  }
}

function sortNodes<T extends { ref: React.RefObject<Element | null> }>(
  items: T[],
): T[] {
  return items.sort((a, b) => {
    const elementA = a.ref.current;
    const elementB = b.ref.current;
    if (!elementA || !elementB) return 0;
    const position = elementA.compareDocumentPosition(elementB);
    if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
      return -1;
    }
    if (position & Node.DOCUMENT_POSITION_PRECEDING) {
      return 1;
    }
    return 0;
  });
}

function getIs12Hour(locale?: string): boolean {
  const testDate = new Date(2000, 0, 1, 13, 0, 0);
  const formatted = new Intl.DateTimeFormat(locale, {
    hour: "numeric",
  }).format(testDate);

  return /am|pm/i.test(formatted) || !formatted.includes("13");
}

function parseTimeString(timeString: string | undefined): TimeValue | null {
  if (!timeString) return null;

  const parts = timeString.split(":");
  if (parts.length < 2) return null;

  const result: TimeValue = {};

  if (parts[0] && parts[0] !== DEFAULT_SEGMENT_PLACEHOLDER) {
    const hour = Number.parseInt(parts[0], 10);
    if (!Number.isNaN(hour) && hour >= 0 && hour <= 23) {
      result.hour = hour;
    }
  }

  if (parts[1] && parts[1] !== DEFAULT_SEGMENT_PLACEHOLDER) {
    const minute = Number.parseInt(parts[1], 10);
    if (!Number.isNaN(minute) && minute >= 0 && minute <= 59) {
      result.minute = minute;
    }
  }

  if (parts[2] && parts[2] !== DEFAULT_SEGMENT_PLACEHOLDER) {
    const second = Number.parseInt(parts[2], 10);
    if (!Number.isNaN(second) && second >= 0 && second <= 59) {
      result.second = second;
    }
  }
  if (
    result.hour === undefined &&
    result.minute === undefined &&
    result.second === undefined
  ) {
    return null;
  }

  return result;
}

function formatTimeValue(value: TimeValue, showSeconds: boolean): string {
  const hourStr =
    value.hour !== undefined
      ? value.hour.toString().padStart(2, "0")
      : DEFAULT_SEGMENT_PLACEHOLDER;
  const minuteStr =
    value.minute !== undefined
      ? value.minute.toString().padStart(2, "0")
      : DEFAULT_SEGMENT_PLACEHOLDER;
  const secondStr =
    value.second !== undefined
      ? value.second.toString().padStart(2, "0")
      : DEFAULT_SEGMENT_PLACEHOLDER;

  if (showSeconds) {
    return `${hourStr}:${minuteStr}:${secondStr}`;
  }
  return `${hourStr}:${minuteStr}`;
}

function to12Hour(hour24: number): { hour: number; period: Period } {
  const period: Period = hour24 >= 12 ? "PM" : "AM";
  const hour = hour24 % 12 || 12;
  return { hour, period };
}

function to24Hour(hour12: number, period: Period): number {
  if (hour12 === 12) {
    return period === "PM" ? 12 : 0;
  }
  return period === "PM" ? hour12 + 12 : hour12;
}

function clamp(value: number, min: number, max: number) {
  return Math.min(Math.max(value, min), max);
}

interface StoreState {
  value: string;
  open: boolean;
  openedViaFocus: boolean;
}

interface Store {
  subscribe: (callback: () => void) => () => void;
  getState: () => StoreState;
  setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
  notify: () => void;
}

const StoreContext = React.createContext<Store | null>(null);

function useStoreContext(consumerName: string) {
  const context = React.useContext(StoreContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}

function useStore<T>(
  selector: (state: StoreState) => T,
  ogStore?: Store | null,
): T {
  const contextStore = React.useContext(StoreContext);

  const store = ogStore ?? contextStore;

  if (!store) {
    throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
  }

  const getSnapshot = React.useCallback(
    () => selector(store.getState()),
    [store, selector],
  );

  return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}

type SegmentPlaceholder =
  | string
  | {
      hour?: string;
      minute?: string;
      second?: string;
      period?: string;
    };

interface TimePickerContextValue {
  id: string;
  inputGroupId: string;
  labelId: string;
  triggerId: string;
  inputGroupRef: React.RefObject<InputGroupElement | null>;
  triggerRef: React.RefObject<TriggerElement | null>;
  openOnFocus: boolean;
  inputGroupClickAction: "focus" | "open";
  onInputGroupChange: (inputGroup: InputGroupElement | null) => void;
  disabled: boolean;
  readOnly: boolean;
  required: boolean;
  invalid: boolean;
  showSeconds: boolean;
  is12Hour: boolean;
  minuteStep: number;
  secondStep: number;
  hourStep: number;
  segmentPlaceholder: {
    hour: string;
    minute: string;
    second: string;
    period: string;
  };
  min?: string;
  max?: string;
}

const TimePickerContext = React.createContext<TimePickerContextValue | null>(
  null,
);

function useTimePickerContext(consumerName: string) {
  const context = React.useContext(TimePickerContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}

interface TimePickerProps extends DivProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  openOnFocus?: boolean;
  inputGroupClickAction?: "focus" | "open";
  min?: string;
  max?: string;
  hourStep?: number;
  minuteStep?: number;
  secondStep?: number;
  segmentPlaceholder?: SegmentPlaceholder;
  locale?: string;
  name?: string;
  disabled?: boolean;
  invalid?: boolean;
  readOnly?: boolean;
  required?: boolean;
  showSeconds?: boolean;
}

function TimePicker(props: TimePickerProps) {
  const {
    value: valueProp,
    defaultValue,
    onValueChange,
    open,
    defaultOpen,
    onOpenChange,
    openOnFocus = false,
    inputGroupClickAction = "focus",
    min,
    max,
    hourStep = DEFAULT_STEP,
    minuteStep = DEFAULT_STEP,
    secondStep = DEFAULT_STEP,
    segmentPlaceholder = DEFAULT_SEGMENT_PLACEHOLDER,
    locale = DEFAULT_LOCALE,
    name,
    asChild,
    disabled = false,
    invalid = false,
    readOnly = false,
    required = false,
    showSeconds = false,
    className,
    children,
    id,
    ...rootProps
  } = props;

  const instanceId = React.useId();
  const rootId = id ?? instanceId;
  const inputGroupId = React.useId();
  const labelId = React.useId();
  const triggerId = React.useId();

  const inputGroupRef = React.useRef<InputGroupElement>(null);
  const triggerRef = React.useRef<TriggerElement>(null);

  const [inputGroup, setInputGroup] = React.useState<InputGroupElement | null>(
    null,
  );
  const isFormControl = inputGroup ? !!inputGroup.closest("form") : true;

  const listenersRef = useLazyRef(() => new Set<() => void>());
  const stateRef = useLazyRef<StoreState>(() => ({
    value: valueProp ?? defaultValue ?? "",
    open: open ?? defaultOpen ?? false,
    openedViaFocus: false,
  }));

  const propsRef = useAsRef({ onValueChange, onOpenChange });

  const store: Store = React.useMemo(() => {
    return {
      subscribe: (cb) => {
        listenersRef.current.add(cb);
        return () => listenersRef.current.delete(cb);
      },
      getState: () => stateRef.current,
      setState: (key, value) => {
        if (Object.is(stateRef.current[key], value)) return;

        if (key === "value" && typeof value === "string") {
          stateRef.current.value = value;
          propsRef.current.onValueChange?.(value);
        } else if (key === "open" && typeof value === "boolean") {
          stateRef.current.open = value;
          propsRef.current.onOpenChange?.(value);
          if (!value) {
            stateRef.current.openedViaFocus = false;
          }
        } else {
          stateRef.current[key] = value;
        }

        store.notify();
      },
      notify: () => {
        for (const cb of listenersRef.current) {
          cb();
        }
      },
    };
  }, [listenersRef, stateRef, propsRef]);

  const value = useStore((state) => state.value, store);

  useIsomorphicLayoutEffect(() => {
    if (valueProp !== undefined) {
      store.setState("value", valueProp);
    }
  }, [valueProp]);

  useIsomorphicLayoutEffect(() => {
    if (open !== undefined) {
      store.setState("open", open);
    }
  }, [open]);

  const storeOpen = useStore((state) => state.open, store);

  const onPopoverOpenChange = React.useCallback(
    (newOpen: boolean) => store.setState("open", newOpen),
    [store],
  );

  const is12Hour = React.useMemo(() => getIs12Hour(locale), [locale]);

  const normalizedPlaceholder = React.useMemo(() => {
    if (typeof segmentPlaceholder === "string") {
      return {
        hour: segmentPlaceholder,
        minute: segmentPlaceholder,
        second: segmentPlaceholder,
        period: segmentPlaceholder,
      };
    }
    return {
      hour: segmentPlaceholder.hour ?? DEFAULT_SEGMENT_PLACEHOLDER,
      minute: segmentPlaceholder.minute ?? DEFAULT_SEGMENT_PLACEHOLDER,
      second: segmentPlaceholder.second ?? DEFAULT_SEGMENT_PLACEHOLDER,
      period: segmentPlaceholder.period ?? DEFAULT_SEGMENT_PLACEHOLDER,
    };
  }, [segmentPlaceholder]);

  const rootContext = React.useMemo<TimePickerContextValue>(
    () => ({
      id: rootId,
      inputGroupId,
      labelId,
      triggerId,
      inputGroupRef,
      triggerRef,
      openOnFocus,
      inputGroupClickAction,
      onInputGroupChange: setInputGroup,
      disabled,
      readOnly,
      required,
      invalid,
      showSeconds,
      is12Hour,
      minuteStep,
      secondStep,
      hourStep,
      segmentPlaceholder: normalizedPlaceholder,
      min,
      max,
    }),
    [
      rootId,
      inputGroupId,
      labelId,
      triggerId,
      openOnFocus,
      inputGroupClickAction,
      disabled,
      readOnly,
      required,
      invalid,
      showSeconds,
      is12Hour,
      minuteStep,
      secondStep,
      hourStep,
      normalizedPlaceholder,
      min,
      max,
    ],
  );

  const RootPrimitive = asChild ? Slot : "div";

  return (
    <>
      <StoreContext.Provider value={store}>
        <TimePickerContext.Provider value={rootContext}>
          <Popover open={storeOpen} onOpenChange={onPopoverOpenChange}>
            <RootPrimitive
              data-slot="time-picker"
              data-disabled={disabled ? "" : undefined}
              data-invalid={invalid ? "" : undefined}
              {...rootProps}
              className={cn("relative", className)}
            >
              {children}
            </RootPrimitive>
          </Popover>
        </TimePickerContext.Provider>
      </StoreContext.Provider>
      {isFormControl && (
        <VisuallyHiddenInput
          type="hidden"
          control={inputGroup}
          name={name}
          value={value}
          disabled={disabled}
          readOnly={readOnly}
          required={required}
        />
      )}
    </>
  );
}

interface TimePickerLabelProps extends React.ComponentProps<"label"> {
  asChild?: boolean;
}

function TimePickerLabel(props: TimePickerLabelProps) {
  const { asChild, className, ...labelProps } = props;

  const { labelId } = useTimePickerContext(LABEL_NAME);

  const LabelPrimitive = asChild ? Slot : "label";

  return (
    <LabelPrimitive
      data-slot="time-picker-label"
      {...labelProps}
      htmlFor={labelId}
      className={cn(
        "font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
        className,
      )}
    />
  );
}

interface TimePickerInputGroupContextValue {
  onInputRegister: (
    segment: Segment,
    ref: React.RefObject<InputElement | null>,
  ) => void;
  onInputUnregister: (segment: Segment) => void;
  getNextInput: (
    currentSegment: Segment,
  ) => React.RefObject<InputElement | null> | null;
}

const TimePickerInputGroupContext =
  React.createContext<TimePickerInputGroupContextValue | null>(null);

function useTimePickerInputGroupContext(consumerName: string) {
  const context = React.useContext(TimePickerInputGroupContext);
  if (!context) {
    throw new Error(
      `\`${consumerName}\` must be used within \`${INPUT_GROUP_NAME}\``,
    );
  }
  return context;
}

function TimePickerInputGroup(props: DivProps) {
  const {
    onPointerDown: onPointerDownProp,
    onClick: onClickProp,
    asChild,
    className,
    style,
    ref,
    ...inputGroupProps
  } = props;

  const {
    inputGroupId,
    labelId,
    onInputGroupChange,
    disabled,
    readOnly,
    invalid,
    segmentPlaceholder,
    inputGroupRef,
    triggerRef,
    inputGroupClickAction,
  } = useTimePickerContext(INPUT_GROUP_NAME);

  const store = useStoreContext(INPUT_GROUP_NAME);

  const composedRef = useComposedRefs(ref, inputGroupRef, onInputGroupChange);

  const inputRefsMap = React.useRef<
    Map<Segment, React.RefObject<InputElement | null>>
  >(new Map());

  const onInputRegister = React.useCallback(
    (segment: Segment, ref: React.RefObject<InputElement | null>) => {
      inputRefsMap.current.set(segment, ref);
    },
    [],
  );

  const onInputUnregister = React.useCallback((segment: Segment) => {
    inputRefsMap.current.delete(segment);
  }, []);

  const getNextInput = React.useCallback(
    (currentSegment: Segment): React.RefObject<InputElement | null> | null => {
      const segmentOrder: Segment[] = ["hour", "minute", "second", "period"];
      const currentIndex = segmentOrder.indexOf(currentSegment);

      if (currentIndex === -1 || currentIndex === segmentOrder.length - 1) {
        return null;
      }

      for (let i = currentIndex + 1; i < segmentOrder.length; i++) {
        const nextSegment = segmentOrder[i];
        if (nextSegment) {
          const nextRef = inputRefsMap.current.get(nextSegment);
          if (nextRef?.current) {
            return nextRef;
          }
        }
      }

      return null;
    },
    [],
  );

  const onPointerDown = React.useCallback(
    (event: React.PointerEvent<InputGroupElement>) => {
      onPointerDownProp?.(event);
      if (disabled || readOnly || event.defaultPrevented) return;

      const target = event.target as HTMLElement;

      if (target.tagName === "INPUT" || target.closest("input")) {
        return;
      }

      if (triggerRef.current?.contains(target)) {
        return;
      }

      event.preventDefault();
    },
    [onPointerDownProp, disabled, readOnly, triggerRef],
  );

  const onClick = React.useCallback(
    (event: React.MouseEvent<InputGroupElement>) => {
      onClickProp?.(event);
      if (disabled || readOnly || event.defaultPrevented) return;

      const target = event.target as HTMLElement;

      if (target.tagName === "INPUT" || target.closest("input")) {
        return;
      }

      if (triggerRef.current?.contains(target)) {
        return;
      }

      if (inputGroupClickAction === "open") {
        store.setState("open", true);
      } else {
        const activeElement = document.activeElement;
        const isInputAlreadyFocused =
          activeElement &&
          activeElement.tagName === "INPUT" &&
          inputGroupRef.current?.contains(activeElement);

        if (!isInputAlreadyFocused) {
          for (const segment of SEGMENTS) {
            const inputRef = inputRefsMap.current.get(segment);
            if (inputRef?.current) {
              inputRef.current.focus();
              inputRef.current.select();
              break;
            }
          }
        }
      }
    },
    [
      onClickProp,
      disabled,
      readOnly,
      inputGroupClickAction,
      store,
      triggerRef,
      inputGroupRef,
    ],
  );

  const inputGroupContextValue =
    React.useMemo<TimePickerInputGroupContextValue>(
      () => ({
        onInputRegister,
        onInputUnregister,
        getNextInput,
      }),
      [onInputRegister, onInputUnregister, getNextInput],
    );

  const InputGroupPrimitive = asChild ? Slot : "div";

  return (
    <TimePickerInputGroupContext.Provider value={inputGroupContextValue}>
      <PopoverAnchor asChild>
        <InputGroupPrimitive
          role="group"
          id={inputGroupId}
          aria-labelledby={labelId}
          data-slot="time-picker-input-group"
          data-disabled={disabled ? "" : undefined}
          data-invalid={invalid ? "" : undefined}
          {...inputGroupProps}
          className={cn(
            "flex h-10 w-full cursor-text items-center gap-0.5 rounded-md border border-input bg-background px-3 py-2 shadow-xs outline-none transition-shadow",
            "has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50",
            invalid && "border-destructive ring-destructive/20",
            disabled && "cursor-not-allowed opacity-50",
            className,
          )}
          style={
            {
              "--time-picker-hour-input-width": `${segmentPlaceholder.hour.length}ch`,
              "--time-picker-minute-input-width": `${segmentPlaceholder.minute.length}ch`,
              "--time-picker-second-input-width": `${segmentPlaceholder.second.length}ch`,
              "--time-picker-period-input-width": `${Math.max(segmentPlaceholder.period.length, 2) + 0.5}ch`,
              ...style,
            } as React.CSSProperties
          }
          ref={composedRef}
          onPointerDown={onPointerDown}
          onClick={onClick}
        />
      </PopoverAnchor>
    </TimePickerInputGroupContext.Provider>
  );
}

interface TimePickerInputProps
  extends Omit<React.ComponentProps<"input">, "type" | "value"> {
  segment: Segment;
}

function TimePickerInput(props: TimePickerInputProps) {
  const {
    segment,
    onBlur: onBlurProp,
    onChange: onChangeProp,
    onClick: onClickProp,
    onFocus: onFocusProp,
    onKeyDown: onKeyDownProp,
    disabled: disabledProp,
    readOnly: readOnlyProp,
    className,
    style,
    ref,
    ...inputProps
  } = props;

  const {
    is12Hour,
    showSeconds,
    disabled,
    readOnly,
    segmentPlaceholder,
    openOnFocus,
  } = useTimePickerContext(INPUT_NAME);
  const store = useStoreContext(INPUT_NAME);
  const inputGroupContext = useTimePickerInputGroupContext(INPUT_NAME);

  const isDisabled = disabledProp || disabled;
  const isReadOnly = readOnlyProp || readOnly;

  const value = useStore((state) => state.value);
  const timeValue = parseTimeString(value);

  const inputRef = React.useRef<HTMLInputElement>(null);
  const composedRef = useComposedRefs(ref, inputRef);

  useIsomorphicLayoutEffect(() => {
    if (segment) {
      inputGroupContext.onInputRegister(segment as Segment, inputRef);
      return () => inputGroupContext.onInputUnregister(segment as Segment);
    }
  }, [inputGroupContext, segment]);

  const getSegmentValue = React.useCallback(() => {
    if (!timeValue) {
      if (!segment) return "";
      return segmentPlaceholder[segment];
    }
    switch (segment) {
      case "hour": {
        if (timeValue.hour === undefined) return segmentPlaceholder.hour;
        if (is12Hour) {
          return to12Hour(timeValue.hour).hour.toString().padStart(2, "0");
        }
        return timeValue.hour.toString().padStart(2, "0");
      }
      case "minute":
        if (timeValue.minute === undefined) return segmentPlaceholder.minute;
        return timeValue.minute.toString().padStart(2, "0");
      case "second":
        if (timeValue.second === undefined) return segmentPlaceholder.second;
        return timeValue.second.toString().padStart(2, "0");
      case "period": {
        if (!timeValue || timeValue.hour === undefined)
          return segmentPlaceholder.period;
        return to12Hour(timeValue.hour).period;
      }
      default:
        return "";
    }
  }, [timeValue, segment, is12Hour, segmentPlaceholder]);

  const [editValue, setEditValue] = React.useState(getSegmentValue());
  const [isEditing, setIsEditing] = React.useState(false);
  const [pendingDigit, setPendingDigit] = React.useState<string | null>(null);

  React.useEffect(() => {
    if (!isEditing) {
      setEditValue(getSegmentValue());
      setPendingDigit(null);
    }
  }, [getSegmentValue, isEditing]);

  const updateTimeValue = React.useCallback(
    (newSegmentValue: string | undefined, shouldCreateIfEmpty = false) => {
      const placeholder = segment
        ? segmentPlaceholder[segment]
        : DEFAULT_SEGMENT_PLACEHOLDER;
      if (!newSegmentValue || newSegmentValue === placeholder) return;
      if (!timeValue && !shouldCreateIfEmpty) return;

      const currentTime = timeValue ?? {};
      const newTime = { ...currentTime };

      switch (segment) {
        case "hour": {
          const displayHour = Number.parseInt(newSegmentValue, 10);
          if (!Number.isNaN(displayHour)) {
            if (is12Hour) {
              const clampedHour = clamp(displayHour, 1, 12);
              let currentPeriod: Period;
              if (timeValue?.period !== undefined) {
                currentPeriod = timeValue.period;
              } else if (timeValue?.hour !== undefined) {
                currentPeriod = to12Hour(timeValue.hour).period;
              } else {
                const now = new Date();
                currentPeriod = to12Hour(now.getHours()).period;
              }
              const hour24 = to24Hour(clampedHour, currentPeriod);
              newTime.hour = hour24;
              if (timeValue?.period !== undefined) {
                newTime.period = timeValue.period;
              }
            } else {
              newTime.hour = clamp(displayHour, 0, 23);
            }
          }
          break;
        }
        case "minute": {
          const minute = Number.parseInt(newSegmentValue, 10);
          if (!Number.isNaN(minute)) {
            newTime.minute = clamp(minute, 0, 59);
          }
          break;
        }
        case "second": {
          const second = Number.parseInt(newSegmentValue, 10);
          if (!Number.isNaN(second)) {
            newTime.second = clamp(second, 0, 59);
          }
          break;
        }
        case "period": {
          if (newSegmentValue === "AM" || newSegmentValue === "PM") {
            newTime.period = newSegmentValue;
            if (timeValue && timeValue.hour !== undefined) {
              const currentDisplay = to12Hour(timeValue.hour);
              newTime.hour = to24Hour(currentDisplay.hour, newSegmentValue);
            }
          }
          break;
        }
      }

      const newValue = formatTimeValue(newTime, showSeconds);
      store.setState("value", newValue);
    },
    [timeValue, segment, is12Hour, showSeconds, store, segmentPlaceholder],
  );

  const onBlur = React.useCallback(
    (event: React.FocusEvent<InputElement>) => {
      onBlurProp?.(event);
      if (event.defaultPrevented) return;

      setIsEditing(false);

      const placeholder = segment
        ? segmentPlaceholder[segment]
        : DEFAULT_SEGMENT_PLACEHOLDER;
      if (editValue && editValue !== placeholder && editValue.length > 0) {
        let valueToUpdate = editValue;

        if (segment !== "period") {
          if (editValue.length === 2) {
            valueToUpdate = editValue;
          } else if (editValue.length === 1) {
            const numValue = Number.parseInt(editValue, 10);
            if (!Number.isNaN(numValue)) {
              valueToUpdate = numValue.toString().padStart(2, "0");
            }
          }
        }

        updateTimeValue(valueToUpdate, true);

        queueMicrotask(() => {
          const currentTimeValue = parseTimeString(store.getState().value);
          if (currentTimeValue) {
            const now = new Date();
            const newTime = { ...currentTimeValue };
            let needsUpdate = false;

            if (newTime.hour === undefined) {
              newTime.hour = now.getHours();
              needsUpdate = true;
            }

            if (newTime.minute === undefined) {
              newTime.minute = now.getMinutes();
              needsUpdate = true;
            }

            if (showSeconds && newTime.second === undefined) {
              newTime.second = now.getSeconds();
              needsUpdate = true;
            }

            if (needsUpdate) {
              const newValue = formatTimeValue(newTime, showSeconds);
              store.setState("value", newValue);
            }
          }
        });
      }

      setEditValue(getSegmentValue());
      setPendingDigit(null);
    },
    [
      onBlurProp,
      editValue,
      updateTimeValue,
      getSegmentValue,
      segment,
      segmentPlaceholder,
      showSeconds,
      store,
    ],
  );

  const onChange = React.useCallback(
    (event: React.ChangeEvent<InputElement>) => {
      onChangeProp?.(event);
      if (event.defaultPrevented) return;

      let newValue = event.target.value;

      const placeholder = segment
        ? segmentPlaceholder[segment]
        : DEFAULT_SEGMENT_PLACEHOLDER;
      if (
        editValue === placeholder &&
        newValue.length > 0 &&
        newValue !== placeholder
      ) {
        newValue = newValue.replace(new RegExp(`^${placeholder}`), "");
      }

      if (segment === "period") {
        const firstChar = newValue.charAt(0).toUpperCase();
        let newPeriod: Period | null = null;

        if (firstChar === "A" || firstChar === "1") {
          newPeriod = "AM";
        } else if (firstChar === "P" || firstChar === "2") {
          newPeriod = "PM";
        }

        if (newPeriod) {
          setEditValue(newPeriod);
          updateTimeValue(newPeriod, true);
          queueMicrotask(() => {
            inputRef.current?.select();
          });
        }
        return;
      }

      if (segment === "hour" || segment === "minute" || segment === "second") {
        newValue = newValue.replace(/\D/g, "");
      }

      if (newValue.length > 2) {
        newValue = newValue.slice(0, 2);
      }
      if (segment === "hour" || segment === "minute" || segment === "second") {
        const numValue = Number.parseInt(newValue, 10);

        if (!Number.isNaN(numValue) && newValue.length > 0) {
          if (pendingDigit !== null && newValue.length === 1) {
            const twoDigitValue = pendingDigit + newValue;
            const combinedNum = Number.parseInt(twoDigitValue, 10);

            if (!Number.isNaN(combinedNum)) {
              const paddedValue = combinedNum.toString().padStart(2, "0");
              setEditValue(paddedValue);
              updateTimeValue(paddedValue, true);
              setPendingDigit(null);

              queueMicrotask(() => {
                if (segment) {
                  const nextInputRef = inputGroupContext.getNextInput(segment);
                  if (nextInputRef?.current) {
                    nextInputRef.current.focus();
                    nextInputRef.current.select();
                  }
                }
              });
              return;
            }
          }

          const maxFirstDigit = segment === "hour" ? (is12Hour ? 1 : 2) : 5;

          const firstDigit = Number.parseInt(newValue[0] ?? "0", 10);
          const shouldAutoAdvance = firstDigit > maxFirstDigit;

          if (newValue.length === 1) {
            if (shouldAutoAdvance) {
              const paddedValue = numValue.toString().padStart(2, "0");
              setEditValue(paddedValue);
              updateTimeValue(paddedValue, true);
              setPendingDigit(null);

              queueMicrotask(() => {
                if (segment) {
                  const nextInputRef = inputGroupContext.getNextInput(segment);
                  if (nextInputRef?.current) {
                    nextInputRef.current.focus();
                    nextInputRef.current.select();
                  }
                }
              });
            } else {
              const paddedValue = numValue.toString().padStart(2, "0");
              setEditValue(paddedValue);
              setPendingDigit(newValue);
              queueMicrotask(() => {
                inputRef.current?.select();
              });
            }
          } else if (newValue.length === 2) {
            const paddedValue = numValue.toString().padStart(2, "0");
            setEditValue(paddedValue);
            updateTimeValue(paddedValue, true);
            setPendingDigit(null);

            queueMicrotask(() => {
              if (segment) {
                const nextInputRef = inputGroupContext.getNextInput(segment);
                if (nextInputRef?.current) {
                  nextInputRef.current.focus();
                  nextInputRef.current.select();
                }
              }
            });
          }
        } else if (newValue.length === 0) {
          setEditValue("");
          setPendingDigit(null);
        }
      }
    },
    [
      segment,
      updateTimeValue,
      onChangeProp,
      editValue,
      is12Hour,
      inputGroupContext,
      pendingDigit,
      segmentPlaceholder,
    ],
  );

  const onClick = React.useCallback(
    (event: React.MouseEvent<InputElement>) => {
      onClickProp?.(event);
      if (event.defaultPrevented) return;

      event.currentTarget.select();
    },
    [onClickProp],
  );

  const onFocus = React.useCallback(
    (event: React.FocusEvent<InputElement>) => {
      onFocusProp?.(event);
      if (event.defaultPrevented) return;

      setIsEditing(true);
      setPendingDigit(null);

      if (openOnFocus && !store.getState().open) {
        store.setState("openedViaFocus", true);
        store.setState("open", true);
      }

      queueMicrotask(() => event.target.select());
    },
    [onFocusProp, openOnFocus, store],
  );

  const onKeyDown = React.useCallback(
    (event: React.KeyboardEvent<InputElement>) => {
      onKeyDownProp?.(event);
      if (event.defaultPrevented) return;

      if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
        event.preventDefault();

        const goToPrevious = event.key === "ArrowLeft";
        const inputGroup = inputRef.current?.closest(
          '[data-slot="time-picker-input-group"]',
        );

        if (inputGroup && inputRef.current) {
          const allInputs = Array.from(
            inputGroup.querySelectorAll('input[type="text"]'),
          ) as HTMLInputElement[];
          const currentIdx = allInputs.indexOf(inputRef.current);

          if (currentIdx !== -1) {
            const targetIdx = goToPrevious
              ? Math.max(0, currentIdx - 1)
              : Math.min(allInputs.length - 1, currentIdx + 1);

            const targetInput = allInputs[targetIdx];
            if (targetInput && targetInput !== inputRef.current) {
              targetInput.focus();
              targetInput.select();
            }
          }
        }
        return;
      }

      if (event.key === "Backspace" || event.key === "Delete") {
        const input = inputRef.current;
        if (
          input &&
          input.selectionStart === 0 &&
          input.selectionEnd === input.value.length
        ) {
          event.preventDefault();
          const placeholder = segment
            ? segmentPlaceholder[segment]
            : DEFAULT_SEGMENT_PLACEHOLDER;
          setEditValue(placeholder);
          setPendingDigit(null);

          if (timeValue) {
            const newTime = { ...timeValue };
            switch (segment) {
              case "hour":
                delete newTime.hour;
                break;
              case "minute":
                delete newTime.minute;
                break;
              case "second":
                delete newTime.second;
                break;
              case "period":
                delete newTime.period;
                break;
            }

            if (
              newTime.hour !== undefined ||
              newTime.minute !== undefined ||
              newTime.second !== undefined ||
              newTime.period !== undefined
            ) {
              const newValue = formatTimeValue(newTime, showSeconds);
              store.setState("value", newValue);
            } else {
              store.setState("value", "");
            }
          } else {
            store.setState("value", "");
          }

          queueMicrotask(() => {
            inputRef.current?.select();
          });
          return;
        }
      }

      if (segment === "period") {
        const key = event.key.toLowerCase();
        if (key === "a" || key === "p" || key === "1" || key === "2") {
          event.preventDefault();
          let newPeriod: Period;
          if (key === "a" || key === "1") {
            newPeriod = "AM";
          } else {
            newPeriod = "PM";
          }
          setEditValue(newPeriod);
          updateTimeValue(newPeriod, true);
          queueMicrotask(() => {
            inputRef.current?.select();
          });
        } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
          event.preventDefault();
          const placeholder = segmentPlaceholder.period;
          const currentPeriod =
            editValue === placeholder || editValue === "" ? "AM" : editValue;
          const newPeriod =
            currentPeriod === "AM" || currentPeriod === "A" ? "PM" : "AM";
          setEditValue(newPeriod);
          updateTimeValue(newPeriod, true);
          queueMicrotask(() => {
            inputRef.current?.select();
          });
        }
        return;
      }

      if (event.key === "Tab") {
        const placeholder = segment
          ? segmentPlaceholder[segment]
          : DEFAULT_SEGMENT_PLACEHOLDER;
        if (editValue && editValue.length > 0 && editValue !== placeholder) {
          if (editValue.length === 2) {
            updateTimeValue(editValue, true);
          } else if (editValue.length === 1) {
            const numValue = Number.parseInt(editValue, 10);
            if (!Number.isNaN(numValue)) {
              const paddedValue = numValue.toString().padStart(2, "0");
              updateTimeValue(paddedValue, true);
            }
          }
        }
        return;
      }

      if (event.key === "Enter") {
        event.preventDefault();
        const placeholder = segment
          ? segmentPlaceholder[segment]
          : DEFAULT_SEGMENT_PLACEHOLDER;
        if (editValue && editValue.length > 0 && editValue !== placeholder) {
          if (editValue.length === 2) {
            updateTimeValue(editValue, true);
          } else if (editValue.length === 1) {
            const numValue = Number.parseInt(editValue, 10);
            if (!Number.isNaN(numValue)) {
              const paddedValue = numValue.toString().padStart(2, "0");
              updateTimeValue(paddedValue, true);
            }
          }
        }
        queueMicrotask(() => {
          inputRef.current?.select();
        });
      }

      if (event.key === "Escape") {
        event.preventDefault();
        setEditValue(getSegmentValue());
        inputRef.current?.blur();
      }

      if (event.key === "ArrowUp") {
        event.preventDefault();
        const placeholder = segment
          ? segmentPlaceholder[segment]
          : DEFAULT_SEGMENT_PLACEHOLDER;
        if (editValue === placeholder || editValue === "") {
          const defaultValue = segment === "hour" ? (is12Hour ? 12 : 0) : 0;
          const formattedValue = defaultValue.toString().padStart(2, "0");
          setEditValue(formattedValue);
          updateTimeValue(formattedValue, true);
          queueMicrotask(() => {
            inputRef.current?.select();
          });
          return;
        }
        const currentValue = Number.parseInt(editValue, 10);
        if (!Number.isNaN(currentValue)) {
          let newValue: number;
          switch (segment) {
            case "hour":
              if (is12Hour) {
                newValue = currentValue === 12 ? 1 : currentValue + 1;
              } else {
                newValue = currentValue === 23 ? 0 : currentValue + 1;
              }
              break;
            case "minute":
            case "second":
              newValue = currentValue === 59 ? 0 : currentValue + 1;
              break;
            default:
              return;
          }
          const formattedValue = newValue.toString().padStart(2, "0");
          setEditValue(formattedValue);
          updateTimeValue(formattedValue, true);
          queueMicrotask(() => {
            inputRef.current?.select();
          });
        }
      }

      if (event.key === "ArrowDown") {
        event.preventDefault();
        const placeholder = segment
          ? segmentPlaceholder[segment]
          : DEFAULT_SEGMENT_PLACEHOLDER;
        if (editValue === placeholder || editValue === "") {
          const defaultValue = segment === "hour" ? (is12Hour ? 12 : 23) : 59;
          const formattedValue = defaultValue.toString().padStart(2, "0");
          setEditValue(formattedValue);
          updateTimeValue(formattedValue, true);
          queueMicrotask(() => {
            inputRef.current?.select();
          });
          return;
        }
        const currentValue = Number.parseInt(editValue, 10);
        if (!Number.isNaN(currentValue)) {
          let newValue: number;
          switch (segment) {
            case "hour":
              if (is12Hour) {
                newValue = currentValue === 1 ? 12 : currentValue - 1;
              } else {
                newValue = currentValue === 0 ? 23 : currentValue - 1;
              }
              break;
            case "minute":
            case "second":
              newValue = currentValue === 0 ? 59 : currentValue - 1;
              break;
            default:
              return;
          }
          const formattedValue = newValue.toString().padStart(2, "0");
          setEditValue(formattedValue);
          updateTimeValue(formattedValue, true);
          queueMicrotask(() => {
            inputRef.current?.select();
          });
        }
      }
    },
    [
      onKeyDownProp,
      editValue,
      segment,
      is12Hour,
      getSegmentValue,
      updateTimeValue,
      showSeconds,
      timeValue,
      store,
      segmentPlaceholder,
    ],
  );

  const displayValue = isEditing ? editValue : getSegmentValue();

  const segmentWidth = segment
    ? `var(--time-picker-${segment}-input-width)`
    : "2ch";

  return (
    <input
      type="text"
      inputMode={segment === "period" ? "text" : "numeric"}
      autoComplete="off"
      autoCorrect="off"
      autoCapitalize="off"
      spellCheck={false}
      translate="no"
      {...inputProps}
      disabled={isDisabled}
      readOnly={isReadOnly}
      className={cn(
        "inline-flex h-full items-center justify-center border-0 bg-transparent text-center text-sm tabular-nums outline-none transition-colors focus:bg-transparent disabled:cursor-not-allowed disabled:opacity-50",
        className,
      )}
      style={{ width: segmentWidth, ...style }}
      ref={composedRef}
      value={displayValue}
      onBlur={onBlur}
      onChange={onChange}
      onClick={onClick}
      onFocus={onFocus}
      onKeyDown={onKeyDown}
    />
  );
}

function TimePickerTrigger(props: ButtonProps) {
  const {
    className,
    children,
    disabled: disabledProp,
    ref,
    ...triggerProps
  } = props;

  const { triggerId, disabled, triggerRef } =
    useTimePickerContext(TRIGGER_NAME);

  const isDisabled = disabledProp || disabled;

  const composedRef = useComposedRefs(ref, triggerRef);

  return (
    <PopoverTrigger
      type="button"
      id={triggerId}
      data-slot="time-picker-trigger"
      disabled={isDisabled}
      ref={composedRef}
      {...triggerProps}
      className={cn(
        "ml-auto flex items-center text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none [&>svg:not([class*='size-'])]:size-4",
        className,
      )}
    >
      {children ?? <Clock />}
    </PopoverTrigger>
  );
}

interface TimePickerGroupContextValue {
  getColumns: () => ColumnData[];
  onColumnRegister: (column: ColumnData) => void;
  onColumnUnregister: (id: string) => void;
}

const TimePickerGroupContext =
  React.createContext<TimePickerGroupContextValue | null>(null);

function useTimePickerGroupContext(consumerName: string) {
  const context = React.useContext(TimePickerGroupContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}

interface TimePickerContentProps
  extends DivProps,
    React.ComponentProps<typeof PopoverContent> {}

function TimePickerContent(props: TimePickerContentProps) {
  const {
    side = "bottom",
    align = "start",
    sideOffset = 6,
    className,
    onOpenAutoFocus: onOpenAutoFocusProp,
    onInteractOutside: onInteractOutsideProp,
    ...contentProps
  } = props;

  const store = useStoreContext(CONTENT_NAME);
  const { openOnFocus, inputGroupRef } = useTimePickerContext(CONTENT_NAME);
  const columnsRef = React.useRef<Map<string, Omit<ColumnData, "id">>>(
    new Map(),
  );

  const onColumnRegister = React.useCallback((column: ColumnData) => {
    columnsRef.current.set(column.id, column);
  }, []);

  const onColumnUnregister = React.useCallback((id: string) => {
    columnsRef.current.delete(id);
  }, []);

  const getColumns = React.useCallback(() => {
    const columns = Array.from(columnsRef.current.entries())
      .map(([id, { ref, getSelectedItemRef, getItems }]) => ({
        id,
        ref,
        getSelectedItemRef,
        getItems,
      }))
      .filter((c) => c.ref.current !== null);
    return sortNodes(columns);
  }, []);

  const groupContextValue = React.useMemo<TimePickerGroupContextValue>(
    () => ({
      getColumns,
      onColumnRegister,
      onColumnUnregister,
    }),
    [getColumns, onColumnRegister, onColumnUnregister],
  );

  const onOpenAutoFocus: NonNullable<PopoverContentProps["onOpenAutoFocus"]> =
    React.useCallback(
      (event) => {
        onOpenAutoFocusProp?.(event);
        if (event.defaultPrevented) return;

        event.preventDefault();

        const { openedViaFocus } = store.getState();

        if (openedViaFocus) {
          store.setState("openedViaFocus", false);
          return;
        }

        const columns = getColumns();
        const firstColumn = columns[0];

        if (!firstColumn) return;

        const items = firstColumn.getItems();
        const selectedItem = items.find((item) => item.selected);

        const candidateRefs = selectedItem
          ? [selectedItem.ref, ...items.map((item) => item.ref)]
          : items.map((item) => item.ref);

        focusFirst(candidateRefs, false);
      },
      [onOpenAutoFocusProp, getColumns, store],
    );

  const onInteractOutside: NonNullable<
    PopoverContentProps["onInteractOutside"]
  > = React.useCallback(
    (event) => {
      onInteractOutsideProp?.(event);
      if (event.defaultPrevented) return;

      if (openOnFocus && inputGroupRef.current) {
        const target = event.target;
        if (!(target instanceof Node)) return;
        const isInsideInputGroup = inputGroupRef.current.contains(target);

        if (isInsideInputGroup) {
          event.preventDefault();
        }
      }
    },
    [onInteractOutsideProp, openOnFocus, inputGroupRef],
  );

  return (
    <TimePickerGroupContext.Provider value={groupContextValue}>
      <PopoverContent
        data-slot="time-picker-content"
        side={side}
        align={align}
        sideOffset={sideOffset}
        {...contentProps}
        className={cn(
          "flex w-auto max-w-(--radix-popover-trigger-width) p-0",
          className,
        )}
        onOpenAutoFocus={onOpenAutoFocus}
        onInteractOutside={onInteractOutside}
      />
    </TimePickerGroupContext.Provider>
  );
}

interface TimePickerColumnContextValue {
  getItems: () => ItemData[];
  onItemRegister: (
    value: number | string,
    ref: React.RefObject<ColumnItemElement | null>,
    selected: boolean,
  ) => void;
  onItemUnregister: (value: number | string) => void;
}

const TimePickerColumnContext =
  React.createContext<TimePickerColumnContextValue | null>(null);

function useTimePickerColumnContext(consumerName: string) {
  const context = React.useContext(TimePickerColumnContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within a column`);
  }
  return context;
}

interface TimePickerColumnProps extends DivProps {}

function TimePickerColumn(props: TimePickerColumnProps) {
  const { children, className, ref, ...columnProps } = props;

  const columnId = React.useId();
  const columnRef = React.useRef<ColumnElement | null>(null);
  const composedRef = useComposedRefs(ref, columnRef);

  const itemsRef = React.useRef<
    Map<
      number | string,
      {
        ref: React.RefObject<ColumnItemElement | null>;
        selected: boolean;
      }
    >
  >(new Map());

  const groupContext = useTimePickerGroupContext(COLUMN_NAME);

  const onItemRegister = React.useCallback(
    (
      value: number | string,
      ref: React.RefObject<HTMLButtonElement | null>,
      selected: boolean,
    ) => {
      itemsRef.current.set(value, { ref, selected });
    },
    [],
  );

  const onItemUnregister = React.useCallback((value: number | string) => {
    itemsRef.current.delete(value);
  }, []);

  const getItems = React.useCallback(() => {
    const items = Array.from(itemsRef.current.entries())
      .map(([value, { ref, selected }]) => ({
        value,
        ref,
        selected,
      }))
      .filter((item) => item.ref.current);
    return sortNodes(items);
  }, []);

  const getSelectedItemRef = React.useCallback(() => {
    const items = getItems();
    return items.find((item) => item.selected)?.ref ?? null;
  }, [getItems]);

  useIsomorphicLayoutEffect(() => {
    groupContext.onColumnRegister({
      id: columnId,
      ref: columnRef,
      getSelectedItemRef,
      getItems,
    });
    return () => groupContext.onColumnUnregister(columnId);
  }, [groupContext, columnId, getSelectedItemRef, getItems]);

  const columnContextValue = React.useMemo<TimePickerColumnContextValue>(
    () => ({
      getItems,
      onItemRegister,
      onItemUnregister,
    }),
    [getItems, onItemRegister, onItemUnregister],
  );

  return (
    <TimePickerColumnContext.Provider value={columnContextValue}>
      <div
        ref={composedRef}
        data-slot="time-picker-column"
        {...columnProps}
        className={cn("flex flex-col gap-1 not-last:border-r p-1", className)}
      >
        {children}
      </div>
    </TimePickerColumnContext.Provider>
  );
}

interface TimePickerColumnItemProps extends ButtonProps {
  value: number | string;
  selected?: boolean;
  format?: SegmentFormat;
}

function TimePickerColumnItem(props: TimePickerColumnItemProps) {
  const {
    value,
    selected = false,
    format = "numeric",
    className,
    ref,
    ...itemProps
  } = props;

  const itemRef = React.useRef<ColumnItemElement | null>(null);
  const composedRef = useComposedRefs(ref, itemRef);
  const columnContext = useTimePickerColumnContext(COLUMN_ITEM_NAME);
  const groupContext = useTimePickerGroupContext(COLUMN_ITEM_NAME);

  useIsomorphicLayoutEffect(() => {
    columnContext.onItemRegister(value, itemRef, selected);
    return () => columnContext.onItemUnregister(value);
  }, [columnContext, value, selected]);

  useIsomorphicLayoutEffect(() => {
    if (selected && itemRef.current) {
      itemRef.current.scrollIntoView({ block: "nearest" });
    }
  }, [selected]);

  const onClick = React.useCallback(
    (event: React.MouseEvent<ColumnItemElement>) => {
      itemProps.onClick?.(event);
      if (event.defaultPrevented) return;

      itemRef.current?.focus();
    },
    [itemProps.onClick],
  );

  const onKeyDown = React.useCallback(
    (event: React.KeyboardEvent<ColumnItemElement>) => {
      itemProps.onKeyDown?.(event);
      if (event.defaultPrevented) return;

      if (event.key === "ArrowUp" || event.key === "ArrowDown") {
        event.preventDefault();
        const items = columnContext.getItems().sort((a, b) => {
          if (typeof a.value === "number" && typeof b.value === "number") {
            return a.value - b.value;
          }
          return 0;
        });
        const currentIndex = items.findIndex((item) => item.value === value);

        let nextIndex: number;
        if (event.key === "ArrowUp") {
          nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
        } else {
          nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
        }

        const nextItem = items[nextIndex];
        nextItem?.ref.current?.focus();
        nextItem?.ref.current?.click();
      } else if (
        (event.key === "Tab" ||
          event.key === "ArrowLeft" ||
          event.key === "ArrowRight") &&
        groupContext
      ) {
        event.preventDefault();

        queueMicrotask(() => {
          const columns = groupContext.getColumns();

          if (columns.length === 0) return;

          const currentColumnIndex = columns.findIndex(
            (c) => c.ref.current?.contains(itemRef.current) ?? false,
          );

          if (currentColumnIndex === -1) return;

          const goToPrevious =
            event.key === "ArrowLeft" ||
            (event.key === "Tab" && event.shiftKey);

          const nextColumnIndex = goToPrevious
            ? currentColumnIndex > 0
              ? currentColumnIndex - 1
              : columns.length - 1
            : currentColumnIndex < columns.length - 1
              ? currentColumnIndex + 1
              : 0;

          const nextColumn = columns[nextColumnIndex];
          if (nextColumn?.ref.current) {
            const items = nextColumn.getItems();
            const selectedItem = items.find((item) => item.selected);

            const candidateRefs = selectedItem
              ? [selectedItem.ref, ...items.map((item) => item.ref)]
              : items.map((item) => item.ref);

            focusFirst(candidateRefs, false);
          }
        });
      }
    },
    [itemProps.onKeyDown, columnContext, groupContext, value],
  );

  const formattedValue =
    typeof value === "number" && format === "2-digit"
      ? value.toString().padStart(2, "0")
      : value.toString();

  return (
    <button
      type="button"
      {...itemProps}
      ref={composedRef}
      data-selected={selected ? "" : undefined}
      className={cn(
        "w-full rounded px-3 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus:border-ring focus:outline-none focus:ring-[3px] focus:ring-ring/50",
        "data-selected:bg-primary data-selected:text-primary-foreground data-selected:hover:bg-primary data-selected:hover:text-primary-foreground",
        className,
      )}
      onClick={onClick}
      onKeyDown={onKeyDown}
    >
      {formattedValue}
    </button>
  );
}

interface TimePickerHourProps extends DivProps {
  format?: SegmentFormat;
}

function TimePickerHour(props: TimePickerHourProps) {
  const { asChild, format = "numeric", className, ...hourProps } = props;

  const { is12Hour, hourStep, showSeconds } = useTimePickerContext(HOUR_NAME);
  const store = useStoreContext(HOUR_NAME);

  const value = useStore((state) => state.value);
  const timeValue = parseTimeString(value);

  const hours = Array.from(
    {
      length: is12Hour ? Math.ceil(12 / hourStep) : Math.ceil(24 / hourStep),
    },
    (_, i) => {
      if (is12Hour) {
        const hour = (i * hourStep) % 12;
        return hour === 0 ? 12 : hour;
      }
      return i * hourStep;
    },
  );

  const onHourSelect = React.useCallback(
    (displayHour: number) => {
      const now = new Date();
      const currentTime = timeValue ?? {};

      let hour24 = displayHour;
      if (is12Hour) {
        let currentPeriod: Period;
        if (timeValue?.period !== undefined) {
          currentPeriod = timeValue.period;
        } else if (timeValue?.hour !== undefined) {
          currentPeriod = to12Hour(timeValue.hour).period;
        } else {
          currentPeriod = to12Hour(now.getHours()).period;
        }
        hour24 = to24Hour(displayHour, currentPeriod);
      }

      const newTime = { ...currentTime, hour: hour24 };
      if (timeValue && timeValue.period !== undefined) {
        newTime.period = timeValue.period;
      }

      if (newTime.minute === undefined) {
        newTime.minute = now.getMinutes();
      }

      if (showSeconds && newTime.second === undefined) {
        newTime.second = now.getSeconds();
      }

      const newValue = formatTimeValue(newTime, showSeconds);
      store.setState("value", newValue);
    },
    [timeValue, showSeconds, is12Hour, store],
  );

  const now = new Date();
  const referenceHour = timeValue?.hour ?? now.getHours();
  const displayHour = is12Hour ? to12Hour(referenceHour).hour : referenceHour;

  const HourPrimitive = asChild ? Slot : TimePickerColumn;

  return (
    <HourPrimitive
      data-slot="time-picker-hour"
      {...hourProps}
      className={cn(
        "scrollbar-none flex max-h-[200px] flex-col gap-1 overflow-y-auto p-1",
        className,
      )}
    >
      {hours.map((hour) => (
        <TimePickerColumnItem
          key={hour}
          value={hour}
          selected={displayHour === hour}
          format={format}
          onClick={() => onHourSelect(hour)}
        />
      ))}
    </HourPrimitive>
  );
}

interface TimePickerMinuteProps extends DivProps {
  format?: SegmentFormat;
}

function TimePickerMinute(props: TimePickerMinuteProps) {
  const { asChild, format = "2-digit", className, ...minuteProps } = props;

  const { minuteStep, showSeconds } = useTimePickerContext(MINUTE_NAME);
  const store = useStoreContext(MINUTE_NAME);

  const value = useStore((state) => state.value);
  const timeValue = parseTimeString(value);

  const minutes = Array.from(
    { length: Math.ceil(60 / minuteStep) },
    (_, i) => i * minuteStep,
  );

  const onMinuteSelect = React.useCallback(
    (minute: number) => {
      const now = new Date();
      const currentTime = timeValue ?? {};
      const newTime = { ...currentTime, minute };

      if (newTime.hour === undefined) {
        newTime.hour = now.getHours();
      }

      if (showSeconds && newTime.second === undefined) {
        newTime.second = now.getSeconds();
      }

      const newValue = formatTimeValue(newTime, showSeconds);
      store.setState("value", newValue);
    },
    [timeValue, showSeconds, store],
  );

  const MinutePrimitive = asChild ? Slot : TimePickerColumn;

  const now = new Date();
  const referenceMinute = timeValue?.minute ?? now.getMinutes();

  return (
    <MinutePrimitive
      data-slot="time-picker-minute"
      {...minuteProps}
      className={cn(
        "scrollbar-none flex max-h-[200px] flex-col gap-1 overflow-y-auto p-1",
        className,
      )}
    >
      {minutes.map((minute) => (
        <TimePickerColumnItem
          key={minute}
          value={minute}
          selected={referenceMinute === minute}
          format={format}
          onClick={() => onMinuteSelect(minute)}
        />
      ))}
    </MinutePrimitive>
  );
}

interface TimePickerSecondProps extends DivProps {
  format?: SegmentFormat;
}

function TimePickerSecond(props: TimePickerSecondProps) {
  const { asChild, format = "2-digit", className, ...secondProps } = props;

  const { secondStep } = useTimePickerContext(SECOND_NAME);
  const store = useStoreContext(SECOND_NAME);

  const value = useStore((state) => state.value);
  const timeValue = parseTimeString(value);

  const seconds = Array.from(
    { length: Math.ceil(60 / secondStep) },
    (_, i) => i * secondStep,
  );

  const onSecondSelect = React.useCallback(
    (second: number) => {
      const now = new Date();
      const currentTime = timeValue ?? {};
      const newTime = { ...currentTime, second };

      if (newTime.hour === undefined) {
        newTime.hour = now.getHours();
      }

      if (newTime.minute === undefined) {
        newTime.minute = now.getMinutes();
      }

      const newValue = formatTimeValue(newTime, true);
      store.setState("value", newValue);
    },
    [timeValue, store],
  );

  const SecondPrimitive = asChild ? Slot : TimePickerColumn;

  const now = new Date();
  const referenceSecond = timeValue?.second ?? now.getSeconds();

  return (
    <SecondPrimitive
      data-slot="time-picker-second"
      {...secondProps}
      className={cn(
        "scrollbar-none flex max-h-[200px] flex-col gap-1 overflow-y-auto p-1",
        className,
      )}
    >
      {seconds.map((second) => (
        <TimePickerColumnItem
          key={second}
          value={second}
          selected={referenceSecond === second}
          format={format}
          onClick={() => onSecondSelect(second)}
        />
      ))}
    </SecondPrimitive>
  );
}

function TimePickerPeriod(props: DivProps) {
  const { asChild, className, ...periodProps } = props;

  const { is12Hour, showSeconds } = useTimePickerContext(PERIOD_NAME);
  const store = useStoreContext(PERIOD_NAME);

  const value = useStore((state) => state.value);
  const timeValue = parseTimeString(value);

  const onPeriodToggle = React.useCallback(
    (period: Period) => {
      const now = new Date();
      const currentTime = timeValue ?? {};

      const currentHour =
        currentTime.hour !== undefined ? currentTime.hour : now.getHours();
      const currentDisplay = to12Hour(currentHour);
      const new24Hour = to24Hour(currentDisplay.hour, period);

      const newTime = { ...currentTime, hour: new24Hour };

      if (newTime.minute === undefined) {
        newTime.minute = now.getMinutes();
      }

      if (showSeconds && newTime.second === undefined) {
        newTime.second = now.getSeconds();
      }

      const newValue = formatTimeValue(newTime, showSeconds);
      store.setState("value", newValue);
    },
    [timeValue, showSeconds, store],
  );

  if (!is12Hour) return null;

  const now = new Date();
  const referenceHour = timeValue?.hour ?? now.getHours();
  const currentPeriod = to12Hour(referenceHour).period;

  const PeriodPrimitive = asChild ? Slot : TimePickerColumn;

  return (
    <PeriodPrimitive
      data-slot="time-picker-period"
      {...periodProps}
      className={cn("flex flex-col gap-1 p-1", className)}
    >
      {PERIODS.map((period) => (
        <TimePickerColumnItem
          key={period}
          value={period}
          selected={currentPeriod === period}
          onClick={() => onPeriodToggle(period)}
        />
      ))}
    </PeriodPrimitive>
  );
}

interface TimePickerSeparatorProps extends React.ComponentProps<"span"> {
  asChild?: boolean;
}

function TimePickerSeparator(props: TimePickerSeparatorProps) {
  const { asChild, children, ...separatorProps } = props;

  const SeparatorPrimitive = asChild ? Slot : "span";

  return (
    <SeparatorPrimitive
      aria-hidden="true"
      data-slot="time-picker-separator"
      {...separatorProps}
    >
      {children ?? ":"}
    </SeparatorPrimitive>
  );
}

function TimePickerClear(props: ButtonProps) {
  const {
    asChild,
    className,
    children,
    disabled: disabledProp,
    ...clearProps
  } = props;

  const { disabled, readOnly } = useTimePickerContext(CLEAR_NAME);
  const store = useStoreContext(CLEAR_NAME);

  const isDisabled = disabledProp || disabled;

  const onClick = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      clearProps.onClick?.(event);
      if (event.defaultPrevented) return;

      event.preventDefault();
      if (disabled || readOnly) return;
      store.setState("value", "");
    },
    [clearProps.onClick, disabled, readOnly, store],
  );

  const ClearPrimitive = asChild ? Slot : "button";

  return (
    <ClearPrimitive
      type="button"
      data-slot="time-picker-clear"
      disabled={isDisabled}
      {...clearProps}
      className={cn(
        "inline-flex items-center justify-center rounded-sm font-medium text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50",
        className,
      )}
      onClick={onClick}
    >
      {children ?? "Clear"}
    </ClearPrimitive>
  );
}

export {
  TimePicker,
  TimePickerLabel,
  TimePickerInputGroup,
  TimePickerInput,
  TimePickerTrigger,
  TimePickerContent,
  TimePickerHour,
  TimePickerMinute,
  TimePickerSecond,
  TimePickerPeriod,
  TimePickerSeparator,
  TimePickerClear,
  //
  useStore as useTimePicker,
  //
  type TimePickerProps,
};

Installation

npx shadcn@latest add @diceui/time-picker

Usage

import { TimePicker } from "@/components/ui/time-picker"
<TimePicker />