{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-seed-slots",
  "type": "registry:ui",
  "title": "Seed Slots",
  "description": "A list of random seeds as content-sized chips: a number field, a die and a remove button that show on hover or focus (always on touch). Stable keys across removes and adds (or the consumer's keys), focus to the previous slot after a remove, renamable labels (slot, die, remove, randomize all), refusals that disable with a reason, and the add and Randomize all actions either inline or as a SeedSlotsActions part of 24px icon buttons for a row header. The consumer owns the randomness.",
  "dependencies": [
    "@base-ui/react",
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-number-field.json"
  ],
  "files": [
    {
      "path": "components/ui/seed-slots.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/seed-slots\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * SeedSlots — one or more random seeds, each a small chip with a number field\n * and a die that rolls a new one: the seed list of a generator that cycles\n * through seeds.\n *\n * Each slot holds a seed or nothing (null, an empty slot the consumer may read\n * as a rest). A slot's field commits once per gesture, as `NumberField` does\n * (Cmd/Ctrl+Enter included, before a face's play shortcut sees the key); its\n * die asks for a new random seed for that slot, and with several slots a\n * \"Randomize all\" button asks for every one at once. The component rolls\n * nothing itself: `onRandomize(index)` or `onRandomize()` goes to the\n * consumer, who owns the randomness and the value.\n *\n * ## Chips\n *\n * A chip is as wide as its seed (about 52px for one to four digits). Its die\n * and its remove button (24px square each) show while the chip is hovered or\n * holds focus, and always on a touch screen or any device without a hovering\n * fine pointer; hidden, they take no room, but stay in the accessibility tree\n * and in the Tab order (focusing one shows it). Slots number themselves once\n * there is more than one, can be removed while more than one is left, and\n * wrap to the available width.\n *\n * Keys are stable: the component keeps one per slot across removes and adds\n * (`seed-slots-math.ts`), or takes the consumer's `keys`, so removing a slot\n * never hands its neighbour's draft or focus to another seed. After a remove,\n * focus moves to the previous slot's field (or the next one's).\n *\n * ## Actions\n *\n * The add and Randomize all buttons sit after the chips (`actions=\"inline\"`,\n * the default), or the consumer places them itself, typically in the row's\n * header, with `actions=\"none\"` here and `<SeedSlotsActions>` there: the same\n * two actions as 24px icon buttons.\n *\n * Every action can be refused: a reason in `refusals` disables that button\n * and becomes its tooltip, so the user sees why (the list is full, the seed is\n * pinned). Every accessible name can be replaced (`slotLabel`, `dieLabel`,\n * `removeLabel`, `randomizeAllLabel`), so a consumer can say \"phrase\" rather\n * than \"slot\" everywhere.\n *\n * SSR-safe; built from the kit's NumberField, console icons and plain buttons.\n */\n\nimport {\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type JSX,\n  type ReactNode,\n} from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport { ConsoleIcon } from \"maruaudio/ui/shared/console-icons\"\nimport {\n  CONSOLE_FONT_SIZE,\n  ConsoleStyles,\n  consoleHeight,\n  type ConsoleSize,\n} from \"maruaudio/ui/shared/console-styles\"\nimport { NumberField } from \"@/components/ui/number-field\"\nimport {\n  initialSlotKeys,\n  reconcileSlotKeys,\n  seedFieldWidth,\n  slotFocusAfterRemove,\n  type SlotKeys,\n} from \"maruaudio/ui/shared/seed-slots-math\"\n\nexport interface SeedSlotsRefusals {\n  /** Why a slot cannot be added. */\n  readonly add?: string\n  /** Why the slots cannot all be re-rolled. */\n  readonly randomizeAll?: string\n  /** Why slot `index` cannot be re-rolled, or undefined when it can. */\n  readonly randomize?: (index: number) => string | undefined\n}\n\nexport interface SeedSlotsProps {\n  /** The seeds; null is an empty slot (a rest). */\n  readonly slots: readonly (number | null)[]\n  /** A slot's field committed a value (null when cleared). */\n  readonly onSlotCommit: (index: number, value: number | null) => void\n  /** Roll a new seed for slot `index`, or for every slot when undefined. */\n  readonly onRandomize: (index?: number) => void\n  /** Add a slot. Without it there is no add button. */\n  readonly onAdd?: () => void\n  /** Remove slot `index`. Offered per slot while there is more than one. */\n  readonly onRemove?: (index: number) => void\n  /** The add button's text. Defaults to \"+ Seed\" when empty and \"+ Slot\" otherwise. */\n  readonly addLabel?: string\n  /** Shown when there are no slots. */\n  readonly emptyLabel?: ReactNode\n  /** A reason disables that action and becomes its tooltip. */\n  readonly refusals?: SeedSlotsRefusals\n  /** The accessible name of each slot's field. Defaults to \"Seed\" / \"Seed of slot n\". */\n  readonly slotLabel?: (index: number, count: number) => string\n  /** The accessible name of slot `index`'s die. Defaults to \"New random seed\" / \"New random seed for slot n\". */\n  readonly dieLabel?: (index: number, count: number) => string\n  /** The accessible name of slot `index`'s remove button. Defaults to \"Remove slot n\". */\n  readonly removeLabel?: (index: number, count: number) => string\n  /** The Randomize all button's accessible name. Defaults to \"Randomize all\". */\n  readonly randomizeAllLabel?: string\n  /**\n   * A stable key per slot, when the consumer has ids of its own. Without it\n   * the component keeps keys itself across its own removes and adds.\n   */\n  readonly keys?: readonly string[]\n  /**\n   * Where the add and Randomize all buttons go: \"inline\" after the chips (the\n   * default), or \"none\" when the consumer renders `<SeedSlotsActions>` itself.\n   */\n  readonly actions?: \"inline\" | \"none\"\n  /** Chip height: \"sm\" 24px (the default), \"md\" 28px, \"lg\" 32px. */\n  readonly size?: ConsoleSize\n  readonly \"aria-labelledby\"?: string\n  readonly className?: string\n  readonly style?: CSSProperties\n}\n\n/** The default accessible name of a slot's field. */\nexport function defaultSeedSlotLabel(index: number, count: number): string {\n  return count > 1 ? `Seed of slot ${index + 1}` : \"Seed\"\n}\n\n/** The die's accessible name for slot `index` of `count`. */\nexport function seedDieLabel(index: number, count: number): string {\n  return count > 1 ? `New random seed for slot ${index + 1}` : \"New random seed\"\n}\n\n/** The remove button's accessible name for slot `index`. */\nexport function seedRemoveLabel(index: number, _count?: number): string {\n  return `Remove slot ${index + 1}`\n}\n\n/** Seeds are whole numbers, written without grouping so they copy cleanly. */\nconst SEED_FORMAT: Intl.NumberFormatOptions = {\n  maximumFractionDigits: 0,\n  useGrouping: false,\n}\n\nfunction buttonStyle(size: ConsoleSize): CSSProperties {\n  return {\n    boxSizing: \"border-box\",\n    display: \"inline-flex\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    gap: 4,\n    flex: \"none\",\n    height: consoleHeight(size),\n    minWidth: consoleHeight(size),\n    padding: 0,\n    margin: 0,\n    border: `1px solid ${token.border}`,\n    borderRadius: 6,\n    font: \"inherit\",\n    whiteSpace: \"nowrap\",\n    cursor: \"pointer\",\n  }\n}\n\n/**\n * A die showing five, drawn in the current colour.\n *\n * @deprecated Use `<ConsoleIcon name=\"die\" />` from `lib/console-icons`.\n */\nexport function DieIcon({ size = 14 }: { readonly size?: number }) {\n  return <ConsoleIcon name=\"die\" size={size} />\n}\n\nexport interface SeedSlotsActionsProps {\n  /** The same seeds the slots show: Randomize all appears once several hold a seed. */\n  readonly slots: readonly (number | null)[]\n  /** Called with no index: roll every slot. */\n  readonly onRandomize: (index?: number) => void\n  /** Add a slot. Without it there is no add button. */\n  readonly onAdd?: () => void\n  /** The add button's accessible name. Defaults to \"Add seed\" when empty and \"Add slot\" otherwise. */\n  readonly addLabel?: string\n  /** The Randomize all button's accessible name. Defaults to \"Randomize all\". */\n  readonly randomizeAllLabel?: string\n  readonly refusals?: Pick<SeedSlotsRefusals, \"add\" | \"randomizeAll\">\n  /** Button size: \"sm\" 24px (the default), \"md\" 28px, \"lg\" 32px. */\n  readonly size?: ConsoleSize\n  readonly className?: string\n  readonly style?: CSSProperties\n}\n\n/** Whether Randomize all means something: several slots, at least one set. */\nfunction offersRandomizeAll(slots: readonly (number | null)[]): boolean {\n  return slots.length > 1 && slots.some((slot) => slot !== null)\n}\n\n/**\n * The seed list's add and Randomize all actions as square icon buttons, for a\n * consumer to place in a row header beside `<SeedSlots actions=\"none\">`.\n */\nexport function SeedSlotsActions({\n  slots,\n  onRandomize,\n  onAdd,\n  addLabel,\n  randomizeAllLabel = \"Randomize all\",\n  refusals,\n  size = \"sm\",\n  className,\n  style,\n}: SeedSlotsActionsProps): JSX.Element {\n  const addName = addLabel ?? (slots.length === 0 ? \"Add seed\" : \"Add slot\")\n  const square: CSSProperties = {\n    ...buttonStyle(size),\n    width: consoleHeight(size),\n    border: 0,\n  }\n  return (\n    <span\n      data-seed-slots-actions=\"\"\n      className={className}\n      style={{ display: \"inline-flex\", alignItems: \"center\", gap: 2, ...style }}\n    >\n      <ConsoleStyles />\n      {onAdd ? (\n        <button\n          type=\"button\"\n          aria-label={addName}\n          disabled={refusals?.add !== undefined}\n          data-uikit-console-button=\"\"\n          aria-description={refusals?.add}\n          data-seed-add=\"\"\n          onClick={onAdd}\n          style={square}\n        >\n          <ConsoleIcon name=\"plus\" />\n        </button>\n      ) : null}\n      {offersRandomizeAll(slots) ? (\n        <button\n          type=\"button\"\n          aria-label={randomizeAllLabel}\n          disabled={refusals?.randomizeAll !== undefined}\n          data-uikit-console-button=\"\"\n          aria-description={refusals?.randomizeAll}\n          data-seed-randomize-all=\"\"\n          onClick={() => onRandomize(undefined)}\n          style={square}\n        >\n          <ConsoleIcon name=\"die\" />\n        </button>\n      ) : null}\n    </span>\n  )\n}\n\n/** A remove the user asked for, waiting for the consumer's new list. */\ninterface PendingRemove {\n  readonly index: number\n  readonly removedKey: string\n  readonly focusKey: string | null\n}\n\nexport function SeedSlots({\n  slots,\n  onSlotCommit,\n  onRandomize,\n  onAdd,\n  onRemove,\n  addLabel,\n  emptyLabel,\n  refusals,\n  slotLabel = defaultSeedSlotLabel,\n  dieLabel = seedDieLabel,\n  removeLabel = seedRemoveLabel,\n  randomizeAllLabel = \"Randomize all\",\n  keys: consumerKeys,\n  actions = \"inline\",\n  size = \"sm\",\n  \"aria-labelledby\": ariaLabelledBy,\n  className,\n  style,\n}: SeedSlotsProps): JSX.Element {\n  const count = slots.length\n  const several = count > 1\n  const showAll = offersRandomizeAll(slots)\n  const addText = addLabel ?? (count === 0 ? \"+ Seed\" : \"+ Slot\")\n  const rootRef = useRef<HTMLDivElement | null>(null)\n  const slotElements = useRef(new Map<string, HTMLDivElement>())\n  const [pending, setPending] = useState<PendingRemove | null>(null)\n\n  // Keys that follow their seeds, adjusted while rendering when the list's\n  // length changes (React's \"adjust state when a prop changes\").\n  const [own, setOwn] = useState<SlotKeys>(() => initialSlotKeys(count))\n  let ownKeys = own\n  if (!consumerKeys && own.keys.length !== count) {\n    ownKeys = reconcileSlotKeys(own, count, pending?.index ?? null)\n    setOwn(ownKeys)\n  }\n  const keys =\n    consumerKeys && consumerKeys.length === count ? consumerKeys : ownKeys.keys\n\n  // Once the removed slot is gone, focus its neighbour's field.\n  useLayoutEffect(() => {\n    if (!pending || keys.includes(pending.removedKey)) return\n    setPending(null)\n    const slot =\n      pending.focusKey === null\n        ? undefined\n        : slotElements.current.get(pending.focusKey)\n    const target =\n      slot?.querySelector<HTMLElement>(\"input[data-number-field-input]\") ??\n      rootRef.current?.querySelector<HTMLElement>(\"[data-seed-add]\") ??\n      null\n    target?.focus()\n  }, [keys, pending])\n\n  const remove = (index: number) => {\n    const removedKey = keys[index]\n    if (removedKey !== undefined) {\n      setPending({\n        index,\n        removedKey,\n        focusKey: slotFocusAfterRemove(keys, index),\n      })\n    }\n    onRemove?.(index)\n  }\n\n  const chip = buttonStyle(size)\n  const action = {\n    ...chip,\n    // Width comes from the stylesheet, which folds a hidden action to 0; the\n    // size it unfolds to is handed over as a variable.\n    minWidth: undefined,\n    border: 0,\n    \"--seed-action-size\": consoleHeight(size),\n  } as CSSProperties\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-labelledby={ariaLabelledBy}\n      data-seed-slots=\"\"\n      className={className}\n      style={{\n        boxSizing: \"border-box\",\n        display: \"flex\",\n        flexWrap: \"wrap\",\n        alignItems: \"center\",\n        gap: 6,\n        minWidth: 0,\n        fontFamily: token.font,\n        fontSize: CONSOLE_FONT_SIZE,\n        color: token.text,\n        ...style,\n      }}\n    >\n      <ConsoleStyles />\n      {count === 0 && emptyLabel !== undefined && emptyLabel !== null ? (\n        <span data-seed-slots-empty=\"\" style={{ color: token.textMuted }}>\n          {emptyLabel}\n        </span>\n      ) : null}\n      {slots.map((slot, index) => {\n        const key = keys[index] ?? `slot-at-${index}`\n        const refusal = refusals?.randomize?.(index)\n        const die = dieLabel(index, count)\n        const removeName = removeLabel(index, count)\n        return (\n          <div\n            key={key}\n            ref={(element) => {\n              if (element) slotElements.current.set(key, element)\n              else slotElements.current.delete(key)\n            }}\n            data-seed-slot={index}\n            data-seed-slot-key={key}\n            style={{\n              display: \"inline-flex\",\n              alignItems: \"center\",\n              flex: \"none\",\n              minWidth: 0,\n            }}\n          >\n            {several ? (\n              <span\n                aria-hidden=\"true\"\n                data-seed-slot-badge=\"\"\n                style={{\n                  flex: \"none\",\n                  minWidth: 10,\n                  marginRight: 3,\n                  color: token.textMuted,\n                  fontFamily: token.fontMono,\n                  fontSize: 11,\n                  textAlign: \"right\",\n                }}\n              >\n                {index + 1}\n              </span>\n            ) : null}\n            <NumberField\n              value={slot}\n              onValueCommitted={(value) => onSlotCommit(index, value)}\n              aria-label={slotLabel(index, count)}\n              placeholder=\"empty\"\n              format={SEED_FORMAT}\n              size={size}\n              inputWidth={seedFieldWidth(slot)}\n              style={{ flex: \"none\", minWidth: 0 }}\n            />\n            <button\n              type=\"button\"\n              aria-label={die}\n              disabled={refusal !== undefined}\n              data-uikit-console-button=\"\"\n              data-seed-slot-action=\"die\"\n              aria-description={refusal}\n              data-seed-die={index}\n              onClick={() => onRandomize(index)}\n              style={action}\n            >\n              <ConsoleIcon name=\"die\" />\n            </button>\n            {several && onRemove ? (\n              <button\n                type=\"button\"\n                aria-label={removeName}\n                data-uikit-console-button=\"\"\n                data-seed-slot-action=\"remove\"\n                data-seed-remove={index}\n                onClick={() => remove(index)}\n                style={action}\n              >\n                <ConsoleIcon name=\"close\" size={12} />\n              </button>\n            ) : null}\n          </div>\n        )\n      })}\n      {actions === \"inline\" && (onAdd || showAll) ? (\n        <div style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n          {onAdd ? (\n            <button\n              type=\"button\"\n              disabled={refusals?.add !== undefined}\n              data-uikit-console-button=\"\"\n              aria-description={refusals?.add}\n              data-seed-add=\"\"\n              onClick={onAdd}\n              style={{ ...chip, padding: \"0 8px\" }}\n            >\n              {addText}\n            </button>\n          ) : null}\n          {showAll ? (\n            <button\n              type=\"button\"\n              aria-label={randomizeAllLabel}\n              disabled={refusals?.randomizeAll !== undefined}\n              data-uikit-console-button=\"\"\n              aria-description={refusals?.randomizeAll}\n              data-seed-randomize-all=\"\"\n              onClick={() => onRandomize(undefined)}\n              style={{ ...chip, padding: \"0 8px\" }}\n            >\n              <ConsoleIcon name=\"die\" size={12} />\n              <span aria-hidden=\"true\">All</span>\n            </button>\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/seed-slots.tsx"
    }
  ]
}
