Glyph-only priority chip with tooltip label that opens a filtered urgent/high/medium/low list — signal-bar glyphs, 0–4 quick keys, destructive-tinted urgent.
npx shadcn@latest add @paragon/priority-pickerAlso installs: tooltip
"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 {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";
export type Priority = "none" | "urgent" | "high" | "medium" | "low";
/** Display order in the menu; digit quick-keys are the semantic 0–4. */
const ORDER: Priority[] = ["none", "urgent", "high", "medium", "low"];
const META: Record<Priority, { label: string; key: number; bars: number }> = {
none: { label: "No priority", key: 0, bars: 0 },
urgent: { label: "Urgent", key: 1, bars: 3 },
high: { label: "High", key: 2, bars: 3 },
medium: { label: "Medium", key: 3, bars: 2 },
low: { label: "Low", key: 4, bars: 1 },
};
/**
* Linear-style priority glyph: ascending signal bars for low/medium/high,
* a filled exclamation square for urgent, and three dots for none.
*/
export function PriorityGlyph({
priority,
className,
...props
}: React.ComponentProps<"svg"> & { priority: Priority }) {
return (
<svg
viewBox="0 0 16 16"
className={cn(
"size-4 shrink-0",
priority === "urgent" ? "text-destructive" : "text-foreground",
priority === "none" && "text-muted-foreground",
priority === "low" && "text-muted-foreground",
className,
)}
aria-hidden
{...props}
>
{priority === "urgent" ? (
<>
<rect x="1.5" y="1.5" width="13" height="13" rx="3.5" fill="currentColor" />
<path
d="M8 4.5v4"
stroke="var(--color-destructive-foreground)"
strokeWidth="1.8"
strokeLinecap="round"
/>
<circle cx="8" cy="11.2" r="1" fill="var(--color-destructive-foreground)" />
</>
) : priority === "none" ? (
<>
<circle cx="3.5" cy="8" r="1.1" fill="currentColor" />
<circle cx="8" cy="8" r="1.1" fill="currentColor" />
<circle cx="12.5" cy="8" r="1.1" fill="currentColor" />
</>
) : (
<>
<rect x="2" y="9" width="3" height="4.5" rx="1" fill="currentColor" opacity={META[priority].bars >= 1 ? 1 : 0.3} />
<rect x="6.5" y="6" width="3" height="7.5" rx="1" fill="currentColor" opacity={META[priority].bars >= 2 ? 1 : 0.3} />
<rect x="11" y="2.5" width="3" height="11" rx="1" fill="currentColor" opacity={META[priority].bars >= 3 ? 1 : 0.3} />
</>
)}
</svg>
);
}
const priorityPickerStyles = `
@keyframes pg-priority-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-priority-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
@keyframes pg-priority-in { from { opacity: 0; } }
@keyframes pg-priority-out { to { opacity: 0; } }
}
`;
export interface PriorityPickerProps
extends Omit<
React.ComponentProps<"button">,
"value" | "defaultValue" | "onChange"
> {
/** Controlled priority. */
value?: Priority;
defaultValue?: Priority;
onValueChange?: (priority: Priority) => void;
/** Show the label next to the glyph on the trigger. */
withLabel?: boolean;
/** Disables the icon-swap motion. */
static?: boolean;
}
/**
* Priority picker: a glyph-only chip (tooltip label) that opens a filtered
* list of urgent/high/medium/low/none with signal-bar glyphs. Digits 0–4
* quick-select the semantic priority, arrows navigate, and the trigger
* glyph blur-swaps on change. Urgent is tinted destructive throughout.
*/
export function PriorityPicker({
value,
defaultValue = "none",
onValueChange,
withLabel = false,
static: isStatic = false,
className,
disabled,
...props
}: PriorityPickerProps) {
const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const reduced = useReducedMotion();
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [internal, setInternal] = React.useState<Priority>(defaultValue);
const selected = value ?? internal;
const listRef = React.useRef<HTMLDivElement>(null);
const visible = React.useMemo(() => {
const q = query.trim().toLowerCase();
return q ? ORDER.filter((p) => META[p].label.toLowerCase().includes(q)) : ORDER;
}, [query]);
const [active, setActive] = React.useState<Priority | "">(selected);
const activeIndex = visible.findIndex((p) => p === active);
React.useEffect(() => {
if (!open) return;
listRef.current
?.querySelector('[data-active="true"]')
?.scrollIntoView({ block: "nearest" });
}, [active, open]);
const select = (p: Priority) => {
setInternal(p);
onValueChange?.(p);
setOpen(false);
};
const move = (delta: number) => {
if (visible.length === 0) return;
const next =
(Math.max(0, activeIndex) + delta + visible.length) % visible.length;
setActive(visible[next]);
};
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) select(target);
} else if (/^[0-4]$/.test(e.key) && query === "") {
// Semantic quick keys: 0 none, 1 urgent, 2 high, 3 medium, 4 low.
e.preventDefault();
const target = ORDER.find((p) => META[p].key === Number(e.key));
if (target) select(target);
}
};
const animate = !isStatic && !reduced;
const blur = animate ? "blur(4px)" : "blur(0px)";
return (
<PopoverPrimitive.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) {
setQuery("");
setActive(selected);
}
}}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverPrimitive.Trigger asChild disabled={disabled}>
<button
type="button"
data-slot="priority-picker"
aria-label={`Priority: ${META[selected].label}`}
className={cn(
"group inline-flex h-7 items-center gap-1.5 rounded-md border border-input bg-transparent text-xs font-medium",
withLabel ? "px-2" : "w-7 justify-center",
"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}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={selected}
className="flex"
initial={{ opacity: 0, scale: 0.25, filter: blur }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: blur }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
<PriorityGlyph priority={selected} />
</motion.span>
</AnimatePresence>
{withLabel && (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={selected}
initial={{ opacity: 0, filter: blur }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: blur, transition: { duration: 0.1 } }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
className={cn(selected === "urgent" && "text-destructive")}
>
{META[selected].label}
</motion.span>
</AnimatePresence>
)}
</button>
</PopoverPrimitive.Trigger>
</TooltipTrigger>
{!withLabel && (
<TooltipContent side="top">{META[selected].label}</TooltipContent>
)}
</Tooltip>
{/* 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-priority-picker" precedence="paragon">
{priorityPickerStyles}
</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-priority-in_160ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-priority-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="Set priority…"
role="combobox"
aria-expanded="true"
aria-controls={`priority-list-${uid}`}
aria-activedescendant={
activeIndex >= 0 ? `priority-opt-${uid}-${active}` : undefined
}
aria-label="Filter priorities"
className="h-8 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
<div
ref={listRef}
id={`priority-list-${uid}`}
role="listbox"
aria-label="Priority"
className="p-1"
onPointerLeave={() => setActive("")}
>
{visible.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
No matching priority.
</p>
)}
{visible.map((p) => {
const isActive = p === active;
const isSelected = p === selected;
const urgent = p === "urgent";
return (
<button
key={p}
type="button"
role="option"
id={`priority-opt-${uid}-${p}`}
aria-selected={isSelected}
data-active={isActive || undefined}
tabIndex={-1}
onPointerMove={() => setActive(p)}
onClick={() => select(p)}
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)",
urgent && "text-destructive",
isActive && (urgent ? "bg-destructive/10" : "bg-accent text-accent-foreground"),
)}
>
<PriorityGlyph priority={p} />
<span className="min-w-0 flex-1 truncate">{META[p].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"
>
{META[p].key}
</span>
</button>
);
})}
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}