{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-rhythm-step-grid",
  "type": "registry:ui",
  "title": "Rhythm Step Grid",
  "description": "One cycle of a rhythm as a row of steps (a note's first step in the value fill, held steps tinted, rests with an edge at least 3:1 against the card), each note one bar, a stronger edge on every beat. Click a step to add or remove a note, drag a note to set its end (committed on release, a lost pointer capture or a release outside), or Shift+arrow on it; a roving Tab stop moves with the arrows, Home and End. One commit per edit; a refused edit is announced in a status line. The row maths sit in a pure core beside it.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/rhythm-step-grid.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/rhythm-step-grid\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 * RhythmStepGrid — one cycle of a rhythm as a row of steps, edited in place.\n *\n * Each step is a note's first step (solid value fill, `--uikit-value`), a\n * step holding that note (tinted), or a rest (the surface inside an edge that\n * stands 3:1 against it, so empty steps stay visible); a note's steps join\n * into one bar that rounds off at its last step, and a stronger edge marks\n * each beat.\n *\n *   - Click a step (or press Enter/Space on it) to add or remove a note: a\n *     rest becomes a one-step note, a note's first step becomes a rest with\n *     its tail, and a held step starts a new note there.\n *   - Drag a note sideways to set where it ends. The new length shows while\n *     dragging and is committed on release, including a release outside the\n *     grid or a lost pointer capture. A pointer that leaves before the drag\n *     has moved does not end it; a move with no button held does.\n *   - Shift+ArrowLeft/Right on a note shortens or lengthens it by a step.\n *   - ArrowLeft/Right, Home and End move between steps; the row is one Tab\n *     stop (roving tabindex), and the focused step stays focused across a\n *     commit.\n *\n * Every edit goes out whole through `onCellsCommit`, once per click, key press\n * or finished drag. An edit `refusal` objects to (by default, a row with no\n * note left) is not committed; its reason is announced in a `role=\"status\"`\n * line under the grid instead.\n *\n * Controlled and SSR-safe: `cells` is the truth; the component keeps only the\n * drag in progress, the focused step and the last refusal.\n */\n\nimport {\n  useRef,\n  useState,\n  type CSSProperties,\n  type JSX,\n  type KeyboardEvent,\n  type MouseEvent,\n  type PointerEvent,\n} from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  CONSOLE_FONT_SIZE,\n  CONSOLE_VALUE,\n  ConsoleStyles,\n  consoleHeight,\n} from \"maruaudio/ui/shared/console-styles\"\nimport {\n  defaultRhythmCellLabel,\n  defaultRhythmRefusal,\n  noteEndOf,\n  noteStartOf,\n  nudgeRhythmNote,\n  rhythmCellShape,\n  rhythmStepAt,\n  sameRhythmCells,\n  setRhythmNoteEnd,\n  toggleRhythmCell,\n  type RhythmCell,\n} from \"maruaudio/ui/shared/rhythm-step-grid-math\"\n\nexport interface RhythmStepGridProps {\n  readonly cells: readonly RhythmCell[]\n  /** Once per click, key press or finished drag, with the whole row. */\n  readonly onCellsCommit: (cells: RhythmCell[]) => void\n  /** Steps per beat, for a stronger edge at each beat (0: none). */\n  readonly beat?: number\n  /**\n   * Why `cells` cannot be committed (e.g. no onset left), or undefined; a\n   * refused edit is not committed and the reason is announced in a\n   * role=status line. Default: refuse a row without an onset: \"A rhythm needs\n   * at least one note.\"\n   */\n  readonly refusal?: (cells: readonly RhythmCell[]) => string | undefined\n  /** Accessible name of step `index`. Defaults to \"Step n: note, k steps long\" and so on. */\n  readonly cellLabel?: (cells: readonly RhythmCell[], index: number) => string\n  /** Cell height in pixels. Defaults to the md control height (`--uikit-control-h-md`, 28px). */\n  readonly height?: number\n  readonly \"aria-labelledby\"?: string\n  readonly \"aria-describedby\"?: string\n  readonly className?: string\n  readonly style?: CSSProperties\n}\n\ninterface Drag {\n  readonly onset: number\n  end: number\n  moved: boolean\n  readonly pointerId: number\n}\n\nconst RADIUS = 4\n\nfunction cellColors(cell: RhythmCell): CSSProperties {\n  if (cell === \"onset\")\n    return { background: CONSOLE_VALUE, borderColor: CONSOLE_VALUE }\n  if (cell === \"hold\") {\n    return {\n      background: `color-mix(in srgb, ${CONSOLE_VALUE} 45%, ${token.surface})`,\n      borderColor: `color-mix(in srgb, ${CONSOLE_VALUE} 60%, ${token.surface})`,\n    }\n  }\n  // A rest's edge is the muted text colour: at least 3:1 against the card.\n  return { background: token.surface, borderColor: token.textMuted }\n}\n\nexport function RhythmStepGrid({\n  cells,\n  onCellsCommit,\n  beat = 0,\n  refusal = defaultRhythmRefusal,\n  cellLabel = defaultRhythmCellLabel,\n  height,\n  \"aria-labelledby\": ariaLabelledBy,\n  \"aria-describedby\": ariaDescribedBy,\n  className,\n  style,\n}: RhythmStepGridProps): JSX.Element {\n  const [draft, setDraft] = useState<readonly RhythmCell[] | null>(null)\n  const [focusIndex, setFocusIndex] = useState(0)\n  const [problem, setProblem] = useState<string | null>(null)\n  const gridRef = useRef<HTMLDivElement>(null)\n  const cellRefs = useRef<(HTMLButtonElement | null)[]>([])\n  const drag = useRef<Drag | null>(null)\n  const suppressClick = useRef(false)\n\n  // A draft from a row of another length is stale (the consumer changed the\n  // grid mid-drag); it is dropped rather than drawn.\n  const shown = draft && draft.length === cells.length ? draft : cells\n  const focused = Math.max(0, Math.min(focusIndex, shown.length - 1))\n\n  const commit = (next: RhythmCell[]) => {\n    setDraft(null)\n    const reason = refusal(next)\n    if (reason !== undefined) {\n      setProblem(reason)\n      return\n    }\n    setProblem(null)\n    if (!sameRhythmCells(next, cells)) onCellsCommit(next)\n  }\n\n  const indexAt = (clientX: number) => {\n    const rect = gridRef.current?.getBoundingClientRect()\n    if (!rect) return undefined\n    return rhythmStepAt(clientX - rect.left, rect.width, cells.length)\n  }\n\n  const endDrag = () => {\n    const current = drag.current\n    drag.current = null\n    if (!current?.moved) return\n    suppressClick.current = true\n    const grid = gridRef.current\n    try {\n      if (grid?.hasPointerCapture?.(current.pointerId))\n        grid.releasePointerCapture(current.pointerId)\n    } catch {\n      // The pointer is already gone; nothing to release.\n    }\n    commit(setRhythmNoteEnd(cells, current.onset, current.end))\n  }\n\n  const moveFocus = (index: number) => {\n    const next = Math.max(0, Math.min(shown.length - 1, index))\n    setFocusIndex(next)\n    cellRefs.current[next]?.focus()\n  }\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const right = event.key === \"ArrowRight\"\n    if (right || event.key === \"ArrowLeft\") {\n      event.preventDefault()\n      if (event.shiftKey) {\n        const next = nudgeRhythmNote(cells, focused, right ? 1 : -1)\n        if (next) commit(next)\n      } else moveFocus(focused + (right ? 1 : -1))\n    } else if (event.key === \"Home\" || event.key === \"End\") {\n      event.preventDefault()\n      moveFocus(event.key === \"Home\" ? 0 : shown.length - 1)\n    }\n  }\n\n  return (\n    <div\n      data-rhythm-step-grid=\"\"\n      className={className}\n      style={{\n        display: \"grid\",\n        minWidth: 0,\n        fontFamily: token.font,\n        fontSize: CONSOLE_FONT_SIZE,\n        ...style,\n      }}\n    >\n      <ConsoleStyles />\n      <div\n        ref={gridRef}\n        role=\"group\"\n        aria-labelledby={ariaLabelledBy}\n        aria-describedby={ariaDescribedBy}\n        data-rhythm-grid=\"\"\n        style={{\n          display: \"grid\",\n          gridTemplateColumns: `repeat(${Math.max(1, shown.length)}, minmax(0, 1fr))`,\n          gap: 2,\n          minWidth: 0,\n          touchAction: \"none\",\n          userSelect: \"none\",\n        }}\n        onKeyDown={onKeyDown}\n        onPointerDown={(event: PointerEvent<HTMLDivElement>) => {\n          suppressClick.current = false\n          if (event.button !== 0) return\n          const index = indexAt(event.clientX)\n          if (index === undefined || cells[index] === \"rest\") return\n          const onset = noteStartOf(cells, index)\n          if (cells[onset] !== \"onset\") return\n          drag.current = {\n            onset,\n            end: noteEndOf(cells, onset),\n            moved: false,\n            pointerId: event.pointerId,\n          }\n        }}\n        onPointerMove={(event: PointerEvent<HTMLDivElement>) => {\n          if (drag.current && event.buttons === 0) {\n            // The button came up where the grid could not hear it.\n            endDrag()\n            return\n          }\n          const current = drag.current\n          const index = current ? indexAt(event.clientX) : undefined\n          if (\n            !current ||\n            index === undefined ||\n            index === current.end ||\n            index < current.onset\n          )\n            return\n          if (!current.moved) {\n            // Capture only once the press has become a drag: capturing on\n            // press would retarget a plain click at the grid.\n            try {\n              event.currentTarget.setPointerCapture?.(event.pointerId)\n            } catch {\n              // Synthetic or already-ended pointer: the leave handler covers it.\n            }\n          }\n          current.end = index\n          current.moved = true\n          setDraft(setRhythmNoteEnd(cells, current.onset, index))\n        }}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onLostPointerCapture={endDrag}\n        onPointerLeave={(event: PointerEvent<HTMLDivElement>) => {\n          // Without a capture (the pointer could not be captured), leaving\n          // ends a drag that has moved; a press that has not moved yet keeps\n          // its drag, since the first move may land outside the grid.\n          const current = drag.current\n          if (!current?.moved) return\n          if (event.currentTarget.hasPointerCapture?.(current.pointerId)) return\n          endDrag()\n        }}\n      >\n        {shown.map((cell, index) => {\n          const shape = rhythmCellShape(shown, index)\n          const left = shape.start ? RADIUS : 0\n          const right = shape.end ? RADIUS : 0\n          const beatEdge = beat > 0 && index % beat === 0\n          return (\n            <button\n              key={index}\n              ref={(element) => {\n                cellRefs.current[index] = element\n              }}\n              type=\"button\"\n              tabIndex={index === focused ? 0 : -1}\n              aria-pressed={cell !== \"rest\"}\n              aria-label={cellLabel(shown, index)}\n              data-rhythm-cell={cell}\n              data-beat={beatEdge ? \"\" : undefined}\n              onFocus={() => setFocusIndex(index)}\n              onClick={(event: MouseEvent<HTMLButtonElement>) => {\n                // A drag's release is not a click, but a key press (detail 0) always is.\n                const dragged = suppressClick.current\n                suppressClick.current = false\n                if (dragged && event.detail !== 0) return\n                commit(toggleRhythmCell(cells, index))\n              }}\n              style={{\n                boxSizing: \"border-box\",\n                minWidth: 0,\n                height: height ?? consoleHeight(\"md\"),\n                padding: 0,\n                margin: 0,\n                borderWidth: 1,\n                borderStyle: \"solid\",\n                borderRadius: `${left}px ${right}px ${right}px ${left}px`,\n                boxShadow: beatEdge\n                  ? `inset 2px 0 ${token.textMuted}`\n                  : undefined,\n                cursor: \"pointer\",\n                ...cellColors(cell),\n              }}\n            />\n          )\n        })}\n      </div>\n      <p\n        role=\"status\"\n        data-rhythm-status=\"\"\n        style={{\n          margin: problem ? \"4px 0 0\" : 0,\n          color: token.danger,\n          fontSize: 11,\n          lineHeight: 1.3,\n        }}\n      >\n        {problem}\n      </p>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/rhythm-step-grid.tsx"
    }
  ]
}
