{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-scale-picker",
  "type": "registry:ui",
  "title": "Scale Picker",
  "description": "A key as a tonic and a mode: twelve tonic buttons coloured like piano keys on a Base UI ToggleGroup (arrow keys, aria-pressed, never cleared by a second press, at least 24px square, the tonic filled with the value colour), and a Base UI Select of grouped modes named by its visible label and read with its value, portaled fullscreen-safe with the face's theme. Refused tonics are disabled with their reason; both values may be unset with a placeholder; renamable labels; one row of twelve or two of six.",
  "dependencies": [
    "@base-ui/react",
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/scale-picker.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/scale-picker\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 * ScalePicker — a key as a tonic and a mode: twelve tonic buttons coloured\n * like a piano's keys, and a grouped mode menu.\n *\n * The tonics are a Base UI ToggleGroup, one pressed at a time and never none\n * once one is chosen: arrow keys move between them, `aria-pressed` says which\n * is the tonic, and each is named \"Tonic C\", \"Tonic C♯\" and so on from the\n * consumer's `tonicLabels` (which may spell flats, or change with the mode).\n * A tonic the consumer cannot accept is disabled with its reason as tooltip.\n * Each tonic is at least the sm control height (24px) wide and tall; the\n * pressed one is filled with the value colour.\n *\n * The mode is a Base UI Select with option groups, in the kit's look rather\n * than the platform's: a trigger named by its visible \"Mode\" label that opens\n * a listbox on click, Enter, Space or the arrow keys, typeahead included; the\n * list is portaled (into the fullscreen element when the face is fullscreen)\n * and carries the face's theme variables across.\n *\n * Controlled: both values may be undefined while the key is not set yet; the\n * trigger then shows `modePlaceholder`. `columns` lays the tonics out in one\n * row of twelve or two rows of six for a narrow palette.\n *\n * SSR-safe (the list mounts only when opened); inline layout and theme\n * tokens, with the kit's console stylesheet for hover and focus.\n */\n\nimport { useId, useRef, useState, type CSSProperties, type JSX } from \"react\"\nimport { Select } from \"@base-ui/react/select\"\nimport { Toggle } from \"@base-ui/react/toggle\"\nimport { ToggleGroup } from \"@base-ui/react/toggle-group\"\nimport { isAccidentalPitchClass } from \"maruaudio/ui/shared/music-theory-pitch-class\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport { ConsoleIcon } from \"maruaudio/ui/shared/console-icons\"\nimport {\n  CONSOLE_FONT_SIZE,\n  CONSOLE_ON_VALUE,\n  CONSOLE_VALUE,\n  ConsoleStyles,\n  consoleHeight,\n} from \"maruaudio/ui/shared/console-styles\"\nimport {\n  OVERLAY_POSITIONER_DEFAULTS,\n  overlayPopupStyle,\n  overlayContainerFor,\n} from \"maruaudio/ui/shared/overlay-container\"\n\n/** Whether a pitch class is a raised key on a piano (C♯, D♯, F♯, G♯, A♯). Music-theory's. */\nexport { isAccidentalPitchClass }\n\nexport interface ScalePickerModeGroup {\n  readonly label: string\n  readonly options: readonly string[]\n}\n\nexport interface ScalePickerProps {\n  readonly positionerProps?: Omit<Select.Positioner.Props, \"children\">\n  /** The tonic's pitch class, 0–11 (0 = C); undefined while unset. */\n  readonly tonic: number | undefined\n  /** The mode; undefined while unset. */\n  readonly mode: string | undefined\n  /** Twelve tonic labels, index 0 = C. */\n  readonly tonicLabels: readonly string[]\n  /** The modes on offer, in option groups. */\n  readonly modeGroups: readonly ScalePickerModeGroup[]\n  readonly onTonicChange: (pitchClass: number) => void\n  readonly onModeChange: (mode: string) => void\n  /** A reason disables that tonic and becomes its tooltip. */\n  readonly tonicRefusal?: (pitchClass: number) => string | undefined\n  /** Shown in the mode trigger while `mode` is undefined. */\n  readonly modePlaceholder?: string\n  /** Tonics per row. Defaults to 12. */\n  readonly columns?: 6 | 12\n  /** The visible label of the mode menu, which also names it. Defaults to \"Mode\". */\n  readonly modeLabel?: string\n  /** The tonic group's accessible name, and each tonic's name prefix. Defaults to \"Tonic\". */\n  readonly tonicLabel?: string\n  readonly \"aria-labelledby\"?: string\n  readonly className?: string\n  readonly style?: CSSProperties\n}\n\nconst PITCH_CLASSES = Array.from({ length: 12 }, (_, pitchClass) => pitchClass)\nconst HEIGHT = consoleHeight(\"sm\")\n\n/** Theme variables copied onto the portaled list, which leaves the face's tree. */\nconst PORTAL_VARIABLES = [\n  \"--uikit-accent\",\n  \"--uikit-surface\",\n  \"--uikit-cap\",\n  \"--uikit-border\",\n  \"--uikit-text\",\n  \"--uikit-text-muted\",\n  \"--uikit-focus\",\n  \"--uikit-value\",\n  \"--uikit-font\",\n  \"--uikit-font-mono\",\n  \"--uikit-menu-surface\",\n  \"--uikit-menu-text\",\n  \"--uikit-menu-border\",\n  \"--uikit-menu-hover\",\n  \"--uikit-control-h-sm\",\n  \"--uikit-control-h-md\",\n] as const\n\nfunction portalTheme(element: Element | null): Record<string, string> {\n  const view = element?.ownerDocument.defaultView\n  if (!element || !view) return {}\n  const computed = view.getComputedStyle(element)\n  const values: Record<string, string> = {}\n  for (const name of PORTAL_VARIABLES) {\n    const value = computed.getPropertyValue(name).trim()\n    if (value) values[name] = value\n  }\n  return values\n}\n\n/** A tonic's face: pressed in the value colour, else a pale or dark key. */\nfunction tonicColors(pressed: boolean, accidental: boolean): CSSProperties {\n  if (pressed) {\n    return {\n      background: CONSOLE_VALUE,\n      borderColor: CONSOLE_VALUE,\n      color: CONSOLE_ON_VALUE,\n    }\n  }\n  return accidental\n    ? {\n        background: `color-mix(in srgb, ${token.surface} 70%, black)`,\n        borderColor: token.border,\n        color: token.textMuted,\n      }\n    : {\n        background: `color-mix(in srgb, ${token.text} 16%, ${token.cap})`,\n        borderColor: token.border,\n        color: token.text,\n      }\n}\n\nexport function ScalePicker({\n  tonic,\n  mode,\n  tonicLabels,\n  modeGroups,\n  onTonicChange,\n  onModeChange,\n  tonicRefusal,\n  modePlaceholder = \"Choose a mode\",\n  columns = 12,\n  modeLabel = \"Mode\",\n  tonicLabel = \"Tonic\",\n  \"aria-labelledby\": ariaLabelledBy,\n  className,\n  style,\n  positionerProps,\n}: ScalePickerProps): JSX.Element {\n  const modeLabelId = useId()\n  const modeValueId = useId()\n  const rootRef = useRef<HTMLDivElement | null>(null)\n  const [layer, setLayer] = useState<{\n    container: HTMLElement | null\n    theme: Record<string, string>\n  }>({ container: null, theme: {} })\n  const tonicValue = tonic === undefined ? [] : [String(tonic)]\n  const known = modeGroups.some((group) => group.options.includes(mode ?? \"\"))\n  const extra = mode !== undefined && !known ? mode : undefined\n  const items = [\n    ...(extra === undefined ? [] : [extra]),\n    ...modeGroups.flatMap((group) => group.options),\n  ].map((option) => ({ value: option, label: option }))\n\n  const itemStyle: CSSProperties = {\n    boxSizing: \"border-box\",\n    display: \"grid\",\n    gridTemplateColumns: \"14px 1fr\",\n    alignItems: \"center\",\n    gap: 6,\n    minHeight: consoleHeight(\"md\"),\n    padding: \"0 10px 0 6px\",\n    borderRadius: 5,\n    cursor: \"pointer\",\n    outline: \"none\",\n    whiteSpace: \"nowrap\",\n  }\n  const item = (option: string) => (\n    <Select.Item\n      key={option}\n      value={option}\n      label={option}\n      data-scale-mode-item={option}\n      style={itemStyle}\n    >\n      <Select.ItemIndicator\n        style={{ display: \"inline-flex\", color: token.text }}\n      >\n        <ConsoleIcon name=\"check\" size={12} />\n      </Select.ItemIndicator>\n      <Select.ItemText style={{ gridColumn: 2 }}>{option}</Select.ItemText>\n    </Select.Item>\n  )\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-labelledby={ariaLabelledBy}\n      data-scale-picker=\"\"\n      className={className}\n      style={{\n        boxSizing: \"border-box\",\n        display: \"grid\",\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      <ToggleGroup<string>\n        value={tonicValue}\n        onValueChange={(next) => {\n          const chosen = next.find((candidate) => candidate !== String(tonic))\n          if (chosen !== undefined) onTonicChange(Number(chosen))\n        }}\n        aria-label={tonicLabel}\n        data-scale-tonics=\"\"\n        style={{\n          display: \"grid\",\n          gridTemplateColumns: `repeat(${columns}, minmax(${HEIGHT}, 1fr))`,\n          gap: 2,\n          minWidth: 0,\n        }}\n      >\n        {PITCH_CLASSES.map((pitchClass) => {\n          const label = tonicLabels[pitchClass] ?? String(pitchClass)\n          const refusal = tonicRefusal?.(pitchClass)\n          const accidental = isAccidentalPitchClass(pitchClass)\n          const pressed = tonic === pitchClass\n          return (\n            <Toggle<string>\n              key={pitchClass}\n              value={String(pitchClass)}\n              aria-label={`${tonicLabel} ${label}`}\n              aria-description={refusal}\n              disabled={refusal !== undefined}\n              data-scale-tonic={pitchClass}\n              data-accidental={accidental ? \"\" : undefined}\n              onPressedChange={(nextPressed, details) => {\n                // The tonic is never cleared by pressing it again.\n                if (!nextPressed) details.cancel()\n              }}\n              style={{\n                boxSizing: \"border-box\",\n                minWidth: HEIGHT,\n                height: HEIGHT,\n                padding: 0,\n                margin: 0,\n                borderWidth: 1,\n                borderStyle: \"solid\",\n                borderRadius: 4,\n                fontFamily: \"inherit\",\n                fontSize: 11,\n                fontWeight: pressed ? 700 : 500,\n                lineHeight: 1,\n                whiteSpace: \"nowrap\",\n                overflow: \"hidden\",\n                cursor: refusal !== undefined ? \"not-allowed\" : \"pointer\",\n                ...tonicColors(pressed, accidental),\n              }}\n            >\n              {label}\n            </Toggle>\n          )\n        })}\n      </ToggleGroup>\n      <div\n        style={{ display: \"flex\", alignItems: \"center\", gap: 6, minWidth: 0 }}\n      >\n        <span\n          id={modeLabelId}\n          data-scale-mode-label=\"\"\n          style={{ flex: \"none\", color: token.textMuted, fontSize: 11 }}\n        >\n          {modeLabel}\n        </span>\n        <Select.Root<string>\n          value={mode ?? null}\n          onValueChange={(next) => {\n            if (next !== null && next !== \"\") onModeChange(next)\n          }}\n          onOpenChange={(open) => {\n            if (open)\n              setLayer({\n                container: overlayContainerFor(rootRef.current),\n                theme: portalTheme(rootRef.current),\n              })\n          }}\n          items={items}\n          modal={false}\n        >\n          <Select.Trigger\n            // Named \"Mode\" and read with its value: \"Mode, dorian\".\n            aria-labelledby={`${modeLabelId} ${modeValueId}`}\n            data-scale-mode-trigger=\"\"\n            style={{\n              boxSizing: \"border-box\",\n              display: \"flex\",\n              alignItems: \"center\",\n              justifyContent: \"space-between\",\n              gap: 6,\n              flex: \"1 1 auto\",\n              minWidth: 0,\n              height: HEIGHT,\n              padding: \"0 6px 0 8px\",\n              margin: 0,\n              borderWidth: 1,\n              borderStyle: \"solid\",\n              borderRadius: 6,\n              font: \"inherit\",\n              textAlign: \"left\",\n              cursor: \"pointer\",\n            }}\n          >\n            <Select.Value\n              id={modeValueId}\n              placeholder={modePlaceholder}\n              data-scale-mode-value=\"\"\n              style={{\n                minWidth: 0,\n                overflow: \"hidden\",\n                textOverflow: \"ellipsis\",\n                whiteSpace: \"nowrap\",\n                color: mode === undefined ? token.textMuted : token.text,\n              }}\n            />\n            <Select.Icon\n              style={{ display: \"inline-flex\", color: token.textMuted }}\n            >\n              <ConsoleIcon name=\"chevron-down\" size={12} />\n            </Select.Icon>\n          </Select.Trigger>\n          <Select.Portal container={layer.container}>\n            <Select.Positioner\n              {...OVERLAY_POSITIONER_DEFAULTS}\n              side=\"bottom\"\n              align=\"start\"\n              sideOffset={4}\n              collisionPadding={8}\n              alignItemWithTrigger={false}\n              style={{ ...layer.theme, zIndex: 2147483647, outline: \"none\" }}\n              {...positionerProps}\n            >\n              <Select.Popup\n                data-scale-mode-popup=\"\"\n                style={{\n                  ...overlayPopupStyle({\n                    maxWidth: \"22rem\",\n                    maxHeight: \"22rem\",\n                  }),\n                  minWidth: \"min(var(--anchor-width), var(--available-width))\",\n                  padding: 4,\n                  borderRadius: 8,\n                  border: `1px solid var(--uikit-menu-border, ${token.border})`,\n                  background: `var(--uikit-menu-surface, ${token.cap})`,\n                  color: `var(--uikit-menu-text, ${token.text})`,\n                  boxShadow: \"0 18px 42px rgb(0 0 0 / .38)\",\n                  fontFamily: token.font,\n                  fontSize: CONSOLE_FONT_SIZE,\n                }}\n              >\n                <ConsoleStyles />\n                <Select.List>\n                  {extra === undefined ? null : item(extra)}\n                  {modeGroups.map((group) => (\n                    <Select.Group\n                      key={group.label}\n                      data-scale-mode-group={group.label}\n                    >\n                      <Select.GroupLabel\n                        data-scale-mode-group-label=\"\"\n                        style={{\n                          padding: \"6px 10px 2px 26px\",\n                          fontSize: 11,\n                          fontWeight: 600,\n                        }}\n                      >\n                        {group.label}\n                      </Select.GroupLabel>\n                      {group.options.map(item)}\n                    </Select.Group>\n                  ))}\n                </Select.List>\n              </Select.Popup>\n            </Select.Positioner>\n          </Select.Portal>\n        </Select.Root>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/scale-picker.tsx"
    }
  ]
}
