{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "permission-matrix",
  "type": "registry:ui",
  "title": "Permission Matrix",
  "description": "A roles × permissions grid with tri-state select-all headers, collapsible groups, locked cells and tracked changes.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/permission-matrix.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/* ---------------------------------- types ---------------------------------- */\n\nexport interface PermissionRole {\n  id: string;\n  name: string;\n  /** Short line under the role name. */\n  description?: string;\n}\n\nexport interface PermissionItem {\n  id: string;\n  label: string;\n  /** Secondary text under the label. */\n  description?: string;\n}\n\nexport interface PermissionGroup {\n  id: string;\n  label: string;\n  permissions: PermissionItem[];\n}\n\n/** A cell the user cannot change (inherited from a parent role, enforced by policy…). */\nexport interface PermissionLock {\n  role: string;\n  permission: string;\n  /** Why it is locked — shown in a tooltip and read as the cell's description. */\n  reason: string;\n  /** Whether the locked cell is granted. */\n  granted?: boolean;\n}\n\n/** Granted permission ids per role id. */\nexport type PermissionValue = Record<string, string[]>;\n\nexport interface PermissionMatrixProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  roles: PermissionRole[];\n  groups: PermissionGroup[];\n  /** Controlled grants. */\n  value?: PermissionValue;\n  /** Initial grants when uncontrolled. */\n  defaultValue?: PermissionValue;\n  /** Called with the next grants after every toggle, bulk change or reset. */\n  onChange?: (value: PermissionValue) => void;\n  /** Cells that cannot be changed, with the reason. */\n  locked?: PermissionLock[];\n  /** What changes are measured against. Defaults to the value at mount. */\n  baseline?: PermissionValue;\n  /** Accessible name (and visible title). */\n  label?: string;\n  /** Let groups collapse. */\n  collapsible?: boolean;\n  /** Groups collapsed at mount. */\n  defaultCollapsed?: string[];\n  /** Tri-state checkboxes on group rows (grant a whole group to a role). */\n  groupToggles?: boolean;\n  /** Highlight cells changed since load and show the change summary. */\n  highlightChanges?: boolean;\n  /** Row height. */\n  density?: \"comfortable\" | \"compact\";\n  /** Scroll area max height in px (0 = no limit); the header and first column stay pinned. */\n  maxHeight?: number;\n  /** Checked fill, change markers and focus rings. */\n  accent?: string;\n}\n\ntype Check = boolean | \"mixed\";\n\n/** One focusable thing in the grid. */\ntype Slot =\n  | { kind: \"col\"; role: string }\n  | { kind: \"group\"; group: string }\n  | { kind: \"groupRole\"; group: string; role: string }\n  | { kind: \"row\"; permission: string }\n  | { kind: \"cell\"; permission: string; role: string };\n\nconst slotKey = (s: Slot) => {\n  switch (s.kind) {\n    case \"col\":\n      return `col:${s.role}`;\n    case \"group\":\n      return `group:${s.group}`;\n    case \"groupRole\":\n      return `gr:${s.group}:${s.role}`;\n    case \"row\":\n      return `row:${s.permission}`;\n    case \"cell\":\n      return `cell:${s.permission}:${s.role}`;\n  }\n};\n\nconst cellId = (role: string, permission: string) => `${role}\\u0000${permission}`;\n\nfunction triState(states: boolean[]): Check {\n  if (!states.length) return false;\n  const on = states.filter(Boolean).length;\n  return on === 0 ? false : on === states.length ? true : \"mixed\";\n}\n\n/* --------------------------------- checkbox -------------------------------- */\n\nfunction CheckGlyph({ state, locked = false, small = false }: { state: Check; locked?: boolean; small?: boolean }) {\n  const filled = state !== false;\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={`relative inline-flex shrink-0 items-center justify-center rounded-[4px] border transition-colors duration-100 ${small ? \"size-3.5\" : \"size-4\"} ${\n        locked\n          ? filled\n            ? \"border-zinc-400/60 bg-zinc-400/50 text-white dark:border-zinc-500/60 dark:bg-zinc-500/50 dark:text-zinc-900\"\n            : \"border-dashed border-zinc-400/70 dark:border-zinc-500/70\"\n          : filled\n            ? \"border-[var(--pm-accent)] bg-[var(--pm-accent)] text-zinc-950\"\n            : \"border-zinc-900/25 bg-white group-hover/cb:border-zinc-900/50 dark:border-white/25 dark:bg-transparent dark:group-hover/cb:border-white/50\"\n      }`}\n    >\n      {state === true ? (\n        <svg viewBox=\"0 0 12 12\" className=\"size-2.5\">\n          <path d=\"m2.5 6.2 2.2 2.2 4.8-5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n        </svg>\n      ) : state === \"mixed\" ? (\n        <svg viewBox=\"0 0 12 12\" className=\"size-2.5\">\n          <path d=\"M3 6h6\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" />\n        </svg>\n      ) : null}\n      {locked ? (\n        <svg viewBox=\"0 0 10 10\" className=\"absolute -right-1.5 -top-1.5 size-2.5 rounded-[2px] bg-white text-zinc-500 dark:bg-zinc-900 dark:text-zinc-400\">\n          <rect x=\"2\" y=\"4.5\" width=\"6\" height=\"4.5\" rx=\"1\" fill=\"currentColor\" />\n          <path d=\"M3.4 4.5V3.4a1.6 1.6 0 0 1 3.2 0v1.1\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1\" />\n        </svg>\n      ) : null}\n    </span>\n  );\n}\n\n/* -------------------------------- component -------------------------------- */\n\n/**\n * A roles × permissions grid of checkboxes in a real <table> with grid keyboard navigation.\n * Column and row headers (and optional group rows) are tri-state \"select all\" boxes; locked cells\n * explain themselves in a tooltip; cells changed since load are marked and counted, with a reset.\n */\nexport function PermissionMatrix({\n  roles,\n  groups,\n  value,\n  defaultValue,\n  onChange,\n  locked = [],\n  baseline: baselineProp,\n  label = \"Permissions\",\n  collapsible = true,\n  defaultCollapsed = [],\n  groupToggles = true,\n  highlightChanges = true,\n  density = \"comfortable\",\n  maxHeight = 420,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: PermissionMatrixProps) {\n  const uid = React.useId();\n  const titleId = `${uid}-title`;\n  const tipId = `${uid}-tip`;\n  const hintId = `${uid}-hint`;\n\n  const [inner, setInner] = React.useState<PermissionValue>(() => defaultValue ?? {});\n  const current = value ?? inner;\n  const [mountValue] = React.useState<PermissionValue>(() => value ?? defaultValue ?? {});\n  const baseline = baselineProp ?? mountValue;\n\n  const [collapsed, setCollapsed] = React.useState<string[]>(defaultCollapsed);\n  const [activeKey, setActiveKey] = React.useState<string | null>(null);\n  const [tip, setTip] = React.useState<{ text: string; x: number; y: number } | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const tableRef = React.useRef<HTMLTableElement>(null);\n  const tipRef = React.useRef<HTMLDivElement>(null);\n  const pendingFocus = React.useRef(false);\n\n  /* ------------------------------- lookups -------------------------------- */\n\n  const lockMap = new Map(locked.map((l) => [cellId(l.role, l.permission), l]));\n  const grantedIn = (v: PermissionValue, role: string, permission: string) => {\n    const lock = lockMap.get(cellId(role, permission));\n    return lock ? Boolean(lock.granted) : (v[role] ?? []).includes(permission);\n  };\n  const isGranted = (role: string, permission: string) => grantedIn(current, role, permission);\n  const isLocked = (role: string, permission: string) => lockMap.has(cellId(role, permission));\n  const isChanged = (role: string, permission: string) =>\n    !isLocked(role, permission) && grantedIn(current, role, permission) !== grantedIn(baseline, role, permission);\n\n  const allPermissions = groups.flatMap((g) => g.permissions);\n\n  /** Tri-state of a set of cells; a set that is entirely locked shows its locked state, disabled. */\n  const setState = (pairs: [string, string][]) => {\n    const open = pairs.filter(([r, p]) => !isLocked(r, p));\n    const allLocked = open.length === 0;\n    return { state: triState((allLocked ? pairs : open).map(([r, p]) => isGranted(r, p))), allLocked };\n  };\n\n  let changeCount = 0;\n  for (const r of roles) for (const p of allPermissions) if (isChanged(r.id, p.id)) changeCount++;\n\n  /* ------------------------------- mutations ------------------------------ */\n\n  const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));\n\n  /** Sets every unlocked (role, permission) pair in `pairs` to `grant`. */\n  const apply = (pairs: [string, string][], grant: boolean) => {\n    const next: PermissionValue = {};\n    for (const r of roles) next[r.id] = [...(current[r.id] ?? [])];\n    for (const [role, permission] of pairs) {\n      if (isLocked(role, permission)) continue;\n      const list = next[role] ?? (next[role] = []);\n      const at = list.indexOf(permission);\n      if (grant && at === -1) list.push(permission);\n      if (!grant && at !== -1) list.splice(at, 1);\n    }\n    // Keep a stable order (the order permissions are listed in).\n    const order = new Map(allPermissions.map((p, i) => [p.id, i]));\n    for (const r of Object.keys(next)) next[r].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));\n    if (value === undefined) setInner(next);\n    onChange?.(next);\n  };\n\n  /** Toggles a set of cells as one: all granted → revoke all, otherwise grant all. */\n  const toggleMany = (pairs: [string, string][], description: string) => {\n    const open = pairs.filter(([r, p]) => !isLocked(r, p));\n    if (!open.length) return;\n    const grant = !open.every(([r, p]) => isGranted(r, p));\n    apply(open, grant);\n    announce(`${description} ${grant ? \"granted\" : \"revoked\"}.`);\n  };\n\n  const reset = () => {\n    const next: PermissionValue = {};\n    for (const r of roles) next[r.id] = allPermissions.filter((p) => !isLocked(r.id, p.id) && grantedIn(baseline, r.id, p.id)).map((p) => p.id);\n    if (value === undefined) setInner(next);\n    onChange?.(next);\n    announce(\"Changes reset.\");\n  };\n\n  /* --------------------------------- layout -------------------------------- */\n\n  // Each row of the visible grid as a list of focusable slots (null = nothing focusable there).\n  const matrix: (Slot | null)[][] = [[null, ...roles.map((r): Slot => ({ kind: \"col\", role: r.id }))]];\n  for (const g of groups) {\n    matrix.push([\n      collapsible ? { kind: \"group\", group: g.id } : null,\n      ...roles.map((r): Slot | null => (groupToggles ? { kind: \"groupRole\", group: g.id, role: r.id } : null)),\n    ]);\n    if (collapsed.includes(g.id) && collapsible) continue;\n    for (const p of g.permissions) {\n      matrix.push([{ kind: \"row\", permission: p.id }, ...roles.map((r): Slot => ({ kind: \"cell\", permission: p.id, role: r.id }))]);\n    }\n  }\n  const visibleKeys = new Set(matrix.flat().flatMap((s) => (s ? [slotKey(s)] : [])));\n  const firstSlot = matrix.flat().find((s) => s?.kind === \"cell\") ?? matrix.flat().find(Boolean);\n  const firstKey = firstSlot ? slotKey(firstSlot) : \"\";\n  const tabKey = activeKey && visibleKeys.has(activeKey) ? activeKey : firstKey;\n\n  const positionOf = (key: string) => {\n    for (let r = 0; r < matrix.length; r++) {\n      const c = matrix[r].findIndex((s) => s !== null && slotKey(s) === key);\n      if (c !== -1) return { r, c };\n    }\n    return { r: 1, c: 1 };\n  };\n\n  const focusSlot = (s: Slot | null | undefined) => {\n    if (!s) return;\n    setActiveKey(slotKey(s));\n    pendingFocus.current = true;\n  };\n\n  React.useEffect(() => {\n    if (!pendingFocus.current) return;\n    pendingFocus.current = false;\n    tableRef.current?.querySelector<HTMLElement>(`[data-slot=\"${CSS.escape(tabKey)}\"]`)?.focus({ preventScroll: false });\n  });\n\n  const handleGridKey = (event: React.KeyboardEvent<HTMLTableElement>) => {\n    if (event.key === \"Escape\" && tip) {\n      // Close the tooltip layer only.\n      event.preventDefault();\n      event.stopPropagation();\n      setTip(null);\n      return;\n    }\n    const { r, c } = positionOf(tabKey);\n    const row = matrix[r];\n    const scanRow = (from: number, dir: 1 | -1) => {\n      for (let i = from; i >= 0 && i < row.length; i += dir) if (row[i]) return row[i];\n      return null;\n    };\n    const scanCol = (from: number, dir: 1 | -1) => {\n      for (let i = from; i >= 0 && i < matrix.length; i += dir) if (matrix[i][c]) return matrix[i][c];\n      return null;\n    };\n    const ctrl = event.ctrlKey || event.metaKey;\n    let target: Slot | null = null;\n    switch (event.key) {\n      case \"ArrowRight\":\n        target = scanRow(c + 1, 1);\n        break;\n      case \"ArrowLeft\":\n        target = scanRow(c - 1, -1);\n        break;\n      case \"ArrowDown\":\n        target = scanCol(r + 1, 1);\n        break;\n      case \"ArrowUp\":\n        target = scanCol(r - 1, -1);\n        break;\n      case \"Home\":\n        target = ctrl ? matrix.flat().find(Boolean) ?? null : scanRow(0, 1);\n        break;\n      case \"End\":\n        target = ctrl ? [...matrix.flat()].reverse().find(Boolean) ?? null : scanRow(row.length - 1, -1);\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    focusSlot(target);\n  };\n\n  /* --------------------------------- tooltip -------------------------------- */\n\n  React.useEffect(() => {\n    const el = tipRef.current;\n    if (!el) return;\n    if (!tip) {\n      if (el.matches(\":popover-open\")) el.hidePopover();\n      return;\n    }\n    if (!el.matches(\":popover-open\")) el.showPopover();\n    const width = el.offsetWidth;\n    const height = el.offsetHeight;\n    const left = Math.min(Math.max(tip.x - width / 2, 8), window.innerWidth - width - 8);\n    const top = tip.y - height - 8 < 8 ? tip.y + 30 : tip.y - height - 8;\n    el.style.left = `${Math.round(left)}px`;\n    el.style.top = `${Math.round(top)}px`;\n  }, [tip]);\n\n  const showTip = (target: HTMLElement, text: string) => {\n    const rect = target.getBoundingClientRect();\n    setTip({ text, x: rect.left + rect.width / 2, y: rect.top });\n  };\n\n  /* --------------------------------- render -------------------------------- */\n\n  const rowH = density === \"compact\" ? \"h-9\" : \"h-11\";\n  const rootStyle = { [\"--pm-accent\" as string]: accent, ...style };\n\n  const slotProps = (s: Slot) => {\n    const key = slotKey(s);\n    return {\n      \"data-slot\": key,\n      tabIndex: key === tabKey ? 0 : -1,\n      onFocus: () => setActiveKey(key),\n    };\n  };\n\n  const checkboxClass =\n    \"group/cb inline-flex items-center justify-center rounded-md p-1.5 outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--pm-accent)]\";\n\n  const headerCell = \"bg-white dark:bg-zinc-900\";\n\n  return (\n    <div\n      className={`flex w-full min-w-0 flex-col overflow-hidden rounded-xl border border-zinc-900/10 bg-white text-zinc-900 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 ${className}`}\n      style={rootStyle}\n      {...props}\n    >\n      <div className=\"flex flex-wrap items-center justify-between gap-3 border-b border-zinc-900/10 px-4 py-3 dark:border-white/10\">\n        <h3 id={titleId} className=\"text-[14px] font-semibold tracking-[-0.01em]\">\n          {label}\n        </h3>\n        {highlightChanges ? (\n          <div className=\"flex items-center gap-3\">\n            <span className=\"flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n              <span\n                aria-hidden=\"true\"\n                className={`size-1.5 rounded-full ${changeCount ? \"bg-[var(--pm-accent)]\" : \"bg-zinc-300 dark:bg-zinc-600\"}`}\n              />\n              {changeCount === 0 ? \"No changes\" : `${changeCount} ${changeCount === 1 ? \"change\" : \"changes\"}`}\n            </span>\n            <button\n              type=\"button\"\n              onClick={reset}\n              disabled={changeCount === 0}\n              className=\"rounded-md border border-zinc-900/15 px-2.5 py-1 text-[12px] font-medium outline-hidden transition-colors hover:bg-zinc-900/[0.04] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pm-accent)] disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/15 dark:hover:bg-white/[0.06]\"\n            >\n              Reset changes\n            </button>\n          </div>\n        ) : null}\n      </div>\n\n      <div\n        className=\"min-w-0 overflow-auto overscroll-contain\"\n        style={maxHeight ? { maxHeight } : undefined}\n        onScroll={() => tip && setTip(null)}\n      >\n        <table\n          ref={tableRef}\n          role=\"grid\"\n          aria-labelledby={titleId}\n          aria-describedby={hintId}\n          onKeyDown={handleGridKey}\n          className=\"w-full border-separate border-spacing-0 text-[13px]\"\n        >\n          <thead>\n            <tr>\n              <th\n                scope=\"col\"\n                className={`sticky left-0 top-0 z-30 w-36 min-w-36 sm:w-auto sm:min-w-52 border-b border-r border-zinc-900/10 px-4 py-2.5 text-left align-bottom font-mono text-[10px] font-normal uppercase tracking-[0.14em] text-zinc-500 dark:border-white/10 dark:text-zinc-400 ${headerCell}`}\n              >\n                Permission\n              </th>\n              {roles.map((r) => {\n                const pairs = allPermissions.map((p): [string, string] => [r.id, p.id]);\n                const { state, allLocked } = setState(pairs);\n                const slot: Slot = { kind: \"col\", role: r.id };\n                return (\n                  <th key={r.id} scope=\"col\" className={`sticky top-0 z-20 min-w-24 border-b border-zinc-900/10 px-2 py-2.5 align-bottom font-normal dark:border-white/10 ${headerCell}`}>\n                    <div className=\"flex flex-col items-center gap-1.5\">\n                      <span className=\"text-[13px] font-semibold tracking-[-0.01em]\">{r.name}</span>\n                      {r.description ? <span className=\"font-mono text-[9px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">{r.description}</span> : null}\n                      <button\n                        type=\"button\"\n                        role=\"checkbox\"\n                        aria-checked={state}\n                        aria-disabled={allLocked || undefined}\n                        aria-label={`All permissions for ${r.name}`}\n                        {...slotProps(slot)}\n                        onClick={() => toggleMany(pairs, `All permissions for ${r.name}`)}\n                        className={`${checkboxClass} ${allLocked ? \"cursor-not-allowed\" : \"\"}`}\n                      >\n                        <CheckGlyph state={state} locked={allLocked} small />\n                      </button>\n                    </div>\n                  </th>\n                );\n              })}\n            </tr>\n          </thead>\n          <tbody>\n            {groups.map((g) => {\n              const isCollapsed = collapsible && collapsed.includes(g.id);\n              const groupSlot: Slot = { kind: \"group\", group: g.id };\n              return (\n                <React.Fragment key={g.id}>\n                  <tr>\n                    <th\n                      scope=\"rowgroup\"\n                      className={`sticky left-0 z-10 border-b border-r border-zinc-900/10 bg-zinc-50 px-2 text-left dark:border-white/10 dark:bg-zinc-800 ${rowH}`}\n                    >\n                      {collapsible ? (\n                        <button\n                          type=\"button\"\n                          aria-expanded={!isCollapsed}\n                          {...slotProps(groupSlot)}\n                          onClick={() => {\n                            setCollapsed((list) => (list.includes(g.id) ? list.filter((x) => x !== g.id) : [...list, g.id]));\n                            setActiveKey(slotKey(groupSlot));\n                          }}\n                          className=\"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--pm-accent)]\"\n                        >\n                          <svg\n                            viewBox=\"0 0 12 12\"\n                            aria-hidden=\"true\"\n                            className={`size-3 shrink-0 text-zinc-400 transition-transform duration-150 motion-reduce:transition-none ${isCollapsed ? \"-rotate-90\" : \"\"}`}\n                          >\n                            <path d=\"M3 4.5 6 7.5l3-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                          </svg>\n                          <span className=\"font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-zinc-700 dark:text-zinc-200\">{g.label}</span>\n                          <span className=\"font-mono text-[10px] text-zinc-400 dark:text-zinc-500\">{g.permissions.length}</span>\n                        </button>\n                      ) : (\n                        <span\n                          className=\"block px-2 font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-zinc-700 dark:text-zinc-200\"\n                        >\n                          {g.label}\n                        </span>\n                      )}\n                    </th>\n                    {roles.map((r) => {\n                      if (!groupToggles) return <td key={r.id} className={`border-b border-zinc-900/10 bg-zinc-50 dark:border-white/10 dark:bg-zinc-800 ${rowH}`} />;\n                      const pairs = g.permissions.map((p): [string, string] => [r.id, p.id]);\n                      const { state, allLocked } = setState(pairs);\n                      const slot: Slot = { kind: \"groupRole\", group: g.id, role: r.id };\n                      return (\n                        <td key={r.id} className={`border-b border-zinc-900/10 bg-zinc-50 text-center dark:border-white/10 dark:bg-zinc-800 ${rowH}`}>\n                          <button\n                            type=\"button\"\n                            role=\"checkbox\"\n                            aria-checked={state}\n                            aria-disabled={allLocked || undefined}\n                            aria-label={`${g.label}: all for ${r.name}`}\n                            {...slotProps(slot)}\n                            onClick={() => toggleMany(pairs, `${g.label} for ${r.name}`)}\n                            className={`${checkboxClass} ${allLocked ? \"cursor-not-allowed\" : \"\"}`}\n                          >\n                            <CheckGlyph state={state} locked={allLocked} small />\n                          </button>\n                        </td>\n                      );\n                    })}\n                  </tr>\n\n                  {isCollapsed\n                    ? null\n                    : g.permissions.map((p) => {\n                        const rowPairs = roles.map((r): [string, string] => [r.id, p.id]);\n                        const { state: rowState, allLocked: rowLocked } = setState(rowPairs);\n                        const rowSlot: Slot = { kind: \"row\", permission: p.id };\n                        return (\n                          <tr key={p.id} className=\"group/row\">\n                            <th\n                              scope=\"row\"\n                              className={`sticky left-0 z-10 max-w-36 border-b border-r border-zinc-900/[0.06] bg-white pl-4 pr-2 sm:max-w-none text-left font-normal group-hover/row:bg-zinc-50 dark:border-white/[0.06] dark:bg-zinc-900 dark:group-hover/row:bg-zinc-800/80 ${rowH}`}\n                            >\n                              <div className=\"flex items-center justify-between gap-3\">\n                                <span className=\"flex min-w-0 flex-col\">\n                                  <span className=\"truncate text-[13px] text-zinc-800 dark:text-zinc-100\">{p.label}</span>\n                                  {p.description && density === \"comfortable\" ? (\n                                    <span className=\"truncate text-[11px] text-zinc-500 dark:text-zinc-400\">{p.description}</span>\n                                  ) : null}\n                                </span>\n                                <button\n                                  type=\"button\"\n                                  role=\"checkbox\"\n                                  aria-checked={rowState}\n                                  aria-disabled={rowLocked || undefined}\n                                  aria-label={`${p.label} for all roles`}\n                                  title=\"All roles\"\n                                  {...slotProps(rowSlot)}\n                                  onClick={() => toggleMany(rowPairs, `${p.label} for all roles`)}\n                                  className={checkboxClass}\n                                >\n                                  <CheckGlyph state={rowState} locked={rowLocked} small />\n                                </button>\n                              </div>\n                            </th>\n                            {roles.map((r) => {\n                              const lock = lockMap.get(cellId(r.id, p.id));\n                              const granted = isGranted(r.id, p.id);\n                              const changed = highlightChanges && isChanged(r.id, p.id);\n                              const slot: Slot = { kind: \"cell\", permission: p.id, role: r.id };\n                              const name = `${p.label} for ${r.name}${changed ? \", changed\" : \"\"}`;\n                              return (\n                                <td\n                                  key={r.id}\n                                  className={`relative border-b border-zinc-900/[0.06] text-center transition-colors duration-150 group-hover/row:bg-zinc-50 dark:border-white/[0.06] dark:group-hover/row:bg-zinc-800/80 ${rowH} ${\n                                    changed ? \"bg-[color-mix(in_oklab,var(--pm-accent)_9%,transparent)]\" : \"\"\n                                  }`}\n                                >\n                                  {changed ? (\n                                    <span aria-hidden=\"true\" className=\"absolute right-1.5 top-1.5 size-1.5 rounded-full bg-[var(--pm-accent)]\" />\n                                  ) : null}\n                                  <button\n                                    type=\"button\"\n                                    role=\"checkbox\"\n                                    aria-checked={granted}\n                                    aria-label={name}\n                                    aria-disabled={lock ? true : undefined}\n                                    aria-describedby={lock ? `${uid}-lock-${r.id}-${p.id}` : undefined}\n                                    {...slotProps(slot)}\n                                    onClick={() => {\n                                      if (lock) return;\n                                      apply([[r.id, p.id]], !granted);\n                                      announce(`${p.label} ${granted ? \"revoked from\" : \"granted to\"} ${r.name}.`);\n                                    }}\n                                    onFocus={(event) => {\n                                      setActiveKey(slotKey(slot));\n                                      if (lock) showTip(event.currentTarget, lock.reason);\n                                    }}\n                                    onBlur={() => lock && setTip(null)}\n                                    onPointerEnter={(event) => lock && event.pointerType === \"mouse\" && showTip(event.currentTarget, lock.reason)}\n                                    onPointerLeave={(event) => {\n                                      if (lock && document.activeElement !== event.currentTarget) setTip(null);\n                                    }}\n                                    className={`${checkboxClass} ${lock ? \"cursor-not-allowed\" : \"\"}`}\n                                  >\n                                    <CheckGlyph state={granted} locked={Boolean(lock)} />\n                                  </button>\n                                  {lock ? (\n                                    <span id={`${uid}-lock-${r.id}-${p.id}`} className=\"sr-only\">\n                                      Locked: {lock.reason}\n                                    </span>\n                                  ) : null}\n                                </td>\n                              );\n                            })}\n                          </tr>\n                        );\n                      })}\n                </React.Fragment>\n              );\n            })}\n          </tbody>\n        </table>\n      </div>\n\n      <p id={hintId} className=\"sr-only\">\n        Arrow keys move between cells, Space toggles, Home and End jump to the ends of a row.\n      </p>\n      <div\n        ref={tipRef}\n        id={tipId}\n        popover=\"manual\"\n        role=\"tooltip\"\n        className=\"pointer-events-none fixed inset-auto m-0 max-w-60 rounded-md bg-zinc-900 px-2.5 py-1.5 text-[12px] leading-snug text-zinc-50 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.4)] dark:bg-zinc-50 dark:text-zinc-900\"\n      >\n        {tip?.text}\n      </div>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/permission-matrix"
}
