"use client";
import * as React from "react";
export type ClockHourCycle = "auto" | "12" | "24";
type Unit = "hour" | "minute";
export interface ClockPickerProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue" | "children"> {
/** Controlled time, 24-hour "HH:mm". */
value?: string;
/** Initial time when uncontrolled, 24-hour "HH:mm". */
defaultValue?: string;
/** Called with the new 24-hour "HH:mm" after every change. */
onChange?: (value: string) => void;
/** 12-hour dial with AM / PM, 24-hour dial with an inner ring, or whatever the locale prefers. */
hourCycle?: ClockHourCycle;
/** Minute snapping on the dial and keyboard step (a divisor of 60: 1, 5, 15…). */
minuteStep?: number;
/** The clock face on the page, or a field button that opens it in a native popover. */
variant?: "inline" | "popover";
/** BCP 47 locale for the formatted time, AM / PM labels and their order. */
locale?: string;
/** Visible label. */
label?: string;
/** Switch to minutes after an hour is picked on the dial. */
autoAdvance?: boolean;
/** Dial diameter in px. */
size?: number;
/** Hand, selection and focus colour. */
accent?: string;
disabled?: boolean;
/** When set, a hidden input submits "HH:mm" under this name. */
name?: string;
}
/* --------------------------------- helpers --------------------------------- */
const pad = (n: number) => String(n).padStart(2, "0");
const mod = (n: number, m: number) => ((n % m) + m) % m;
const round2 = (n: number) => Math.round(n * 100) / 100;
function parseTime(value: string | undefined): { h: number; m: number } {
const match = /^(\d{1,2}):(\d{2})$/.exec(value ?? "");
if (!match) return { h: 9, m: 0 };
return { h: Math.min(23, Number(match[1])), m: Math.min(59, Number(match[2])) };
}
const toValue = (h: number, m: number) => `${pad(h)}:${pad(m)}`;
/** Largest divisor of 60 that is ≤ the requested step, so snapping always wraps cleanly. */
function cleanStep(step: number) {
const s = Math.max(1, Math.min(30, Math.round(step)));
for (let d = s; d > 1; d--) if (60 % d === 0) return d;
return 1;
}
/** Collapse exotic spaces so server and browser ICU builds produce the same text. */
const tidy = (s: string) => s.replace(/[ ]/g, " ");
function localeInfo(locale: string, cycle: ClockHourCycle) {
let resolved: Intl.DateTimeFormat;
try {
resolved = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
} catch {
resolved = new Intl.DateTimeFormat("en", { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
}
const lc = resolved.resolvedOptions().locale;
const prefers12 = ["h11", "h12"].includes(resolved.resolvedOptions().hourCycle ?? "h23");
const twelve = cycle === "auto" ? prefers12 : cycle === "12";
const fmt = new Intl.DateTimeFormat(lc, { hour: "numeric", minute: "2-digit", hourCycle: twelve ? "h12" : "h23", timeZone: "UTC" });
const at = (h: number, m: number) => new Date(Date.UTC(2000, 0, 1, h, m));
const period = (h: number) =>
tidy(new Intl.DateTimeFormat(lc, { hour: "numeric", hourCycle: "h12", timeZone: "UTC" }).formatToParts(at(h, 0)).find((p) => p.type === "dayPeriod")?.value ?? (h < 12 ? "AM" : "PM"));
const parts = fmt.formatToParts(at(9, 30));
const periodFirst = parts.findIndex((p) => p.type === "dayPeriod") > -1 && parts.findIndex((p) => p.type === "dayPeriod") < parts.findIndex((p) => p.type === "hour");
return {
twelve,
format: (h: number, m: number) => tidy(fmt.format(at(h, m))),
am: period(9),
pm: period(21),
periodFirst,
};
}
/* ------------------------------- dial geometry ------------------------------ */
interface DialItem {
value: number;
label: string;
angle: number;
inner: boolean;
}
function hourItems(twelve: boolean): DialItem[] {
const items: DialItem[] = [];
for (let i = 0; i < 12; i++) {
// Outer ring 12, 1…11 (12-hour labels or noon / morning hours); inner ring 00, 13…23.
items.push({ value: i === 0 ? 12 : i, label: String(i === 0 ? 12 : i), angle: i * 30, inner: false });
if (!twelve) items.push({ value: i === 0 ? 0 : i + 12, label: i === 0 ? "00" : String(i + 12), angle: i * 30, inner: true });
}
return items;
}
function minuteItems(): DialItem[] {
return Array.from({ length: 12 }, (_, i) => ({ value: i * 5, label: pad(i * 5), angle: i * 30, inner: false }));
}
/* -------------------------------- component -------------------------------- */
/**
* An analog time picker: pick the hour on a clock face (12-hour with AM / PM, or 24-hour with an
* inner ring), then the minute, by tapping or dragging the hand with snapping. The dial is an ARIA
* slider, the digital readout is editable, and a popover variant uses the native popover API.
*/
export function ClockPicker({
value,
defaultValue = "09:30",
onChange,
hourCycle = "auto",
minuteStep = 5,
variant = "inline",
locale = "en-US",
label = "Time",
autoAdvance = true,
size = 232,
accent = "#FF7448",
disabled = false,
name,
className = "",
style,
...props
}: ClockPickerProps) {
const uid = React.useId();
const id = `cp-${uid}`;
const labelId = `${id}-label`;
const panelId = `${id}-panel`;
const popoverId = `${id}-popover`;
const step = cleanStep(minuteStep);
const [inner, setInner] = React.useState(defaultValue);
const current = value ?? inner;
const { h, m } = parseTime(current);
const info = localeInfo(locale, hourCycle);
const isPm = h >= 12;
const [unit, setUnit] = React.useState<Unit>("hour");
const [dragging, setDragging] = React.useState(false);
const [drafts, setDrafts] = React.useState<{ hour: string | null; minute: string | null }>({ hour: null, minute: null });
const [open, setOpen] = React.useState(false);
const dialRef = React.useRef<HTMLDivElement>(null);
const ringRef = React.useRef<HTMLDivElement>(null);
const minuteInputRef = React.useRef<HTMLInputElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const popoverRef = React.useRef<HTMLDivElement>(null);
const prevUnit = React.useRef<Unit>(unit);
const setTime = (nh: number, nm: number) => {
const next = toValue(mod(nh, 24), mod(nm, 60));
if (next === current) return;
if (value === undefined) setInner(next);
onChange?.(next);
};
/* ------------------------------ hand rotation ------------------------------ */
const targetAngle = unit === "hour" ? mod(h, 12) * 30 : m * 6;
const handInner = unit === "hour" && !info.twelve && (h === 0 || h > 12);
// Unwrapped angle: always turn the short way (59 → 00 moves forward, not backwards round the dial).
const [hand, setHand] = React.useState({ target: targetAngle, angle: targetAngle });
if (hand.target !== targetAngle) {
const delta = mod(targetAngle - hand.angle + 180, 360) - 180;
setHand({ target: targetAngle, angle: hand.angle + delta });
}
// Cross-fade the numbers when switching between hours and minutes.
React.useEffect(() => {
if (prevUnit.current === unit) return;
prevUnit.current = unit;
const ring = ringRef.current;
if (!ring || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const animation = ring.animate(
[
{ opacity: 0, transform: "scale(0.92)" },
{ opacity: 1, transform: "scale(1)" },
],
{ duration: 220, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" },
);
return () => animation.cancel();
}, [unit]);
/* ---------------------------------- dial ---------------------------------- */
const half = size / 2;
const outerR = half - 22;
const innerR = outerR - 36;
const items = unit === "hour" ? hourItems(info.twelve) : minuteItems();
const pickFromPoint = (clientX: number, clientY: number) => {
const rect = dialRef.current?.getBoundingClientRect();
if (!rect) return;
const dx = clientX - (rect.left + rect.width / 2);
const dy = clientY - (rect.top + rect.height / 2);
const angle = mod((Math.atan2(dx, -dy) * 180) / Math.PI, 360);
const scale = rect.width / size;
const dist = Math.hypot(dx, dy) / scale;
if (unit === "hour") {
const index = Math.round(angle / 30) % 12;
if (info.twelve) setTime((index % 12) + (isPm ? 12 : 0), m);
else if (dist < (outerR + innerR) / 2) setTime(index === 0 ? 0 : index + 12, m);
else setTime(index === 0 ? 12 : index, m);
} else {
setTime(h, mod(Math.round(angle / (6 * step)) * step, 60));
}
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (disabled || event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.focus({ preventScroll: true });
setDragging(true);
pickFromPoint(event.clientX, event.clientY);
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
// setTime only commits when the snapped value actually changes.
if (dragging) pickFromPoint(event.clientX, event.clientY);
};
const endDrag = () => {
if (!dragging) return;
setDragging(false);
if (unit === "hour" && autoAdvance) setUnit("minute");
};
const handleDialKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const isHour = unit === "hour";
const unitStep = isHour ? 1 : step;
const page = isHour ? 3 : step === 1 ? 5 : 15;
const shift = (delta: number) => (isHour ? setTime(h + delta, m) : setTime(h, Math.round((m + delta) / unitStep) * unitStep));
switch (event.key) {
case "ArrowUp":
case "ArrowRight":
shift(unitStep);
break;
case "ArrowDown":
case "ArrowLeft":
shift(-unitStep);
break;
case "PageUp":
shift(page);
break;
case "PageDown":
shift(-page);
break;
case "Home":
if (isHour) setTime(info.twelve && isPm ? 12 : 0, m);
else setTime(h, 0);
break;
case "End":
if (isHour) setTime(info.twelve && !isPm ? 11 : 23, m);
else setTime(h, 60 - step);
break;
case "Enter":
if (isHour) setUnit("minute");
else if (variant === "popover") popoverRef.current?.hidePopover();
break;
default:
return;
}
event.preventDefault();
};
/* --------------------------------- readout -------------------------------- */
const displayHour = info.twelve ? String(h % 12 || 12).padStart(2, "0") : pad(h);
const commitDraft = (which: Unit, text: string) => {
setDrafts((d) => ({ ...d, [which]: null }));
const n = Number.parseInt(text, 10);
if (!Number.isFinite(n)) return;
if (which === "minute") {
if (n >= 0 && n <= 59) setTime(h, n);
return;
}
if (info.twelve) {
if (n >= 1 && n <= 12) setTime((n % 12) + (isPm ? 12 : 0), m);
} else if (n >= 0 && n <= 23) setTime(n, m);
};
const readoutInput = (which: Unit) => {
const isHour = which === "hour";
const shown = drafts[which] ?? (isHour ? displayHour : pad(m));
const min = isHour ? (info.twelve ? 1 : 0) : 0;
const max = isHour ? (info.twelve ? 12 : 23) : 59;
const now = isHour ? (info.twelve ? h % 12 || 12 : h) : m;
const selected = unit === which;
return (
<input
ref={isHour ? undefined : minuteInputRef}
type="text"
inputMode="numeric"
role="spinbutton"
aria-label={isHour ? "Hours" : "Minutes"}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={now}
aria-valuetext={isHour ? info.format(h, m) : `${m} ${m === 1 ? "minute" : "minutes"}`}
maxLength={2}
disabled={disabled}
value={shown}
onFocus={(event) => {
setUnit(which);
event.currentTarget.select();
}}
onChange={(event) => {
const text = event.target.value.replace(/\D/g, "").slice(0, 2);
setDrafts((d) => ({ ...d, [which]: text }));
if (text.length === 2) {
commitDraft(which, text);
if (isHour && autoAdvance) minuteInputRef.current?.focus();
}
}}
onBlur={(event) => {
if (drafts[which] !== null) commitDraft(which, event.target.value);
}}
onKeyDown={(event) => {
const delta = { ArrowUp: 1, ArrowDown: -1, PageUp: isHour ? 3 : 10, PageDown: isHour ? -3 : -10 }[event.key];
if (delta !== undefined) {
event.preventDefault();
setDrafts((d) => ({ ...d, [which]: null }));
if (isHour) setTime(h + delta, m);
else setTime(h, m + delta);
} else if (event.key === "Enter" && drafts[which] !== null) {
event.preventDefault();
commitDraft(which, event.currentTarget.value);
}
}}
className={`h-14 w-[2.6ch] rounded-lg bg-transparent text-center text-[2.5rem] font-medium leading-none tracking-[-0.04em] tabular-nums outline-hidden transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cp-accent)] disabled:cursor-not-allowed ${
selected
? "bg-[color-mix(in_oklab,var(--cp-accent)_16%,transparent)] text-zinc-950 dark:text-white"
: "text-zinc-400 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-200"
}`}
/>
);
};
const periodToggle = info.twelve ? (
<div role="group" aria-label="Period" className="flex flex-col overflow-hidden rounded-lg border border-zinc-900/15 dark:border-white/15">
{[
{ pm: false, text: info.am },
{ pm: true, text: info.pm },
].map((p) => (
<button
key={String(p.pm)}
type="button"
disabled={disabled}
aria-pressed={isPm === p.pm}
onClick={() => {
if (isPm !== p.pm) setTime(h + (p.pm ? 12 : -12), m);
}}
className="h-7 min-w-11 px-2 font-mono text-[11px] uppercase tracking-[0.08em] text-zinc-500 outline-hidden transition-colors first:border-b first:border-zinc-900/15 hover:text-zinc-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cp-accent)] aria-pressed:bg-[color-mix(in_oklab,var(--cp-accent)_16%,transparent)] aria-pressed:text-zinc-950 dark:text-zinc-400 dark:first:border-white/15 dark:hover:text-zinc-100 dark:aria-pressed:text-white"
>
{p.text}
</button>
))}
</div>
) : null;
/* ---------------------------------- popover --------------------------------- */
const position = React.useCallback(() => {
const pop = popoverRef.current;
const trigger = triggerRef.current;
if (!pop || !trigger) return;
const rect = trigger.getBoundingClientRect();
const height = pop.offsetHeight;
const width = pop.offsetWidth;
const below = rect.bottom + 6;
const top = below + height > window.innerHeight - 8 && rect.top - height - 6 > 8 ? rect.top - height - 6 : below;
pop.style.top = `${Math.round(top)}px`;
pop.style.left = `${Math.round(Math.min(Math.max(rect.left, 8), Math.max(window.innerWidth - width - 8, 8)))}px`;
}, []);
React.useEffect(() => {
const pop = popoverRef.current;
if (!pop || variant !== "popover") return;
const onToggle = (event: Event) => {
const next = (event as ToggleEvent).newState === "open";
setOpen(next);
if (next) {
setUnit("hour");
requestAnimationFrame(() => dialRef.current?.focus({ preventScroll: true }));
} else if (pop.contains(document.activeElement) || document.activeElement === document.body) {
triggerRef.current?.focus({ preventScroll: true });
}
};
pop.addEventListener("toggle", onToggle);
return () => pop.removeEventListener("toggle", onToggle);
}, [variant]);
React.useEffect(() => {
if (!open) return;
position();
window.addEventListener("scroll", position, { capture: true, passive: true });
window.addEventListener("resize", position);
return () => {
window.removeEventListener("scroll", position, { capture: true });
window.removeEventListener("resize", position);
};
}, [open, position]);
/* ---------------------------------- render ---------------------------------- */
const valueText = info.format(h, m);
const knobR = 16;
const selectedValue = unit === "hour" ? (info.twelve ? h % 12 || 12 : h) : m;
const handLength = handInner ? innerR : outerR;
const tabs = (
<div role="tablist" aria-label="Clock unit" className="flex rounded-md border border-zinc-900/10 p-0.5 dark:border-white/10">
{(["hour", "minute"] as const).map((u) => (
<button
key={u}
type="button"
role="tab"
id={`${id}-tab-${u}`}
aria-selected={unit === u}
aria-controls={panelId}
tabIndex={unit === u ? 0 : -1}
disabled={disabled}
onClick={() => setUnit(u)}
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const next = u === "hour" ? "minute" : "hour";
setUnit(next);
event.currentTarget.parentElement?.querySelector<HTMLButtonElement>(`#${CSS.escape(`${id}-tab-${next}`)}`)?.focus();
}
}}
className="rounded-[5px] px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 outline-hidden transition-colors hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--cp-accent)] aria-selected:bg-zinc-900 aria-selected:text-white dark:text-zinc-400 dark:hover:text-zinc-100 dark:aria-selected:bg-white dark:aria-selected:text-zinc-900"
>
{u === "hour" ? "Hour" : "Min"}
</button>
))}
</div>
);
const panel = (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<span id={labelId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</span>
{tabs}
</div>
<div className="flex items-center justify-center gap-2">
{info.periodFirst ? periodToggle : null}
<div className="flex items-center gap-1" role="group" aria-label={`${label}, digital`}>
{readoutInput("hour")}
<span aria-hidden="true" className="pb-1 text-[2.25rem] font-light leading-none text-zinc-300 dark:text-zinc-600">
:
</span>
{readoutInput("minute")}
</div>
{info.periodFirst ? null : periodToggle}
</div>
<div id={panelId} role="tabpanel" aria-labelledby={`${id}-tab-${unit}`} className="flex justify-center">
<div
ref={dialRef}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={unit === "hour" ? "Hour" : "Minute"}
aria-valuemin={0}
aria-valuemax={unit === "hour" ? 23 : 59}
aria-valuenow={unit === "hour" ? h : m}
aria-valuetext={unit === "hour" ? `${valueText}, hour` : `${valueText}, ${m} ${m === 1 ? "minute" : "minutes"}`}
aria-disabled={disabled || undefined}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={handleDialKey}
className="relative shrink-0 cursor-pointer touch-none select-none rounded-full bg-zinc-900/[0.045] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[var(--cp-accent)] dark:bg-white/[0.06]"
style={{ width: size, height: size }}
>
{/* Minute ticks (one per minute step) — decorative. */}
{unit === "minute" && step < 5 ? (
<svg aria-hidden="true" className="absolute inset-0" width={size} height={size}>
{Array.from({ length: 60 / step }, (_, i) => {
const a = (i * step * 6 * Math.PI) / 180;
const r1 = half - 5;
const r2 = half - ((i * step) % 5 === 0 ? 10 : 8);
return (
<line
key={i}
x1={round2(half + Math.sin(a) * r1)}
y1={round2(half - Math.cos(a) * r1)}
x2={round2(half + Math.sin(a) * r2)}
y2={round2(half - Math.cos(a) * r2)}
className="stroke-zinc-900/20 dark:stroke-white/20"
strokeWidth={1}
/>
);
})}
</svg>
) : null}
{/* Hand + knob, rotated as one piece. */}
<div
aria-hidden="true"
className={`pointer-events-none absolute inset-0 ${dragging ? "" : "transition-transform duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none"}`}
style={{ transform: `rotate(${round2(hand.angle)}deg)` }}
>
<span
className="absolute left-1/2 w-[2px] -translate-x-1/2 bg-[var(--cp-accent)] transition-[height] duration-200 motion-reduce:transition-none"
style={{ bottom: half, height: handLength }}
/>
<span
className="absolute left-1/2 rounded-full bg-[var(--cp-accent)] transition-[top] duration-200 motion-reduce:transition-none"
style={{ width: knobR * 2, height: knobR * 2, marginLeft: -knobR, top: half - handLength - knobR }}
>
{unit === "minute" && m % 5 !== 0 ? <span className="absolute left-1/2 top-1/2 size-1.5 -translate-1/2 rounded-full bg-zinc-950" /> : null}
</span>
<span className="absolute left-1/2 top-1/2 size-2 -translate-1/2 rounded-full bg-[var(--cp-accent)]" />
</div>
{/* Numbers stay upright; the selected one sits on the knob. */}
<div ref={ringRef} aria-hidden="true" className="pointer-events-none absolute inset-0">
{items.map((item) => {
const r = item.inner ? innerR : outerR;
const a = (item.angle * Math.PI) / 180;
const isSelected = item.value === selectedValue && (unit === "minute" || item.inner === handInner || info.twelve);
return (
<span
key={`${item.inner ? "i" : "o"}${item.value}`}
className={`absolute flex size-8 -translate-1/2 items-center justify-center rounded-full font-mono tabular-nums transition-colors duration-150 ${
item.inner ? "text-[11px]" : "text-[13px]"
} ${
isSelected
? "font-semibold text-zinc-950"
: item.inner
? "text-zinc-500 dark:text-zinc-400"
: "text-zinc-800 dark:text-zinc-200"
}`}
style={{ left: round2(half + Math.sin(a) * r), top: round2(half - Math.cos(a) * r) }}
>
{item.label}
</span>
);
})}
</div>
</div>
</div>
<p className="text-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{valueText}
</p>
</div>
);
const rootStyle = { ["--cp-accent" as string]: accent, ...style };
const hidden = name ? <input type="hidden" name={name} value={toValue(h, m)} /> : null;
if (variant === "inline") {
return (
<div
role="group"
aria-labelledby={labelId}
className={`inline-flex rounded-2xl border border-zinc-900/10 bg-white p-4 text-zinc-900 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 ${
disabled ? "opacity-50" : ""
} ${className}`}
style={rootStyle}
{...props}
>
{panel}
{hidden}
</div>
);
}
return (
<div className={`inline-flex flex-col gap-1.5 ${className}`} style={rootStyle} {...props}>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">{label}</span>
<button
ref={triggerRef}
type="button"
disabled={disabled}
popoverTarget={popoverId}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={popoverId}
aria-label={`${label}: ${valueText}. Change time`}
className="flex h-10 min-w-40 items-center justify-between gap-3 rounded-lg border border-zinc-900/15 bg-white px-3 text-left text-[14px] tabular-nums text-zinc-900 outline-hidden transition-colors hover:border-zinc-900/30 focus-visible:border-[var(--cp-accent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cp-accent)] disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/15 dark:bg-white/[0.04] dark:text-zinc-100 dark:hover:border-white/30"
>
<span>{valueText}</span>
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4 text-zinc-400">
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.3" />
<path d="M8 4.5V8l2.5 1.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
</button>
<div
ref={popoverRef}
id={popoverId}
popover="auto"
role="dialog"
aria-label={label}
onKeyDown={(event) => {
// Escape closes only this layer.
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
popoverRef.current?.hidePopover();
}
}}
className="fixed inset-auto m-0 rounded-2xl border border-zinc-900/10 bg-white p-4 text-zinc-900 shadow-[0_24px_48px_-24px_rgb(0_0_0/0.35)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100"
style={rootStyle}
>
{open ? (
<div className="flex flex-col gap-3">
{panel}
<button
type="button"
onClick={() => popoverRef.current?.hidePopover()}
className="h-9 rounded-lg bg-zinc-900 text-[13px] font-medium text-white outline-hidden transition-colors hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cp-accent)] dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Done
</button>
</div>
) : null}
</div>
{hidden}
</div>
);
}