"use client";
import * as React from "react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface WheelPickerOption {
value: string;
label: string;
disabled?: boolean;
}
export interface WheelPickerColumn {
/** Key of this column in the value record. */
key: string;
/** Accessible name of the column's listbox, e.g. "Hours". */
label: string;
options: WheelPickerOption[];
/** CSS width (e.g. "4.5rem"). Columns without a width share the remaining space. */
width?: string;
/** Text alignment of the items. */
align?: "start" | "center" | "end";
}
export type WheelPickerValue = Record<string, string>;
export interface WheelPickerProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue"> {
columns: WheelPickerColumn[];
/** Controlled value: one option value per column key. */
value?: WheelPickerValue;
/** Initial value when uncontrolled (missing columns start at their first enabled option). */
defaultValue?: WheelPickerValue;
/** Called when a column settles on a new option. */
onChange?: (value: WheelPickerValue, changedKey: string) => void;
/** Accessible name of the whole picker (role="group"). */
label?: string;
/** Row height in px. */
itemHeight?: number;
/** Visible rows; even numbers are rounded up so one row sits in the middle. */
visibleCount?: number;
/** Degrees each row turns on the drum; 0 renders a flat list. */
curve?: number;
/** Colour of the selection band edges and focus ring (any CSS colour). */
accent?: string;
}
const firstEnabled = (options: WheelPickerOption[]) => Math.max(0, options.findIndex((o) => !o.disabled));
/** Nearest enabled index to `i`, preferring `dir` on ties. */
function nearestEnabled(options: WheelPickerOption[], i: number, dir: 1 | -1 = 1) {
if (options[i] && !options[i].disabled) return i;
for (let n = 1; n < options.length; n++) {
for (const s of [dir, -dir]) {
const j = i + s * n;
if (options[j] && !options[j].disabled) return j;
}
}
return i;
}
interface ColumnProps {
column: WheelPickerColumn;
value: string | undefined;
onSelect: (value: string) => void;
itemHeight: number;
visibleCount: number;
curve: number;
reduceMotion: boolean;
baseId: string;
}
function WheelColumn({ column, value, onSelect, itemHeight, visibleCount, curve, reduceMotion, baseId }: ColumnProps) {
const { options } = column;
const found = options.findIndex((o) => o.value === value);
const index = found === -1 ? firstEnabled(options) : found;
const optionId = (i: number) => `${baseId}-${i}`;
const scrollerRef = React.useRef<HTMLDivElement>(null);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const committed = React.useRef(index);
const frame = React.useRef(0);
const settleTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const drag = React.useRef<{ y: number; top: number; moved: boolean } | null>(null);
const suppressClick = React.useRef(false);
const typeahead = React.useRef({ text: "", at: 0 });
const mounted = React.useRef(false);
/** Row a keyboard/click/value-driven scroll is heading to; null while the user scrolls. */
const target = React.useRef<number | null>(null);
const pad = (itemHeight * (visibleCount - 1)) / 2;
/** Places every row on the drum according to the current scroll position (no React state). */
const paint = React.useCallback(() => {
frame.current = 0;
const el = scrollerRef.current;
if (!el) return;
const center = el.scrollTop / itemHeight;
const step = (curve * Math.PI) / 180;
// Radius of a drum whose rows are `itemHeight` tall chords of `step` radians.
const radius = step > 0 ? itemHeight / (2 * Math.tan(step / 2)) : 0;
const reach = visibleCount / 2 + 1;
itemRefs.current.forEach((item, i) => {
if (!item) return;
const d = i - center;
if (Math.abs(d) > reach) {
item.style.visibility = "hidden";
return;
}
item.style.visibility = "";
if (step > 0) {
const theta = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, d * step));
const y = radius * Math.sin(theta) - d * itemHeight;
const z = radius * (Math.cos(theta) - 1);
item.style.transform = `translate3d(0, ${y.toFixed(2)}px, ${z.toFixed(2)}px) rotateX(${(-theta).toFixed(4)}rad)`;
} else {
item.style.transform = "";
}
item.style.opacity = Math.max(0.18, 1 - Math.abs(d) * (0.9 / reach)).toFixed(3);
item.dataset.center = Math.abs(d) < 0.5 ? "true" : "false";
});
}, [itemHeight, curve, visibleCount]);
const scrollToIndex = (i: number, smooth: boolean) => {
target.current = i;
scrollerRef.current?.scrollTo({ top: i * itemHeight, behavior: smooth && !reduceMotion ? "smooth" : "instant" });
};
const commit = (i: number) => {
const option = options[i];
if (!option || option.disabled) return;
committed.current = i;
if (option.value !== value) onSelect(option.value);
};
/** Runs once scrolling has stopped: lands on an enabled row and reports it. */
const settle = () => {
const el = scrollerRef.current;
if (!el || drag.current) return;
const raw = Math.min(options.length - 1, Math.max(0, Math.round(el.scrollTop / itemHeight)));
const aim = target.current;
target.current = null;
if (aim !== null) {
// A programmatic smooth scroll can be cut short by snapping; finish it instantly.
if (Math.abs(el.scrollTop - aim * itemHeight) > 1) el.scrollTo({ top: aim * itemHeight, behavior: "instant" });
if (aim !== committed.current) commit(aim);
return;
}
const i = nearestEnabled(options, raw, el.scrollTop / itemHeight > raw ? 1 : -1);
if (Math.abs(el.scrollTop - i * itemHeight) > 1) scrollToIndex(i, true);
if (i !== committed.current) commit(i);
};
// Keep the scroll position in step with the value (initial mount, keyboard, controlled updates).
React.useLayoutEffect(() => {
const el = scrollerRef.current;
if (!el) return;
committed.current = index;
const top = index * itemHeight;
// Skip when a scroll toward this row is already running (keyboard and clicks start their own).
if (!drag.current && target.current !== index && Math.abs(el.scrollTop - top) > 1) {
target.current = index;
el.scrollTo({ top, behavior: mounted.current && !reduceMotion ? "smooth" : "instant" });
}
mounted.current = true;
paint();
}, [index, itemHeight, visibleCount, reduceMotion, paint]);
React.useEffect(
() => () => {
cancelAnimationFrame(frame.current);
clearTimeout(settleTimer.current);
},
[],
);
const handleScroll = () => {
if (!frame.current) frame.current = requestAnimationFrame(paint);
clearTimeout(settleTimer.current);
settleTimer.current = setTimeout(settle, 140);
};
const move = (i: number, dir: 1 | -1) => {
const next = nearestEnabled(options, Math.min(options.length - 1, Math.max(0, i)), dir);
commit(next);
// Uncontrolled parents re-render with the new index; scroll here too so it is immediate.
scrollToIndex(next, true);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const page = Math.max(1, visibleCount - 1);
const keys: Record<string, () => void> = {
ArrowDown: () => move(index + 1, 1),
ArrowUp: () => move(index - 1, -1),
PageDown: () => move(index + page, 1),
PageUp: () => move(index - page, -1),
Home: () => move(0, 1),
End: () => move(options.length - 1, -1),
};
const action = keys[event.key];
if (action) {
event.preventDefault();
action();
return;
}
// Typeahead: printable characters jump to the next option whose label starts with them.
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
const now = event.timeStamp;
const t = typeahead.current;
t.text = now - t.at > 600 ? event.key.toLowerCase() : t.text + event.key.toLowerCase();
t.at = now;
const repeated = t.text.split("").every((c) => c === t.text[0]);
const needle = repeated ? t.text[0] : t.text;
const start = repeated || t.text.length === 1 ? index + 1 : index;
for (let n = 0; n < options.length; n++) {
const i = (start + n) % options.length;
if (!options[i].disabled && options[i].label.toLowerCase().startsWith(needle)) {
event.preventDefault();
commit(i);
scrollToIndex(i, true);
break;
}
}
}
};
// Mouse drag: native scrolling covers wheel and touch; this adds grab-to-spin for mice.
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== "mouse" || event.button !== 0) return;
const el = event.currentTarget;
drag.current = { y: event.clientY, top: el.scrollTop, moved: false };
target.current = null;
suppressClick.current = false;
const onMove = (e: PointerEvent) => {
const d = drag.current;
if (!d) return;
const dy = e.clientY - d.y;
if (!d.moved && Math.abs(dy) > 3) {
d.moved = true;
el.style.scrollSnapType = "none"; // snapping would fight the drag
}
if (d.moved) el.scrollTop = d.top - dy;
};
const onUp = () => {
const moved = drag.current?.moved ?? false;
drag.current = null;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
if (!moved) return;
suppressClick.current = true;
el.style.scrollSnapType = "";
const i = Math.min(options.length - 1, Math.max(0, Math.round(el.scrollTop / itemHeight)));
scrollToIndex(nearestEnabled(options, i), true);
handleScroll();
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
};
const align = column.align === "start" ? "justify-start pl-4" : column.align === "end" ? "justify-end pr-4" : "justify-center";
return (
<div
ref={scrollerRef}
role="listbox"
tabIndex={0}
aria-label={column.label}
aria-activedescendant={optionId(index)}
onScroll={handleScroll}
// Direct user scrolling takes over from any programmatic scroll.
onWheel={() => (target.current = null)}
onTouchStart={() => (target.current = null)}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
className="relative min-w-0 cursor-grab snap-y snap-mandatory overflow-y-scroll overscroll-contain rounded-lg outline-hidden [scrollbar-width:none] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--wp-accent)] active:cursor-grabbing [&::-webkit-scrollbar]:hidden"
style={{
height: itemHeight * visibleCount,
paddingBlock: pad,
flex: column.width ? `0 0 ${column.width}` : "1 1 0",
perspective: 1000,
}}
>
{options.map((option, i) => (
<div
key={option.value}
ref={(el) => {
itemRefs.current[i] = el;
}}
id={optionId(i)}
role="option"
aria-selected={i === index}
aria-disabled={option.disabled || undefined}
onClick={() => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
if (option.disabled) return;
commit(i);
scrollToIndex(i, true);
}}
style={{ height: itemHeight }}
className={`flex snap-center select-none items-center ${align} whitespace-nowrap text-[17px] tabular-nums tracking-tight text-zinc-500 transition-[color] duration-100 [backface-visibility:hidden] data-[center=true]:font-medium data-[center=true]:text-zinc-950 dark:text-zinc-500 dark:data-[center=true]:text-white motion-reduce:transition-none ${
option.disabled ? "line-through decoration-zinc-400/60" : ""
}`}
>
{option.label}
</div>
))}
</div>
);
}
/**
* An iOS-style drum picker. Each column is a native scroll-snap list, so wheel, trackpad and
* touch momentum come from the browser; on every scroll frame the rows are placed on a cylinder
* (translate3d + rotateX by distance from the centre) without React re-renders. Each column is
* a focusable role="listbox" with aria-activedescendant, arrow/page/Home/End keys and typeahead.
*/
export function WheelPicker({
columns,
value,
defaultValue,
onChange,
label = "Picker",
itemHeight = 36,
visibleCount = 5,
curve = 18,
accent = "#FF7448",
className = "",
style,
...props
}: WheelPickerProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const [inner, setInner] = React.useState<WheelPickerValue>(() => defaultValue ?? {});
const current = value ?? inner;
const rows = visibleCount % 2 === 0 ? visibleCount + 1 : Math.max(1, visibleCount);
const select = (key: string, next: string) => {
const merged = { ...current, [key]: next };
if (value === undefined) setInner(merged);
onChange?.(merged, key);
};
return (
<div
role="group"
aria-label={label}
className={`relative w-full select-none rounded-2xl border border-zinc-900/10 bg-white p-1.5 dark:border-white/10 dark:bg-zinc-950 ${className}`}
style={{ ["--wp-accent" as string]: accent, ...style }}
{...props}
>
{/* Selection band, drawn behind the rows. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-1.5 top-1/2 -translate-y-1/2 rounded-lg border-y border-[color-mix(in_srgb,var(--wp-accent)_55%,transparent)] bg-zinc-900/[0.04] dark:bg-white/[0.06]"
style={{ height: itemHeight }}
/>
<div className="relative flex gap-1 [mask-image:linear-gradient(to_bottom,transparent,black_28%,black_72%,transparent)]">
{columns.map((column, ci) => (
<WheelColumn
key={column.key}
column={column}
value={current[column.key]}
onSelect={(v) => select(column.key, v)}
itemHeight={itemHeight}
visibleCount={rows}
curve={curve}
reduceMotion={reduceMotion}
baseId={`wp-${uid}-${ci}`}
/>
))}
</div>
</div>
);
}