{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-tag-browser",
  "type": "registry:ui",
  "title": "Tag Browser",
  "description": "A controlled category/keyword chip browser in the style of a hardware preset browser: a column per category from 560 px and stacked, wrapping categories below (no horizontal scroll at 360 px), per-chip match counts, zero-count chips disabled in browse mode, single-select replacement, an up-to-n limit that refuses and announces, per-category and global clear, and an optional search filter. ARIA group/checkbox semantics with one roving tab stop; arrows move across columns and within them, Space/Enter toggle. Quiet, Panel and Glass skins over the same headless useTagBrowser hook, with the rules as a pure unit-tested model.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/tag-browser.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/tag-browser\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 * TagBrowser — a category/keyword chip browser in the style of a hardware\n * preset browser: a column per category (Instrument, Character, Style…),\n * chips with match counts, a per-category clear, a global clear and an\n * optional search that filters the chips.\n *\n * It is controlled (`value`/`onChange`) and the rules live in\n * `tag-browser-model`: a `\"single\"` category replaces its chip, `{ max: n }`\n * refuses an n+1th pick (and says why in a live region), and with\n * `disableZeroCount` a chip that matches nothing cannot be picked (browse\n * mode). Behavior comes from `useTagBrowser`, so a custom skin can reuse it.\n *\n * Layout follows the component's own width, not the window's: columns from\n * 560 px, stacked categories of wrapping chips below that, so it fits a\n * 360 px face without horizontal scroll. `layout` forces either.\n *\n * Skins match `PresetSelector`: `quiet` (no frame), `panel` (surface and\n * hairline) and `glass` (translucent, blurred). Colours come from the kit's\n * `--uikit-*` tokens through `--tag-*` variables; the stylesheet is inline and\n * uses `:where()` selectors, so it works in an isolated face without Tailwind\n * and module CSS overrides it without specificity fights.\n */\n\nimport type { CSSProperties, ReactNode } from \"react\"\nimport { useElementSize } from \"maruaudio/ui/shared/use-element-size\"\nimport {\n  tagCategoryLimit,\n  type TagAvailabilityOptions,\n  type TagCategory,\n  type TagOption,\n  type TagSelection,\n} from \"maruaudio/ui/shared/tag-browser-model\"\nimport {\n  TAG_BROWSER_VISUALLY_HIDDEN,\n  useTagBrowser,\n  type TagBrowserChange,\n  type TagBrowserLayout,\n} from \"maruaudio/ui/shared/use-tag-browser\"\n\nexport type TagBrowserVariant = \"quiet\" | \"panel\" | \"glass\"\n\nexport interface TagBrowserProps extends TagAvailabilityOptions {\n  readonly categories: readonly TagCategory[]\n  readonly value: TagSelection\n  readonly onChange: (next: TagSelection, change: TagBrowserChange) => void\n  /** `auto` (default) uses columns from `columnsMinWidth` and stacks below it. */\n  readonly layout?: \"auto\" | TagBrowserLayout\n  /** Width at which `auto` switches to columns. Defaults to 560. */\n  readonly columnsMinWidth?: number\n  /** Show the search field. */\n  readonly search?: boolean\n  readonly searchPlaceholder?: string\n  readonly query?: string\n  readonly onQueryChange?: (query: string) => void\n  /** Show chip counts when chips carry them. Defaults to true. */\n  readonly showCounts?: boolean\n  readonly formatCount?: (count: number) => string\n  /** Max height of a column's chip list before it scrolls. Defaults to 15rem. */\n  readonly columnHeight?: number | string\n  /** Extra toolbar content, after search and before Clear all. */\n  readonly toolbar?: ReactNode\n  /** Accessible name. Defaults to \"Keywords\". */\n  readonly label?: string\n  /** Shown when a search matches nothing. */\n  readonly emptyLabel?: ReactNode\n  readonly variant?: TagBrowserVariant\n  readonly disabled?: boolean\n  readonly className?: string\n  readonly style?: CSSProperties\n}\n\n/** The portable stylesheet. Exported so hosts can preload or inspect it. */\nexport const TAG_BROWSER_STYLES = String.raw`\n:where([data-tag-browser]){--tag-accent:var(--uikit-accent,#6366f1);--tag-text:var(--uikit-text,currentColor);--tag-muted:var(--uikit-text-muted,color-mix(in oklab,currentColor 58%,transparent));--tag-border:var(--uikit-border,color-mix(in oklab,currentColor 20%,transparent));--tag-surface:color-mix(in oklab,currentColor 6%,transparent);--tag-surface-strong:color-mix(in oklab,currentColor 12%,transparent);--tag-selected:color-mix(in oklab,var(--tag-accent) 24%,transparent);--tag-radius:999px;--tag-panel-radius:var(--uikit-radius,12px);position:relative;box-sizing:border-box;display:flex;flex-direction:column;gap:.625rem;min-width:0;max-width:100%;color:var(--tag-text);font:500 12px/1.3 var(--uikit-font,ui-sans-serif,system-ui,sans-serif)}\n:where([data-tag-browser][data-variant=\"panel\"]){border:1px solid var(--tag-border);border-radius:var(--tag-panel-radius);background:var(--uikit-surface,color-mix(in oklab,currentColor 5%,transparent));padding:.75rem;box-shadow:0 2px 8px rgb(0 0 0/.08)}\n:where([data-tag-browser][data-variant=\"glass\"]){--tag-border:var(--uikit-border,color-mix(in oklab,currentColor 28%,transparent));--tag-panel-radius:var(--uikit-radius,16px);border:1px solid var(--tag-border);border-radius:var(--tag-panel-radius);background:color-mix(in oklab,var(--uikit-surface,#fff) 60%,transparent);padding:.75rem;box-shadow:0 10px 30px rgb(0 0 0/.14);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}\n:where([data-tag-browser] *,[data-tag-browser] *::before,[data-tag-browser] *::after){box-sizing:border-box}\n:where([data-tag-toolbar]){display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;min-width:0}\n:where([data-tag-search]){flex:1 1 10rem;min-width:0;height:2rem;border:1px solid var(--tag-border);border-radius:var(--tag-radius);background:var(--tag-surface);padding:0 .75rem;color:inherit;font:inherit;outline:none}\n:where([data-tag-search])::placeholder{color:var(--tag-muted)}\n:where([data-tag-search]:focus-visible){border-color:var(--tag-accent)}\n:where([data-tag-summary]){color:var(--tag-muted);font-variant-numeric:tabular-nums;white-space:nowrap}\n:where([data-tag-categories]){display:flex;flex-direction:column;gap:.75rem;min-width:0}\n:where([data-tag-browser][data-layout=\"columns\"] [data-tag-categories]){display:grid;grid-template-columns:repeat(var(--tag-columns,4),minmax(0,1fr));gap:.5rem}\n:where([data-tag-category]){display:flex;flex-direction:column;gap:.375rem;min-width:0}\n:where([data-tag-browser][data-layout=\"columns\"] [data-tag-category]){border-radius:calc(var(--tag-panel-radius) - 2px);background:var(--tag-surface);padding:.5rem}\n:where([data-tag-category-header]){display:flex;align-items:baseline;gap:.375rem;min-width:0;min-height:1.5rem}\n:where([data-tag-category-label]){overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.6875rem;font-weight:650;letter-spacing:.1em;text-transform:uppercase;color:var(--tag-muted)}\n:where([data-tag-limit]){flex:none;font-size:.6875rem;color:var(--tag-muted);opacity:.8;white-space:nowrap}\n:where([data-tag-category][data-full] [data-tag-limit]){color:var(--tag-accent);opacity:1}\n:where([data-tag-clear],[data-tag-clear-all]){flex:none;margin-left:auto;min-height:1.5rem;border:0;border-radius:var(--tag-radius);background:transparent;padding:0 .5rem;color:var(--tag-muted);font:inherit;font-size:.6875rem;cursor:pointer}\n:where([data-tag-clear-all]){margin-left:0;min-height:2rem;border:1px solid var(--tag-border);padding:0 .75rem;font-size:12px}\n:where([data-tag-clear]:hover,[data-tag-clear-all]:hover){background:var(--tag-surface-strong);color:var(--tag-text)}\n:where([data-tag-clear][aria-disabled=\"true\"],[data-tag-clear-all][aria-disabled=\"true\"]){opacity:.4;cursor:default;background:transparent}\n:where([data-tag-chips]){display:flex;flex-wrap:wrap;gap:.25rem;min-width:0;margin:0;padding:0;list-style:none}\n:where([data-tag-browser][data-layout=\"columns\"] [data-tag-chips]){flex-direction:column;flex-wrap:nowrap;gap:2px;max-height:var(--tag-column-height,15rem);overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin}\n:where([data-tag-chip]){display:inline-flex;align-items:center;gap:.375rem;max-width:100%;min-height:1.75rem;border:1px solid var(--tag-border);border-radius:var(--tag-radius);background:transparent;padding:0 .625rem;color:inherit;font:inherit;text-align:left;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:background-color 100ms ease-out,border-color 100ms ease-out,opacity 100ms ease-out}\n:where([data-tag-browser][data-layout=\"columns\"] [data-tag-chip]){display:flex;width:100%;justify-content:space-between;border-color:transparent;border-radius:calc(var(--tag-panel-radius) - 5px);padding:0 .5rem}\n:where([data-tag-chip]:hover){background:var(--tag-surface-strong)}\n:where([data-tag-chip][aria-checked=\"true\"]){border-color:var(--tag-accent);background:var(--tag-selected);color:var(--tag-text)}\n:where([data-tag-chip][aria-disabled=\"true\"]){opacity:.38;cursor:not-allowed}\n:where([data-tag-chip][aria-disabled=\"true\"]:hover){background:transparent}\n:where([data-tag-chip]:focus-visible,[data-tag-clear]:focus-visible,[data-tag-clear-all]:focus-visible){outline:2px solid var(--uikit-focus,var(--tag-accent));outline-offset:1px}\n:where([data-tag-chip-label]){min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n:where([data-tag-count]){flex:none;font-size:.9em;font-variant-numeric:tabular-nums;color:var(--tag-muted)}\n:where([data-tag-empty]){padding:.75rem;text-align:center;color:var(--tag-muted)}\n`\n\nfunction formatCountDefault(count: number): string {\n  return count >= 1000 ? `${Math.round(count / 100) / 10}k` : String(count)\n}\n\nexport function TagBrowser({\n  categories,\n  value,\n  onChange,\n  layout = \"auto\",\n  columnsMinWidth = 560,\n  search = false,\n  searchPlaceholder,\n  query,\n  onQueryChange,\n  showCounts = true,\n  formatCount = formatCountDefault,\n  columnHeight,\n  toolbar,\n  label = \"Keywords\",\n  emptyLabel,\n  variant = \"quiet\",\n  disabled = false,\n  disableZeroCount = false,\n  className,\n  style,\n}: TagBrowserProps) {\n  const [sizeRef, size] = useElementSize<HTMLDivElement>()\n  // Unmeasured (SSR, first layout pass) stacks: it fits any width.\n  const resolvedLayout: TagBrowserLayout =\n    layout === \"auto\"\n      ? size.width >= columnsMinWidth\n        ? \"columns\"\n        : \"stacked\"\n      : layout\n  const browser = useTagBrowser({\n    categories,\n    value,\n    onChange,\n    layout: resolvedLayout,\n    query,\n    onQueryChange,\n    label,\n    disabled,\n    disableZeroCount,\n  })\n  const visible = browser.visibleCategories\n  const renderChip = (category: TagCategory, tag: TagOption) => {\n    const count =\n      showCounts && tag.count !== undefined ? formatCount(tag.count) : null\n    return (\n      <button key={tag.id} {...browser.getChipProps(category, tag)}>\n        <span data-tag-chip-label=\"\">{tag.label}</span>\n        {count !== null ? (\n          <span data-tag-count=\"\">\n            <span aria-hidden=\"true\">{count}</span>\n            <span style={TAG_BROWSER_VISUALLY_HIDDEN}>\n              {`, ${tag.count} ${tag.count === 1 ? \"match\" : \"matches\"}`}\n            </span>\n          </span>\n        ) : null}\n      </button>\n    )\n  }\n  return (\n    <div\n      {...browser.getRootProps()}\n      ref={sizeRef}\n      data-variant={variant}\n      className={className}\n      style={\n        {\n          \"--tag-columns\": String(Math.max(1, visible.length)),\n          ...(columnHeight !== undefined\n            ? {\n                \"--tag-column-height\":\n                  typeof columnHeight === \"number\"\n                    ? `${columnHeight}px`\n                    : columnHeight,\n              }\n            : null),\n          ...style,\n        } as CSSProperties\n      }\n    >\n      {/* Inline so the item stays standalone in isolated faces and webviews. */}\n      <style data-tag-browser-styles=\"\">{TAG_BROWSER_STYLES}</style>\n      <div data-tag-toolbar=\"\">\n        {search ? (\n          <input {...browser.getSearchProps(searchPlaceholder)} />\n        ) : null}\n        {toolbar}\n        <span data-tag-summary=\"\" aria-hidden=\"true\">\n          {browser.selectedCount > 0\n            ? `${browser.selectedCount} selected`\n            : null}\n        </span>\n        <button {...browser.getClearAllProps()}>Clear all</button>\n      </div>\n      <div data-tag-categories=\"\">\n        {visible.length === 0 ? (\n          <p data-tag-empty=\"\">\n            {emptyLabel ?? `No keywords match “${browser.query.trim()}”.`}\n          </p>\n        ) : (\n          visible.map((category) => {\n            const limit = tagCategoryLimit(category.selection)\n            const count = browser.selectedIn(category).length\n            return (\n              <div key={category.id} {...browser.getCategoryProps(category)}>\n                <div data-tag-category-header=\"\">\n                  <span {...browser.getCategoryLabelProps(category)}>\n                    {category.label}\n                  </span>\n                  {Number.isFinite(limit) ? (\n                    <span\n                      data-tag-limit=\"\"\n                      aria-description={browser.limitHint(category) ?? undefined}\n                    >\n                      {limit === 1 ? \"pick one\" : `${count}/${limit}`}\n                    </span>\n                  ) : null}\n                  <button {...browser.getClearCategoryProps(category)}>\n                    Clear\n                  </button>\n                </div>\n                <div data-tag-chips=\"\">\n                  {category.tags.map((tag) => renderChip(category, tag))}\n                </div>\n              </div>\n            )\n          })\n        )}\n      </div>\n      <div {...browser.getLiveRegionProps()}>{browser.announcement}</div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/tag-browser.tsx"
    }
  ]
}
