Priority Picker
Inputs & Forms

Priority Picker

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.

Install

npx shadcn@latest add @paragon/priority-picker

Also installs: tooltip

priority-picker.tsx

"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>
  );
}