{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "seat-picker",
  "type": "registry:ui",
  "title": "Seat Picker",
  "description": "Stepper and slider in sync, with a rolling total and volume tiers that light up.",
  "categories": [
    "commerce"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/seat-picker.tsx",
      "content": "\"use client\";\nimport { NumberField } from \"@base-ui/react/number-field\";\nimport { Slider } from \"@base-ui/react/slider\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Minus, Plus } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type SeatTier = {\n  /** The first seat count this price applies to. The first tier should start at 1 or at min. */\n  from: number;\n  /** Price per seat, for every seat, once the count reaches `from`. */\n  price: number;\n};\n\n/** The tier a seat count falls in, with volume pricing: every seat is charged the tier's price. */\nexport function tierFor(seats: number, tiers: SeatTier[]) {\n  let index = 0;\n  tiers.forEach((t, i) => {\n    if (seats >= t.from) index = i;\n  });\n  return { index, tier: tiers[index], total: Math.round(seats * tiers[index].price * 100) / 100 };\n}\n\nexport type SeatPickerProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (seats: number) => void;\n  tiers: SeatTier[];\n  /** The fewest seats allowed, usually the people already on the team. */\n  min?: number;\n  max?: number;\n  /** Why the minimum exists, shown when the count sits on it: \"7 people are on your team\". */\n  minReason?: React.ReactNode;\n  /** Shown at the maximum: where to go for more. */\n  maxReason?: React.ReactNode;\n  label?: React.ReactNode;\n  currency?: string;\n  locale?: Intl.LocalesArgument;\n  /** Unit of the total. */\n  interval?: string;\n  disabled?: boolean;\n};\n\nexport function SeatPicker({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  tiers,\n  min = 1,\n  max = 100,\n  minReason,\n  maxReason,\n  label = \"Seats\",\n  currency = \"USD\",\n  locale,\n  interval = \"month\",\n  disabled,\n  className,\n  ...rest\n}: SeatPickerProps) {\n  const [seats, setSeats] = useControllableState({ value: valueProp, defaultValue: defaultValue ?? min, onChange: onValueChange });\n  const reduce = useReducedMotion();\n  const inputId = useId();\n  const pill = useId();\n  const hintId = useId();\n\n  const loc = locale ?? \"en-US\";\n  const clamp = (n: number) => Math.min(max, Math.max(min, Math.round(n)));\n  const count = clamp(seats);\n  const { index, tier, total } = tierFor(count, tiers);\n  const base = tiers[0]?.price ?? tier.price;\n  const next = tiers[index + 1];\n  const money = (n: number) =>\n    new Intl.NumberFormat(loc, { style: \"currency\", currency, maximumFractionDigits: n % 1 ? 2 : 0 }).format(n);\n  const set = (n: number | null) => {\n    if (n == null || Number.isNaN(n)) return;\n    setSeats(clamp(n));\n  };\n  const pct = (n: number) => ((Math.min(max, Math.max(min, n)) - min) / Math.max(1, max - min)) * 100;\n\n  // Tiers clipped to the allowed range. Ticks on the track mark where each begins.\n  const bands = tiers\n    .map((t, i) => {\n      const start = Math.max(min, t.from);\n      const end = Math.min(max, (tiers[i + 1]?.from ?? max + 1) - 1);\n      return { ...t, i, start, end, left: pct(start) };\n    })\n    .filter((b) => b.start <= b.end);\n\n  const hint =\n    count <= min && minReason != null\n      ? { key: \"min\", node: minReason }\n      : count >= max && maxReason != null\n        ? { key: \"max\", node: maxReason }\n        : next && next.from - count <= 5\n          ? {\n              key: `next-${next.from}`,\n              node: (\n                <>\n                  Add {next.from - count} more {next.from - count === 1 ? \"seat\" : \"seats\"} to pay{\" \"}\n                  <span className=\"text-fg tabular\">{money(next.price)}</span> per seat\n                </>\n              ),\n            }\n          : index > 0\n            ? {\n                key: `saving-${index}`,\n                node: (\n                  <>\n                    Volume price: saving <span className=\"text-fg tabular\">{money(Math.round((base - tier.price) * count * 100) / 100)}</span> a {interval}\n                  </>\n                ),\n              }\n            : { key: \"none\", node: null };\n\n  return (\n    <div data-disabled={disabled || undefined} className={cn(\"flex w-full min-w-0 flex-col gap-4 data-disabled:opacity-50\", className)} {...rest}>\n      <div className=\"flex items-end justify-between gap-4\">\n        <NumberField.Root\n          id={inputId}\n          value={count}\n          onValueChange={set}\n          min={min}\n          max={max}\n          step={1}\n          disabled={disabled}\n          className=\"flex flex-col gap-1.5\"\n        >\n          <label htmlFor={inputId} className=\"text-[12.5px] font-medium tracking-[-0.005em] text-fg\">\n            {label}\n          </label>\n          <NumberField.Group\n            className={cn(\n              \"flex h-9 items-stretch rounded-lg border border-line-2 bg-raised shadow-[var(--shadow)]\",\n              \"transition-[border-color,box-shadow] duration-150 focus-within:border-fg-4 focus-within:ring-3 focus-within:ring-fg/10\",\n            )}\n          >\n            <StepButton part=\"decrement\" label={`Remove a seat`} />\n            <NumberField.Input\n              aria-describedby={hint.node != null ? hintId : undefined}\n              className=\"w-14 min-w-0 border-x border-line bg-transparent text-center text-base font-medium text-fg tabular outline-none sm:text-[13px]\"\n            />\n            <StepButton part=\"increment\" label={`Add a seat`} />\n          </NumberField.Group>\n        </NumberField.Root>\n\n        <div className=\"flex min-w-0 flex-col items-end gap-0.5 text-right\">\n          <p className=\"flex items-baseline gap-1\">\n            <NumberFlow\n              value={total}\n              locales={loc}\n              format={{ style: \"currency\", currency, maximumFractionDigits: total % 1 ? 2 : 0 }}\n              className=\"text-[26px] font-medium leading-8 tracking-[-0.03em] text-fg\"\n            />\n            <span className=\"text-[12px] text-fg-3\">/{interval}</span>\n          </p>\n          <p className=\"flex items-baseline gap-1 whitespace-nowrap text-[12px] leading-4 text-fg-3 tabular\">\n            {tier.price < base && <s className=\"text-fg-4\">{money(base)}</s>}\n            <NumberFlow value={tier.price} locales={loc} format={{ style: \"currency\", currency, maximumFractionDigits: tier.price % 1 ? 2 : 0 }} className=\"text-fg-2\" />\n            <span>per seat</span>\n          </p>\n        </div>\n      </div>\n\n      <div className=\"flex flex-col gap-2\">\n        <Slider.Root\n          value={count}\n          onValueChange={(v) => set(v as number)}\n          min={min}\n          max={max}\n          step={1}\n          largeStep={10}\n          // The thumb stays inside the track at both ends instead of hanging off it.\n          thumbAlignment=\"edge\"\n          disabled={disabled}\n          className=\"group/slider w-full\"\n        >\n          <Slider.Control className=\"group/control relative flex h-6 cursor-pointer touch-none select-none items-center group-data-disabled/slider:pointer-events-none\">\n            <Slider.Track className=\"relative h-1.5 w-full rounded-full bg-fg/10 transition-colors duration-150 group-hover/control:bg-fg/14\">\n              <Slider.Indicator className=\"rounded-full bg-fg\" />\n              {bands.slice(1).map((b) => (\n                <span\n                  key={b.from}\n                  aria-hidden\n                  data-passed={count >= b.start || undefined}\n                  className=\"absolute top-1/2 h-2.5 w-px -translate-y-1/2 bg-fg-3 transition-colors duration-150 data-passed:bg-frame/60\"\n                  // Same inset as an edge-aligned 16px thumb, so a tick sits under the thumb's center at that count.\n                  style={{ left: `calc(8px + (100% - 16px) * ${b.left / 100})` }}\n                />\n              ))}\n              <Slider.Thumb\n                aria-label=\"Seats\"\n                getAriaValueText={(_, v) => {\n                  const t = tierFor(v, tiers);\n                  return `${v} ${v === 1 ? \"seat\" : \"seats\"}, ${money(t.tier.price)} each, ${money(t.total)} per ${interval}`;\n                }}\n                className=\"group/thumb size-4 outline-none before:absolute before:-inset-3.5 before:content-['']\"\n              >\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"absolute inset-0 rounded-full border-2 border-raised bg-fg shadow-[var(--shadow)]\",\n                    \"outline-offset-2 group-has-focus-visible/thumb:outline-1 group-has-focus-visible/thumb:outline-solid group-has-focus-visible/thumb:outline-fg-3\",\n                    \"transition-[scale,box-shadow] duration-150 ease-out-expo\",\n                    // Grab squashes the thumb a touch and rings it, so the hold is felt before anything moves.\n                    \"group-hover/control:scale-110 group-data-dragging/slider:scale-x-[1.3] group-data-dragging/slider:scale-y-[1.12] group-data-dragging/slider:ring-4 group-data-dragging/slider:ring-fg/10\",\n                    \"motion-reduce:transition-none\",\n                  )}\n                />\n              </Slider.Thumb>\n            </Slider.Track>\n          </Slider.Control>\n        </Slider.Root>\n\n        {/* The price bands as a legend, equal widths so a short band stays readable. Pressing one jumps to its first seat. */}\n        <div className=\"relative grid w-full auto-cols-fr grid-flow-col gap-1\">\n          {bands.map((b) => {\n            const on = b.i === index;\n            const range = b.end >= max && !tiers[b.i + 1] ? `${b.start}+` : b.start === b.end ? `${b.start}` : `${b.start}–${b.end}`;\n            return (\n              <button\n                key={b.from}\n                type=\"button\"\n                disabled={disabled}\n                onClick={() => set(b.start)}\n                aria-label={`${range} seats, ${money(b.price)} per seat`}\n                aria-pressed={on}\n                className={cn(\n                  \"group/band relative flex h-10 min-w-0 flex-col items-start justify-center rounded-md px-2 text-left outline-none\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                  \"transition-[color,scale] duration-150 active:scale-[0.97] active:duration-75\",\n                  on ? \"text-fg\" : \"text-fg-3 hover:text-fg-2\",\n                )}\n              >\n                {on && (\n                  <motion.span\n                    layoutId={pill}\n                    aria-hidden\n                    className=\"absolute inset-0 rounded-md border border-line-2 bg-hover\"\n                    transition={reduce ? { duration: 0 } : spring.snappy}\n                  />\n                )}\n                <span className=\"relative w-full truncate text-[12px] font-medium leading-4 tabular\">{money(b.price)}</span>\n                <span className=\"relative w-full truncate text-[11px] leading-4 text-fg-3 tabular\">{range}</span>\n              </button>\n            );\n          })}\n        </div>\n      </div>\n\n      <p id={hintId} className=\"relative h-4 overflow-hidden text-[12px] leading-4 text-fg-3\" aria-live=\"polite\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {hint.node != null && (\n            <motion.span\n              key={hint.key}\n              className=\"absolute inset-x-0 top-0 truncate\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, transition: { duration: 0.12, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n            >\n              {hint.node}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </p>\n    </div>\n  );\n}\n\nfunction StepButton({ part, label }: { part: \"increment\" | \"decrement\"; label: string }) {\n  const Part = part === \"increment\" ? NumberField.Increment : NumberField.Decrement;\n  return (\n    <Part\n      aria-label={label}\n      className={cn(\n        \"group/step relative grid w-9 shrink-0 place-items-center text-fg-2 outline-none\",\n        part === \"decrement\" ? \"rounded-l-[7px]\" : \"rounded-r-[7px]\",\n        \"transition-[background-color,color] duration-150 hover:bg-hover hover:text-fg\",\n        \"focus-visible:z-1 focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n        \"data-disabled:cursor-not-allowed data-disabled:text-fg-4 data-disabled:hover:bg-transparent\",\n        // Touch: 44px tall target without changing the drawing.\n        \"before:absolute before:-inset-y-1 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n      )}\n    >\n      <span className=\"transition-[scale] duration-100 ease-out group-active/step:scale-[0.8] group-data-disabled/step:scale-100\">\n        {part === \"increment\" ? <Plus size={14} /> : <Minus size={14} />}\n      </span>\n    </Part>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/seat-picker"
}
