Points or t-shirt estimate picker with size-scaled glyphs, scroll-wheel and arrow cycling on the closed chip with a rolling digit, and a team-average hint.
npx shadcn@latest add @paragon/estimate-picker"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Search } from "lucide-react";
import { cn } from "@/lib/utils";
export interface EstimateOption {
id: string;
label: string;
/** Numeric points — used for quick keys and the average hint. */
points?: number;
}
const POINTS: EstimateOption[] = [
{ id: "none", label: "No estimate" },
{ id: "1", label: "1 point", points: 1 },
{ id: "2", label: "2 points", points: 2 },
{ id: "3", label: "3 points", points: 3 },
{ id: "5", label: "5 points", points: 5 },
{ id: "8", label: "8 points", points: 8 },
];
const TSHIRT: EstimateOption[] = [
{ id: "none", label: "No estimate" },
{ id: "xs", label: "XS", points: 1 },
{ id: "s", label: "S", points: 2 },
{ id: "m", label: "M", points: 3 },
{ id: "l", label: "L", points: 5 },
{ id: "xl", label: "XL", points: 8 },
];
/**
* Size-scaled estimate glyph: a rounded square that grows with the
* option's magnitude; "no estimate" is a dashed outline.
*/
export function EstimateGlyph({
step,
of = 5,
none = false,
className,
...props
}: React.ComponentProps<"svg"> & {
/** 1-based magnitude step. */
step?: number;
/** Total steps in the scale. */
of?: number;
none?: boolean;
}) {
const idx = Math.max(1, Math.min(of, step ?? 1));
const side = 5 + ((idx - 1) / Math.max(1, of - 1)) * 8; // 5 → 13
const off = (16 - side) / 2;
return (
<svg
viewBox="0 0 16 16"
className={cn("size-4 shrink-0 text-muted-foreground", className)}
aria-hidden
{...props}
>
{none ? (
<rect
x="4.5"
y="4.5"
width="7"
height="7"
rx="2"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeDasharray="2 2"
/>
) : (
<rect
x={off}
y={off}
width={side}
height={side}
rx={1.5 + idx * 0.3}
fill="currentColor"
opacity={0.5 + idx * 0.1}
/>
)}
</svg>
);
}
const estimatePickerStyles = `
@keyframes pg-estimate-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-estimate-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
@keyframes pg-estimate-in { from { opacity: 0; } }
@keyframes pg-estimate-out { to { opacity: 0; } }
}
`;
export interface EstimatePickerProps
extends Omit<
React.ComponentProps<"button">,
"value" | "defaultValue" | "onChange"
> {
/** Estimate scale. Defaults to exponential points. */
options?: EstimateOption[];
/** Convenience switch between the built-in scales. */
scale?: "points" | "tshirt";
/** Controlled selected option id. */
value?: string;
defaultValue?: string;
onValueChange?: (id: string) => void;
/** Team average, e.g. 3.2 — renders the hint line. */
teamAverage?: number;
/** Cycle values with the scroll wheel over the closed chip. */
wheelCycle?: boolean;
/** Disables the digit-roll motion. */
static?: boolean;
}
/**
* Estimate picker: option rows with size-scaled glyphs and a team-average
* hint line. The closed chip cycles with arrow keys or the scroll wheel —
* the value digit rolls vertically in the direction of change. Digits
* quick-select matching point values inside the open list.
*/
export function EstimatePicker({
options,
scale = "points",
value,
defaultValue = "none",
onValueChange,
teamAverage,
wheelCycle = true,
static: isStatic = false,
className,
disabled,
...props
}: EstimatePickerProps) {
const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const reduced = useReducedMotion();
const opts = options ?? (scale === "tshirt" ? TSHIRT : POINTS);
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [internal, setInternal] = React.useState(defaultValue);
const selectedId = value ?? internal;
const selectedIndex = Math.max(0, opts.findIndex((o) => o.id === selectedId));
const selected = opts[selectedIndex];
const [dir, setDir] = React.useState(1);
const listRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const commit = React.useCallback(
(id: string, close = false) => {
setInternal(id);
onValueChange?.(id);
if (close) setOpen(false);
},
[onValueChange],
);
const cycle = React.useCallback(
(delta: number) => {
const next = Math.max(0, Math.min(opts.length - 1, selectedIndex + delta));
if (next === selectedIndex) return;
setDir(delta > 0 ? 1 : -1);
commit(opts[next].id);
},
[opts, selectedIndex, commit],
);
// Scroll-wheel cycling on the closed chip. Native listener: React wheel
// handlers are passive, so preventDefault must be attached manually.
const cycleRef = React.useRef(cycle);
cycleRef.current = cycle;
const lastWheel = React.useRef(0);
React.useEffect(() => {
const el = triggerRef.current;
if (!el || !wheelCycle || isStatic) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const t = performance.now();
if (t - lastWheel.current < 90) return;
lastWheel.current = t;
cycleRef.current(e.deltaY > 0 ? 1 : -1);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [wheelCycle, isStatic]);
const visible = React.useMemo(() => {
const q = query.trim().toLowerCase();
return q ? opts.filter((o) => o.label.toLowerCase().includes(q)) : opts;
}, [opts, query]);
const [active, setActive] = React.useState(selectedId);
const activeIndex = visible.findIndex((o) => o.id === active);
React.useEffect(() => {
if (!open) return;
listRef.current
?.querySelector('[data-active="true"]')
?.scrollIntoView({ block: "nearest" });
}, [active, open]);
const move = (delta: number) => {
if (visible.length === 0) return;
const next =
(Math.max(0, activeIndex) + delta + visible.length) % visible.length;
setActive(visible[next].id);
};
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
move(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
move(-1);
} else if (e.key === "Enter") {
e.preventDefault();
const target = visible[activeIndex] ?? visible[0];
if (target) {
setDir(opts.indexOf(target) > selectedIndex ? 1 : -1);
commit(target.id, true);
}
} else if (/^[0-9]$/.test(e.key) && query === "") {
// Digits match point values; 0 selects "no estimate".
e.preventDefault();
const n = Number(e.key);
const target =
n === 0
? opts.find((o) => o.points === undefined)
: opts.find((o) => o.points === n);
if (target) {
setDir(opts.indexOf(target) > selectedIndex ? 1 : -1);
commit(target.id, true);
}
}
};
const magnitudes = opts.filter((o) => o.points !== undefined);
const stepOf = (o: EstimateOption) =>
o.points === undefined ? 0 : magnitudes.indexOf(o) + 1;
const animate = !isStatic && !reduced;
const unit = scale === "tshirt" ? "" : " pts";
return (
<PopoverPrimitive.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) {
setQuery("");
setActive(selectedId);
}
}}
>
<PopoverPrimitive.Trigger asChild disabled={disabled}>
<button
ref={triggerRef}
type="button"
data-slot="estimate-picker"
aria-label={`Estimate: ${selected?.label ?? "none"}`}
onKeyDown={(e) => {
if (open || isStatic) return;
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
cycle(e.key === "ArrowDown" ? 1 : -1);
}
}}
className={cn(
"group inline-flex h-7 items-center gap-1.5 rounded-md border border-input bg-transparent px-2 text-xs font-medium whitespace-nowrap",
"transition-[background-color,border-color,box-shadow,scale] duration-150 ease-(--ease-out)",
"outline-none hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
"data-[state=open]:border-ring disabled:pointer-events-none disabled:opacity-50",
!isStatic && "active:not-disabled:scale-[0.97]",
className,
)}
{...props}
>
<EstimateGlyph
step={stepOf(selected)}
of={magnitudes.length}
none={selected?.points === undefined}
/>
<span className="relative overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={selected?.id ?? "none"}
initial={{
opacity: 0,
y: animate ? dir * 9 : 0,
filter: animate ? "blur(2px)" : "blur(0px)",
}}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{
opacity: 0,
y: animate ? dir * -9 : 0,
filter: animate ? "blur(2px)" : "blur(0px)",
transition: { duration: 0.1 },
}}
transition={{ type: "spring", duration: 0.25, bounce: 0 }}
className={cn(
"block tabular-nums",
selected?.points === undefined && "text-muted-foreground",
)}
>
{selected?.points === undefined
? "Estimate"
: scale === "tshirt"
? selected.label
: `${selected.points}${unit}`}
</motion.span>
</AnimatePresence>
</span>
<span aria-live="polite" className="sr-only">
{selected?.label}
</span>
</button>
</PopoverPrimitive.Trigger>
{/* Hoisted outside the Portal: React 19 keeps a hoistable <style> as a
child node, and the Radix Portal enforces a single child. */}
<style href="paragon-estimate-picker" precedence="paragon">
{estimatePickerStyles}
</style>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align="start"
sideOffset={6}
collisionPadding={8}
className={cn(
"z-50 w-52 origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover text-popover-foreground shadow-overlay outline-none",
"data-[state=open]:animate-[pg-estimate-in_170ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-estimate-out_90ms_var(--ease-exit)_forwards]",
)}
>
<div className="flex items-center gap-2 border-b border-border px-2.5">
<Search className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<input
autoFocus
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive("");
}}
onKeyDown={onSearchKeyDown}
placeholder="Change estimate…"
role="combobox"
aria-expanded="true"
aria-controls={`estimate-list-${uid}`}
aria-activedescendant={
activeIndex >= 0 ? `estimate-opt-${uid}-${active}` : undefined
}
aria-label="Filter estimates"
className="h-8 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
<div
ref={listRef}
id={`estimate-list-${uid}`}
role="listbox"
aria-label="Estimate"
className="p-1"
onPointerLeave={() => setActive("")}
>
{visible.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
No matching estimate.
</p>
)}
{visible.map((o) => {
const isActive = o.id === active;
const isSelected = o.id === selectedId;
return (
<button
key={o.id}
type="button"
role="option"
id={`estimate-opt-${uid}-${o.id}`}
aria-selected={isSelected}
data-active={isActive || undefined}
tabIndex={-1}
onPointerMove={() => setActive(o.id)}
onClick={() => {
setDir(opts.indexOf(o) > selectedIndex ? 1 : -1);
commit(o.id, true);
}}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none",
"transition-[background-color] duration-100 ease-(--ease-out)",
isActive && "bg-accent text-accent-foreground",
)}
>
<EstimateGlyph
step={stepOf(o)}
of={magnitudes.length}
none={o.points === undefined}
/>
<span className="min-w-0 flex-1 truncate">{o.label}</span>
<span className="flex size-3.5 shrink-0 items-center justify-center">
{isSelected && <Check className="size-3.5" aria-hidden />}
</span>
<span
aria-hidden
className="w-3 shrink-0 text-right font-mono text-[10px] text-muted-foreground/70 tabular-nums"
>
{o.points ?? 0}
</span>
</button>
);
})}
</div>
{teamAverage !== undefined && (
<div className="border-t border-border px-3 py-2">
<p className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>Team average</span>
<span className="tabular-nums">
{teamAverage.toFixed(1)}
{scale === "tshirt" ? "" : " pts"}
</span>
</p>
</div>
)}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}