{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "permission-matrix",
  "title": "Permission Matrix",
  "description": "The role × permission grid: rows are roles, columns are the things they may do, and every crossing is a checkbox. Reach for it on the screen where somebody decides who can do what — an admin role editor, a team or workspace permissions page, the scope list on an API key or personal access token, an RBAC or access-control settings pane, group policy for an org, a sharing dialog that has outgrown three radio buttons, per-plan feature entitlements, moderation or staff roles in a community tool, and the permissions step of an onboarding or provisioning wizard. Common asks it answers: “permission matrix react”, “role permission table component”, “RBAC UI react”, “role based access control component”, “permissions grid checkbox react”, “shadcn permission matrix”, “shadcn roles and permissions table”, “scopes checkbox grid”, “api key scopes ui”, “access control matrix component”, “checkbox matrix react”, “table of checkboxes rows and columns”, “select all row and column checkboxes”, “permission dependencies ui”, “write implies read permission”, “implied permissions checkbox”, “role inheritance ui”, “inherited permission greyed out”, “show inherited vs explicit permission”, “why can I not untick this permission”, “permission checkbox will not uncheck”, “unchecking a box that stays checked”, “cascade permissions on checkbox”, “tri-state select all column”, “aria-checked mixed grid”, “role=grid roving tabindex react”, “too many tab stops table checkboxes”, “arrow key navigation table react”, “accessible checkbox grid”, “checkbox needs row and column name screen reader”, “sticky first column permission table”, “border disappears on sticky column”, “casbin ui”, “oso / permit.io permissions screen”, 権限マトリクス react, ロール 権限 表 コンポーネント, 権限設定 管理画面, 継承した権限 表示, 行と列の全選択, チェックボックス 中間状態 表. Official shadcn/ui has nothing of the kind, measured today rather than remembered. Fetching every entry in its registry — 65 listed, 62 fetchable (code-block, questionnaire and trace 404 on both style tracks) — and searching all 223,287 bytes of their source, permission, grant, matrix, indeterminate, scope, privilege, capability, acl and rbac are every one of them a zero hit. inherit is not zero and is worth unfolding rather than rounding: it returns exactly one, a rounded-[inherit] on the scroll-area viewport, which is a Tailwind value and not a permission that comes from somewhere. The name list settles the rest — nothing among the 65 is a role, permission or access control of any kind, and its table is 2,859 bytes of six presentational wrappers. The decision everything else follows from: the value is the grants you ticked, and only those. A cell can be granted four ways — ticked here, implied by another action on the same row (write requires read), inherited from a parent role, or some combination — and three of them are derived at render time from the roles and actions you pass in. The one-line alternative is to write the closure into the value when a box is ticked, and it is wrong in both directions. Forwards: untick write and read stays ticked, with nothing on screen saying why, because by then it is indistinguishable from a grant somebody made on purpose. Backwards: the day a deploy says write also requires list, nobody who already holds write gets list, because their stored value was closed over the old graph — so the matrix disagrees with whatever the server computes at check time, which is the thing actually being enforced. Keeping only explicit grants also means there is no cascade to write. “Can you untick the read that write pulled in” is a question that only exists once the closure is in the value; here read is not in the value, so there is nothing to untick and nothing to unwind. Cycle safety is two requirements and almost everyone implements one. Terminating is the easy half: a visited set does it. The half that bites is that a cycle must not report a key as requiring itself — follow a → b → a and a naive closure adds a to the set of things a requires, so the cell is permanently “required by itself”, which makes it derived, which refuses every press, and the grant can never be removed by anyone. requires: [\"self\"] is the one-line version of the same bug, and a role that inherits itself is the same bug one axis over. So origins are added back at the end rather than walked into, and a role is never its own ancestor even if the config says so. Requirements are then followed only from root causes — the grants ticked on the row — and never from other requirements, which is what keeps two cells in a cycle from pinning each other down, and which also makes the reason useful: a read held by a chain reports the delete that started it rather than the write in the middle, so what is on screen is the box you have to go and clear. A derived cell is aria-checked=\"true\" and aria-disabled. It is granted, so saying otherwise would be a lie about what is enforced; there is nothing explicit on it to remove, so the press is refused and the refusal names what is holding it. aria-disabled rather than the disabled attribute, so the cell stays reachable and somebody on a keyboard can find out that the grant exists and where it comes from — which is the whole job of the row they cannot change. A cell that is both explicit and derived stays pressable, and that produces the press this component exists to narrate: the explicit grant goes, the cell is still granted, and the tick does not move. Reported as a plain revoke that reads as a broken control, so the live region says it, and the change object hands the caller the same list under stillGranted. The select-alls — one per row, one per column, one in the corner — split what they show from what they do, and the split is load-bearing. What is displayed counts every cell in scope, derived and locked ones included, because a row claiming “all of it” while a cell nobody can change sits underneath would be lying about what is enforced. What a press does is decided only from the cells it may actually change. Read the direction off the displayed state instead and a row held at “mixed” by a single inherited cell is never “checked”, so every press reads as “grant the rest” and the row can be pressed forever without clearing — the same trap nested-checkbox has to avoid one dimension down, and it is reachable here without inheritance at all, through one locked column that is off. One tab stop, arrow keys inside. A table of checkboxes where every cell is a tab stop costs eight roles times ten permissions equals eighty presses of Tab to get past it, which is why this is role=\"grid\" with a roving tabindex: Tab reaches the grid, arrows move within it, Home and End run along the row and with the modifier go to the corners, and movement clamps at the edges rather than wrapping, as the grid pattern specifies. The select-alls are cells of the same grid — the header line and the first column — so there is one way to move and no separate tab stops hiding above the table. Each cell is named by both of its headers through aria-labelledby, role first, so it reads as a sentence about the role and the two names come from the rendered labels and translate with them. The header row and the role column pin on scroll by composing sticky-table-header, because a cell in the middle of a wide matrix is meaningless without both of its names on screen. One thing that component cannot do from the outside, and it was measured in a browser rather than assumed: the pinned column's right edge cannot be a border. Tailwind's preflight puts border-collapse: collapse on every table, and in the collapsed model a border belongs to the table's grid rather than to the cell — the cell is what sticks, the grid is not — so a border-r on the role column slides out from under it on the first sideways scroll and the column floats over the cells with no line down its side. It is drawn as a pseudo-element instead, which travels because it belongs to the cell. The row separators looked like they had to go the same way and they do not: a separator is horizontal and spans the whole row, so scrolling never carries it out from under the column, and collapsed borders paint above cell backgrounds rather than beneath them. Drawing those too would have been a second line on top of the real one, invisible until a theme gave the two different colours. They stay ordinary borders on the tr. Within this registry the neighbours are each a different shape. nested-checkbox is a hierarchy you select — parents, children and a third state down a tree — where this is the crossing of two independent lists, and the two are routinely wanted on the same screen. sort-header, column-visibility, column-resizer, sticky-table-header and table-row-expander are the table's other axes and none of them decides what the cells mean. transfer-list moves items between two panes, multi-select puts chips in one field, and neither has a second dimension. The API: roles is { key, label, inherits?, locked?, description? }[], actions is { key, label, requires?, locked?, description? }[], and grants is Record<roleKey, actionKey[]> — controlled with onGrantsChange, which is handed the next value and a change object naming what was granted, what was revoked and which of those are still granted anyway, or uncontrolled with defaultGrants. maxHeight and offset go through to the sticky wrapper, the three select-alls can each be turned off, announce turns the live region off, and every label is replaceable one at a time for another language. The logic is exported as pure functions so a server route, an audit view or a test can have the answers without mounting anything: effectivePermissions is the set an authorization check enforces for one role, and resolvePermissionMatrix, expandRequiredActions, inheritedRoleKeys, togglePermissionCell, togglePermissionRole, togglePermissionAction, togglePermissionMatrix, permissionCellReason and nextMatrixFocus are the rest. Styled entirely with shadcn tokens (primary, primary-foreground, input, background, border, muted-foreground, ring) so it follows light and dark mode; lucide-react for three icons is the only npm dependency, and there is no table library and no state library.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://pulld.pages.dev/r/sticky-table-header.json"
  ],
  "files": [
    {
      "path": "registry/ui/permission-matrix.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Lock, Minus } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { StickyTableHeader } from \"@/registry/ui/sticky-table-header\"\n\n/** One row: a role, a group, an API token — whatever the permissions are being handed to. */\nexport interface PermissionRole {\n  /** Unique among rows. Free-form: it is never concatenated into an id or a token list. */\n  key: string\n  label: React.ReactNode\n  /**\n   * Row keys this role is granted everything from. Transitive — a role inheriting a role that\n   * inherits a third gets all three — and safe against a cycle or a self-reference, both of which\n   * are easy to write in a config and neither of which may deadlock a cell (see\n   * {@link inheritedRoleKeys}).\n   *\n   * What is inherited is the parent's *effective* set, not the grants ticked on its row: \"Admin\n   * inherits Editor\" has to mean \"Admin can do everything Editor can do\", and Editor's own\n   * inheritance and requirements are part of that.\n   */\n  inherits?: string[]\n  /** Fixed: no cell in this row can be changed. The Owner row that always holds everything. */\n  locked?: boolean\n  /** Secondary line under the label. Announced after the name, via `aria-describedby`. */\n  description?: React.ReactNode\n}\n\n/** One column: a permission, a scope, a capability. */\nexport interface PermissionAction {\n  /** Unique among columns. Free-form, for the same reason as {@link PermissionRole.key}. */\n  key: string\n  label: React.ReactNode\n  /**\n   * Action keys that holding this one implies — `write` requires `read`. Transitive, and safe\n   * against cycles and self-reference (see {@link expandRequiredActions}).\n   *\n   * These are *derived*, never written into the value. Granting `write` does not tick `read`; it\n   * makes `read` granted, shown as such, and refused to a press that would clear it.\n   */\n  requires?: string[]\n  /** Fixed: this action cannot be changed for any role. */\n  locked?: boolean\n  /** Secondary line under the column label. */\n  description?: React.ReactNode\n}\n\n/**\n * The value: for each role key, the action keys **explicitly** ticked on that row.\n *\n * Derived grants are never in here — not the ones a requirement implies, not the ones inheritance\n * brings. The one-line alternative is to write the closure into the value instead, and it is wrong\n * in both directions. Forwards: untick `write` and `read` stays ticked with nothing on screen\n * saying why it is still there, because by then it is indistinguishable from a grant somebody made\n * on purpose. Backwards: the day a deploy says `write` also requires `list`, nobody who already\n * has `write` gets `list`, because their stored value was closed over the old graph — so the\n * matrix disagrees with whatever the server computes at check time, which is the thing actually\n * being enforced.\n *\n * Keeping only explicit grants also means there is no cascade to implement. \"Can you untick the\n * `read` that `write` pulled in\" is a question that only exists once the closure is in the value;\n * here `read` is not in the value, so there is nothing to untick and nothing to unwind.\n */\nexport type PermissionGrants = Record<string, string[]>\n\n/** Why a press on a cell is refused. `null` when it is not. */\nexport type PermissionBlock = \"locked\" | \"derived\"\n\n/** What one cell is, and why. */\nexport interface PermissionCellState {\n  roleKey: string\n  actionKey: string\n  /** Ticked on this row in the value. */\n  explicit: boolean\n  /**\n   * Other actions on this row that imply this one. Each is a *root cause* — explicitly granted or\n   * inherited — never another requirement, which is what keeps a `requires` cycle from pinning two\n   * cells against each other so that neither can be cleared.\n   */\n  requiredBy: string[]\n  /** Direct parents of this role whose effective set holds this action. */\n  inheritedFrom: string[]\n  /** What the server would enforce: `explicit || requiredBy.length > 0 || inheritedFrom.length > 0`. */\n  granted: boolean\n  /** Whether a press may change this cell. */\n  togglable: boolean\n  /** Why a press is refused, or `null`. */\n  blocked: PermissionBlock | null\n}\n\n/** A row's, a column's or the whole grid's summary — what the select-all above it shows and does. */\nexport interface PermissionAggregate {\n  /**\n   * What the select-all displays, counted over **every** cell in scope — derived and locked ones\n   * included. A scope claiming \"all of it\" while a cell nobody can see the reason for sits\n   * underneath would be lying about what is going to be enforced.\n   */\n  state: \"checked\" | \"unchecked\" | \"mixed\"\n  /** How many cells a press may change. Zero means the control says so instead of looking live. */\n  togglable: number\n  /**\n   * What a press would do, decided **only** from the togglable cells.\n   *\n   * This is the split `nested-checkbox` had to make for the same reason, one dimension up: read the\n   * direction off the displayed state and a row held at `\"mixed\"` by a single inherited cell is\n   * never `\"checked\"`, so every press reads as \"grant the rest\" and the row can be pressed forever\n   * without changing.\n   */\n  pressWould: \"grant\" | \"revoke\"\n}\n\n/** One cell, named by its two keys rather than by a joined string, since keys are free-form. */\nexport interface PermissionCellRef {\n  roleKey: string\n  actionKey: string\n}\n\n/** What a press did, handed to `onGrantsChange` beside the next value. */\nexport interface PermissionMatrixChange {\n  kind: \"cell\" | \"role\" | \"action\" | \"all\"\n  /** Set for `\"cell\"` and `\"role\"`. */\n  roleKey?: string\n  /** Set for `\"cell\"` and `\"action\"`. */\n  actionKey?: string\n  /** Cells that gained an explicit grant. */\n  granted: PermissionCellRef[]\n  /** Cells that lost one. */\n  revoked: PermissionCellRef[]\n  /**\n   * The subset of `revoked` that is **still granted** afterwards, because a requirement or\n   * inheritance holds it. These are the presses where the checkbox does not clear, which reads as\n   * a broken control unless something says so — the component announces it, and this is here so a\n   * caller can too.\n   */\n  stillGranted: PermissionCellRef[]\n}\n\nconst asArray = (value: readonly string[] | undefined): readonly string[] =>\n  Array.isArray(value) ? value : []\n\n/**\n * Every action implied by `keys`, including `keys` themselves, in `columns` order.\n *\n * Cycle-safe, and that is two separate requirements rather than one. Terminating is the easy half:\n * a visited set does it. The half that bites is that **a cycle must not report a key as requiring\n * itself.** Seed the frontier with `a`, follow `a → b → a`, and a naive walk adds `a` to the set of\n * things `a` requires; the cell is then permanently \"required by itself\", which makes it derived,\n * which refuses every press, and the grant can never be removed by anyone. The same holds for the\n * one-line version of the mistake, `requires: [\"self\"]`. So requirements are collected from the\n * *successors* of the frontier and the origins are added back at the end — a key appears here\n * because it was asked for or because something else needs it, never because of itself.\n *\n * Unknown keys are ignored rather than thrown on: a `requires` entry naming a column this deploy\n * no longer renders is a stale config, not a crash.\n */\nexport function expandRequiredActions(\n  columns: readonly PermissionAction[],\n  keys: Iterable<string>\n): string[] {\n  const byKey = new Map(columns.map((column) => [column.key, column]))\n  const origins = [...new Set(keys)]\n  const implied = new Set<string>()\n  const seen = new Set<string>()\n  let frontier = origins.filter((key) => byKey.has(key))\n\n  while (frontier.length > 0) {\n    const next: string[] = []\n    for (const key of frontier) {\n      if (seen.has(key)) continue\n      seen.add(key)\n      for (const required of asArray(byKey.get(key)?.requires)) {\n        if (!byKey.has(required)) continue\n        if (!implied.has(required)) {\n          implied.add(required)\n          next.push(required)\n        }\n      }\n    }\n    frontier = next\n  }\n\n  // Order by the column list so the result is stable regardless of how `keys` arrived, with keys\n  // the matrix does not render kept at the end rather than dropped — the server may know actions\n  // this screen does not.\n  const all = new Set([...origins, ...implied])\n  const ordered = columns.filter((column) => all.has(column.key)).map((column) => column.key)\n  const unknown = origins.filter((key) => !byKey.has(key))\n  return [...ordered, ...unknown]\n}\n\n/**\n * Every role `roleKey` inherits from, transitively, excluding itself — in `rows` order.\n *\n * Self-exclusion is load-bearing for the same reason as in {@link expandRequiredActions}: a role\n * that reaches itself through a cycle would hold every one of its own grants \"by inheritance\",\n * which makes each of them derived and each press on them refused. A role cannot be its own parent\n * here even if the config says so.\n */\nexport function inheritedRoleKeys(\n  rows: readonly PermissionRole[],\n  roleKey: string\n): string[] {\n  const byKey = new Map(rows.map((row) => [row.key, row]))\n  const ancestors = new Set<string>()\n  const seen = new Set<string>()\n  let frontier = [roleKey]\n\n  while (frontier.length > 0) {\n    const next: string[] = []\n    for (const key of frontier) {\n      if (seen.has(key)) continue\n      seen.add(key)\n      for (const parent of asArray(byKey.get(key)?.inherits)) {\n        if (!byKey.has(parent) || parent === roleKey) continue\n        if (!ancestors.has(parent)) {\n          ancestors.add(parent)\n          next.push(parent)\n        }\n      }\n    }\n    frontier = next\n  }\n\n  return rows.filter((row) => ancestors.has(row.key)).map((row) => row.key)\n}\n\n/** The explicit grants on one row, de-duplicated, in the order they arrived. */\nfunction explicitFor(grants: PermissionGrants | undefined, roleKey: string): string[] {\n  const list = grants?.[roleKey]\n  return Array.isArray(list) ? [...new Set(list)] : []\n}\n\n/**\n * Everything `roleKey` can actually do: its own grants, everything its parents can do, and the\n * closure of what all of that requires.\n *\n * This is the function a server route wants, and it is exported so the answer on the screen and the\n * answer in the authorization check come from one place. Keys the matrix does not render are kept.\n */\nexport function effectivePermissions(\n  rows: readonly PermissionRole[],\n  columns: readonly PermissionAction[],\n  grants: PermissionGrants | undefined,\n  roleKey: string\n): string[] {\n  const own = explicitFor(grants, roleKey)\n  const inherited = inheritedRoleKeys(rows, roleKey).flatMap((parent) =>\n    explicitFor(grants, parent)\n  )\n  return expandRequiredActions(columns, [...own, ...inherited])\n}\n\nfunction aggregate(cells: readonly PermissionCellState[]): PermissionAggregate {\n  let granted = 0\n  let togglable = 0\n  let togglableGranted = 0\n  for (const cell of cells) {\n    if (cell.granted) granted++\n    if (cell.togglable) {\n      togglable++\n      if (cell.explicit) togglableGranted++\n    }\n  }\n  const state =\n    cells.length === 0 || granted === 0\n      ? \"unchecked\"\n      : granted === cells.length\n        ? \"checked\"\n        : \"mixed\"\n  // Among togglable cells `granted` and `explicit` coincide — a cell that is granted without being\n  // explicit is derived, and derived cells are not togglable — so `explicit` is the honest field to\n  // count here, and it says what the press is going to write.\n  return {\n    state,\n    togglable,\n    pressWould: togglable > 0 && togglableGranted === togglable ? \"revoke\" : \"grant\",\n  }\n}\n\n/** Options shared by {@link resolvePermissionMatrix} and the component. */\nexport interface ResolvePermissionMatrixOptions {\n  /** Locks every cell. */\n  disabled?: boolean\n}\n\n/** Every cell, plus the summary each select-all needs. */\nexport interface ResolvedPermissionMatrix {\n  cells: PermissionCellState[]\n  /** Keyed by `roleKey`, then `actionKey`. */\n  cell: (roleKey: string, actionKey: string) => PermissionCellState | undefined\n  /** Keyed by `roleKey`. */\n  roles: Map<string, PermissionAggregate>\n  /** Keyed by `actionKey`. */\n  actions: Map<string, PermissionAggregate>\n  all: PermissionAggregate\n}\n\n/**\n * Resolves the whole grid in one pass over the rows: the state of every cell and the three kinds of\n * summary, from the rows, the columns and the explicit grants.\n *\n * Exported because a server-rendered table, a read-only audit view or a test wants the resolved\n * grid without mounting anything.\n */\nexport function resolvePermissionMatrix(\n  rows: readonly PermissionRole[],\n  columns: readonly PermissionAction[],\n  grants: PermissionGrants | undefined,\n  { disabled = false }: ResolvePermissionMatrixOptions = {}\n): ResolvedPermissionMatrix {\n  const byActionKey = new Map(columns.map((column) => [column.key, column]))\n\n  // One effective set per row, memoised, so a chain of inheriting roles costs one pass rather than\n  // one pass per descendant.\n  const effective = new Map<string, Set<string>>()\n  for (const row of rows) {\n    effective.set(row.key, new Set(effectivePermissions(rows, columns, grants, row.key)))\n  }\n\n  const cells: PermissionCellState[] = []\n  const index = new Map<string, Map<string, PermissionCellState>>()\n\n  for (const row of rows) {\n    const explicit = new Set(explicitFor(grants, row.key))\n    const parents = asArray(row.inherits).filter(\n      (parent) => parent !== row.key && effective.has(parent)\n    )\n\n    // The root causes on this row are the grants ticked on it, and requirements are followed *from*\n    // these and never from each other — which is what breaks a `requires` cycle into something a\n    // person can still untick.\n    //\n    // Inherited actions are deliberately not roots. An effective set is already closed under\n    // `requires`, so anything an inherited action implies is inherited in its own right and\n    // `inheritedFrom` has it covered; adding it here would only put a second clause on the same\n    // sentence — \"Required by Edit. Inherited from Editor.\" — about one grant with one place to go\n    // and change it.\n    const requiredBy = new Map<string, string[]>()\n    for (const root of explicit) {\n      if (!byActionKey.has(root)) continue\n      for (const implied of expandRequiredActions(columns, [root])) {\n        if (implied === root) continue\n        const list = requiredBy.get(implied)\n        if (list) list.push(root)\n        else requiredBy.set(implied, [root])\n      }\n    }\n\n    const perAction = new Map<string, PermissionCellState>()\n    for (const column of columns) {\n      const inheritedFrom = parents.filter((parent) =>\n        (effective.get(parent) as Set<string>).has(column.key)\n      )\n      const isExplicit = explicit.has(column.key)\n      const requirements = requiredBy.get(column.key) ?? []\n      const granted = isExplicit || requirements.length > 0 || inheritedFrom.length > 0\n      const locked = disabled || row.locked === true || column.locked === true\n      // A derived cell has nothing explicit to remove, so the press has no meaning; a derived cell\n      // that is *also* explicit does, and stays pressable — the press clears the explicit grant and\n      // the cell goes on being granted, which is the one case the live region has to narrate.\n      const blocked: PermissionBlock | null = locked\n        ? \"locked\"\n        : !isExplicit && granted\n          ? \"derived\"\n          : null\n\n      const cell: PermissionCellState = {\n        roleKey: row.key,\n        actionKey: column.key,\n        explicit: isExplicit,\n        requiredBy: requirements,\n        inheritedFrom,\n        granted,\n        togglable: blocked === null,\n        blocked,\n      }\n      cells.push(cell)\n      perAction.set(column.key, cell)\n    }\n    index.set(row.key, perAction)\n  }\n\n  const roles = new Map<string, PermissionAggregate>()\n  for (const row of rows) {\n    roles.set(row.key, aggregate(columns.map((column) => index.get(row.key)?.get(column.key) as PermissionCellState)))\n  }\n  const actions = new Map<string, PermissionAggregate>()\n  for (const column of columns) {\n    actions.set(\n      column.key,\n      aggregate(rows.map((row) => index.get(row.key)?.get(column.key) as PermissionCellState))\n    )\n  }\n\n  return {\n    cells,\n    cell: (roleKey, actionKey) => index.get(roleKey)?.get(actionKey),\n    roles,\n    actions,\n    all: aggregate(cells),\n  }\n}\n\n/** Replaces one row's explicit grants, keeping every other row — including rows not rendered. */\nfunction withRow(\n  grants: PermissionGrants | undefined,\n  roleKey: string,\n  next: string[]\n): PermissionGrants {\n  const out: PermissionGrants = { ...(grants ?? {}) }\n  if (next.length > 0) out[roleKey] = next\n  else delete out[roleKey]\n  return out\n}\n\n/**\n * The next value after pressing one cell, and what that did.\n *\n * A refused press returns the value it was handed, by identity, so a caller can tell a no-op from a\n * change without comparing contents.\n */\nexport function togglePermissionCell(\n  rows: readonly PermissionRole[],\n  columns: readonly PermissionAction[],\n  grants: PermissionGrants | undefined,\n  roleKey: string,\n  actionKey: string,\n  options: ResolvePermissionMatrixOptions = {}\n): { grants: PermissionGrants; change: PermissionMatrixChange } | null {\n  const resolved = resolvePermissionMatrix(rows, columns, grants, options)\n  const cell = resolved.cell(roleKey, actionKey)\n  if (!cell || !cell.togglable) return null\n\n  const current = explicitFor(grants, roleKey)\n  const nextRow = cell.explicit\n    ? current.filter((key) => key !== actionKey)\n    : [...current, actionKey]\n  const next = withRow(grants, roleKey, nextRow)\n\n  const ref: PermissionCellRef = { roleKey, actionKey }\n  const after = resolvePermissionMatrix(rows, columns, next, options).cell(roleKey, actionKey)\n  return {\n    grants: next,\n    change: {\n      kind: \"cell\",\n      roleKey,\n      actionKey,\n      granted: cell.explicit ? [] : [ref],\n      revoked: cell.explicit ? [ref] : [],\n      stillGranted: cell.explicit && after?.granted ? [ref] : [],\n    },\n  }\n}\n\n/** The next value after pressing a select-all over `scope`'s cells. */\nfunction toggleScope(\n  rows: readonly PermissionRole[],\n  columns: readonly PermissionAction[],\n  grants: PermissionGrants | undefined,\n  scope: readonly PermissionCellState[],\n  summary: PermissionAggregate,\n  kind: PermissionMatrixChange[\"kind\"],\n  named: { roleKey?: string; actionKey?: string },\n  options: ResolvePermissionMatrixOptions\n): { grants: PermissionGrants; change: PermissionMatrixChange } | null {\n  const togglable = scope.filter((cell) => cell.togglable)\n  if (togglable.length === 0) return null\n\n  const grant = summary.pressWould === \"grant\"\n  const byRole = new Map<string, Set<string>>()\n  for (const cell of togglable) {\n    if (cell.explicit === grant) continue\n    const set = byRole.get(cell.roleKey) ?? new Set<string>()\n    set.add(cell.actionKey)\n    byRole.set(cell.roleKey, set)\n  }\n  if (byRole.size === 0) return null\n\n  let next: PermissionGrants = { ...(grants ?? {}) }\n  for (const [roleKey, keys] of byRole) {\n    const current = explicitFor(grants, roleKey)\n    next = withRow(\n      next,\n      roleKey,\n      grant\n        ? [...current, ...columns.filter((c) => keys.has(c.key)).map((c) => c.key)]\n        : current.filter((key) => !keys.has(key))\n    )\n  }\n\n  const touched: PermissionCellRef[] = []\n  for (const [roleKey, keys] of byRole) {\n    for (const actionKey of keys) touched.push({ roleKey, actionKey })\n  }\n  const after = resolvePermissionMatrix(rows, columns, next, options)\n  return {\n    grants: next,\n    change: {\n      kind,\n      ...named,\n      granted: grant ? touched : [],\n      revoked: grant ? [] : touched,\n      stillGranted: grant\n        ? []\n        : touched.filter((ref) => after.cell(ref.roleKey, ref.actionKey)?.granted === true),\n    },\n  }\n}\n\n/** The next value after pressing a row's select-all. */\nexport function togglePermissionRole(\n  rows: readonly PermissionRole[],\n  columns: readonly PermissionAction[],\n  grants: PermissionGrants | undefined,\n  roleKey: string,\n  options: ResolvePermissionMatrixOptions = {}\n) {\n  const resolved = resolvePermissionMatrix(rows, columns, grants, options)\n  const summary = resolved.roles.get(roleKey)\n  if (!summary) return null\n  const scope = resolved.cells.filter((cell) => cell.roleKey === roleKey)\n  return toggleScope(rows, columns, grants, scope, summary, \"role\", { roleKey }, options)\n}\n\n/** The next value after pressing a column's select-all. */\nexport function togglePermissionAction(\n  rows: readonly PermissionRole[],\n  columns: readonly PermissionAction[],\n  grants: PermissionGrants | undefined,\n  actionKey: string,\n  options: ResolvePermissionMatrixOptions = {}\n) {\n  const resolved = resolvePermissionMatrix(rows, columns, grants, options)\n  const summary = resolved.actions.get(actionKey)\n  if (!summary) return null\n  const scope = resolved.cells.filter((cell) => cell.actionKey === actionKey)\n  return toggleScope(rows, columns, grants, scope, summary, \"action\", { actionKey }, options)\n}\n\n/** The next value after pressing the corner select-all. */\nexport function togglePermissionMatrix(\n  rows: readonly PermissionRole[],\n  columns: readonly PermissionAction[],\n  grants: PermissionGrants | undefined,\n  options: ResolvePermissionMatrixOptions = {}\n) {\n  const resolved = resolvePermissionMatrix(rows, columns, grants, options)\n  return toggleScope(rows, columns, grants, resolved.cells, resolved.all, \"all\", {}, options)\n}\n\n/** A position in the grid. Row and column `0` are the select-all header line and column. */\nexport interface MatrixFocus {\n  row: number\n  col: number\n}\n\n/**\n * Where an arrow, Home or End moves focus inside the grid.\n *\n * Clamped rather than wrapped, which is what the ARIA grid pattern specifies: at the rightmost cell\n * a press of Right End does nothing, instead of teleporting to the other side of a table that may be\n * wider than the screen. `Home`/`End` work along the row and take the modifier to mean the grid, so\n * the two corners are one chord away in a grid of any size.\n *\n * Exported because it is the part of a roving tabindex that is wrong in most implementations — off\n * the end, over the header line, or along the wrong axis — and it is pure, so it can be asserted\n * without a DOM.\n */\nexport function nextMatrixFocus(\n  current: MatrixFocus,\n  key: string,\n  options: { rows: number; cols: number; ctrl?: boolean }\n): MatrixFocus | null {\n  const lastRow = options.rows\n  const lastCol = options.cols\n  const clamp = (value: number, max: number) => Math.max(0, Math.min(value, max))\n  const at = (row: number, col: number): MatrixFocus | null => {\n    const next = { row: clamp(row, lastRow), col: clamp(col, lastCol) }\n    return next.row === current.row && next.col === current.col ? null : next\n  }\n\n  switch (key) {\n    case \"ArrowUp\":\n      return at(current.row - 1, current.col)\n    case \"ArrowDown\":\n      return at(current.row + 1, current.col)\n    case \"ArrowLeft\":\n      return at(current.row, current.col - 1)\n    case \"ArrowRight\":\n      return at(current.row, current.col + 1)\n    case \"Home\":\n      return options.ctrl ? at(0, 0) : at(current.row, 0)\n    case \"End\":\n      return options.ctrl ? at(lastRow, lastCol) : at(current.row, lastCol)\n    default:\n      return null\n  }\n}\n\n/** Every string a reader sees or hears. Override to translate or to reword. */\nexport interface PermissionMatrixLabels {\n  /** Names the grid when no `aria-label` is given, and used as the table's caption. */\n  caption: string\n  /** The corner select-all. */\n  all: string\n  /** A row's select-all. */\n  allForRole: (role: string) => string\n  /** A column's select-all. */\n  actionForAllRoles: (action: string) => string\n  /** Each cell's accessible name. Role first, so it reads as a sentence about the role. */\n  cell: (role: string, action: string) => string\n  /** Why a cell is granted although nothing is ticked on it. */\n  requiredBy: (actions: string) => string\n  /** Why a cell is granted although nothing is ticked on it, by inheritance. */\n  inheritedFrom: (roles: string) => string\n  /** Why a cell cannot be changed at all. */\n  locked: string\n  /** Announced after a cell is granted. */\n  granted: (role: string, action: string) => string\n  /** Announced after a cell is revoked and is no longer granted. */\n  revoked: (role: string, action: string) => string\n  /**\n   * Announced after a cell is revoked and is **still granted** — the press where the tick does not\n   * go away. Without this the control reads as broken.\n   */\n  revokedStillGranted: (role: string, action: string, reason: string) => string\n  /** Announced when a press is refused, with the reason. */\n  refused: (reason: string) => string\n  /** Announced after a select-all granted cells. */\n  bulkGranted: (count: number, scope: string) => string\n  /** Announced after a select-all revoked cells, with how many are still granted. */\n  bulkRevoked: (count: number, scope: string, stillGranted: number) => string\n  /** Announced when a select-all had nothing it was allowed to change. */\n  bulkNothing: (scope: string) => string\n  /** Joins several reasons into one sentence. */\n  joinReasons: (reasons: string[]) => string\n}\n\nexport const defaultPermissionMatrixLabels: PermissionMatrixLabels = {\n  caption: \"Permissions by role\",\n  all: \"All permissions for all roles\",\n  allForRole: (role) => `All permissions for ${role}`,\n  actionForAllRoles: (action) => `${action} for all roles`,\n  cell: (role, action) => `${role}: ${action}`,\n  requiredBy: (actions) => `Required by ${actions}`,\n  inheritedFrom: (roles) => `Inherited from ${roles}`,\n  locked: \"Fixed, cannot be changed\",\n  granted: (role, action) => `${action} granted to ${role}.`,\n  revoked: (role, action) => `${action} removed from ${role}.`,\n  revokedStillGranted: (role, action, reason) =>\n    `${action} is no longer set on ${role} directly, and is still granted. ${reason}.`,\n  refused: (reason) => `Cannot be changed. ${reason}.`,\n  bulkGranted: (count, scope) => `${count} granted. ${scope}.`,\n  bulkRevoked: (count, scope, stillGranted) =>\n    stillGranted > 0\n      ? `${count} removed. ${stillGranted} still granted by inheritance or a requirement. ${scope}.`\n      : `${count} removed. ${scope}.`,\n  bulkNothing: (scope) => `Nothing can be changed. ${scope}.`,\n  joinReasons: (reasons) => reasons.join(\". \"),\n}\n\n/**\n * The sentence that says why a cell is the way it is, or `null` when there is nothing to say.\n *\n * Exported so a read-only view of the same data can explain itself with the same words.\n */\nexport function permissionCellReason(\n  cell: PermissionCellState,\n  names: { role: (key: string) => string; action: (key: string) => string },\n  labels: PermissionMatrixLabels = defaultPermissionMatrixLabels\n): string | null {\n  const reasons: string[] = []\n  if (cell.blocked === \"locked\") reasons.push(labels.locked)\n  if (cell.requiredBy.length > 0) {\n    reasons.push(labels.requiredBy(cell.requiredBy.map(names.action).join(\", \")))\n  }\n  if (cell.inheritedFrom.length > 0) {\n    reasons.push(labels.inheritedFrom(cell.inheritedFrom.map(names.role).join(\", \")))\n  }\n  return reasons.length > 0 ? labels.joinReasons(reasons) : null\n}\n\n/**\n * The right edge of the pinned role column, which cannot be a border.\n *\n * Tailwind's preflight puts `border-collapse: collapse` on every `<table>`, and in the collapsed\n * model a border belongs to the table's grid rather than to the cell that declared it. The cell is\n * what `position: sticky` moves; the grid is not. So a `border-r` on the role column stays at the\n * table's own left edge and slides away underneath the pinned cell the moment anything is scrolled\n * sideways, leaving the column floating over the permission cells with no line down its side.\n *\n * `sticky-table-header` documents this for the header's bottom edge and fixes it there with an\n * `::after`, which travels because it belongs to the cell. This is the same bug on the other axis,\n * and outside what that component can fix: it pins the cells, and what needs this line is the role\n * column's own trailing edge.\n *\n * **Only this one line, and that was measured rather than assumed.** The row separators looked like\n * they had to go the same way — `border-t` on a `<tr>` is a collapsed border too, so the pinned\n * cell's opaque background ought to hide it. In a browser it does not, and both halves of the\n * reason matter: a row separator is horizontal and spans the whole row, so scrolling never carries\n * it out from under the column the way it carries the vertical edge, and collapsed borders are\n * painted above cell backgrounds rather than beneath them. Drawing them here as well would have\n * been a second line on top of the real one, invisible until a theme gave the two different\n * colours. Row separators stay ordinary `border-t` on the `<tr>`.\n *\n * `::before` rather than `::after` because the corner cell is a `thead th` and already carries\n * `sticky-table-header`'s `::after`.\n */\nconst PINNED_COLUMN_EDGES = [\n  \"[&_tbody_th]:before:absolute [&_tbody_th]:before:inset-y-0 [&_tbody_th]:before:right-0\",\n  \"[&_tbody_th]:before:w-px [&_tbody_th]:before:bg-border [&_tbody_th]:before:content-['']\",\n  \"[&_thead_th:first-child]:before:absolute [&_thead_th:first-child]:before:inset-y-0\",\n  \"[&_thead_th:first-child]:before:right-0 [&_thead_th:first-child]:before:w-px\",\n  \"[&_thead_th:first-child]:before:bg-border [&_thead_th:first-child]:before:content-['']\",\n].join(\" \")\n\nexport interface PermissionMatrixProps {\n  /** The rows. */\n  roles: readonly PermissionRole[]\n  /** The columns. */\n  actions: readonly PermissionAction[]\n  /** Controlled explicit grants. Pair with `onGrantsChange`. */\n  grants?: PermissionGrants\n  /** Explicit grants on first render when uncontrolled. */\n  defaultGrants?: PermissionGrants\n  /** Handed the next value and what the press did. */\n  onGrantsChange?: (next: PermissionGrants, change: PermissionMatrixChange) => void\n  /** Locks every cell. */\n  disabled?: boolean\n  /** Show the row select-all in the first column. Defaults to `true`. */\n  roleSelectAll?: boolean\n  /** Show the column select-all in the header. Defaults to `true`. */\n  actionSelectAll?: boolean\n  /** Show the corner select-all. Defaults to `true`. */\n  selectAll?: boolean\n  /**\n   * Makes the component the scrolling box, capped here, so the header row and the role column both\n   * stay put. Passed through to `sticky-table-header`, which is the only arrangement in which a\n   * table scrolls sideways and keeps a pinned header — see its notes on what owns the scrollport.\n   */\n  maxHeight?: number | string\n  /** Distance from the top of the scrollport to pin the header at. For a fixed app bar. */\n  offset?: number | string\n  /** Announce changes through a live region. Defaults to `true`. */\n  announce?: boolean\n  labels?: Partial<PermissionMatrixLabels>\n  className?: string\n  \"aria-label\"?: string\n  \"aria-labelledby\"?: string\n}\n\n/** Flattens a label to the string an accessible name and an announcement need. */\nfunction labelText(label: React.ReactNode, fallback: string): string {\n  if (typeof label === \"string\") return label\n  if (typeof label === \"number\") return String(label)\n  return fallback\n}\n\n/**\n * The role × permission grid: a table of checkboxes where the rows are roles and the columns are\n * the things they may do.\n *\n * Reach for it on an admin screen's role editor, a team's permission settings, the scope list on an\n * API token, or a sharing dialog that has outgrown three radio buttons.\n *\n * ```tsx\n * <PermissionMatrix\n *   roles={[\n *     { key: \"viewer\", label: \"Viewer\" },\n *     { key: \"editor\", label: \"Editor\", inherits: [\"viewer\"] },\n *     { key: \"owner\", label: \"Owner\", inherits: [\"editor\"], locked: true },\n *   ]}\n *   actions={[\n *     { key: \"read\", label: \"View\" },\n *     { key: \"write\", label: \"Edit\", requires: [\"read\"] },\n *     { key: \"delete\", label: \"Delete\", requires: [\"write\"] },\n *   ]}\n *   grants={grants}\n *   onGrantsChange={setGrants}\n * />\n * ```\n *\n * **The value is the explicit grants and nothing else.** A cell can be granted four ways — ticked\n * here, implied by another action on the row, inherited from a parent role, or some combination —\n * and only the first is stored. See {@link PermissionGrants} for why writing the closure into the\n * value is wrong in both directions, and {@link effectivePermissions} for the set a server enforces.\n *\n * **A derived cell is `aria-checked=\"true\"` and `aria-disabled`.** It is granted, so saying\n * otherwise would be a lie about what is enforced; there is nothing explicit on it to remove, so\n * the press is refused and the refusal names what is holding it — `aria-disabled` rather than\n * `disabled` so the cell stays focusable and somebody on a keyboard can find out that the grant\n * exists and where it comes from.\n *\n * **One tab stop, arrow keys inside.** A table of checkboxes where every cell is a tab stop costs\n * eight roles times ten permissions equals eighty presses of Tab to get past, which is why this is\n * `role=\"grid\"` with a roving tabindex: Tab reaches the grid, the arrows move within it, Home and\n * End run along the row, and with the modifier they go to the corners. The select-alls are cells in\n * the same grid — the header line and the first column — so there is one way to move and no\n * separate tab stops hiding above the table.\n *\n * **The header row and the role column pin on scroll**, by composing `sticky-table-header`: a\n * matrix of any size scrolls in both directions and a cell in the middle of it is meaningless\n * without both of its names on screen.\n */\nexport function PermissionMatrix({\n  roles,\n  actions,\n  grants,\n  defaultGrants,\n  onGrantsChange,\n  disabled = false,\n  roleSelectAll = true,\n  actionSelectAll = true,\n  selectAll = true,\n  maxHeight,\n  offset = 0,\n  announce = true,\n  labels: labelOverrides,\n  className,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n}: PermissionMatrixProps) {\n  const labels = React.useMemo(\n    () => ({ ...defaultPermissionMatrixLabels, ...labelOverrides }),\n    [labelOverrides]\n  )\n  const controlled = grants !== undefined\n  const [internal, setInternal] = React.useState<PermissionGrants>(\n    () => (controlled ? (grants as PermissionGrants) : defaultGrants) ?? {}\n  )\n  const value = controlled ? (grants as PermissionGrants) : internal\n\n  const reactId = React.useId()\n  const resolved = React.useMemo(\n    () => resolvePermissionMatrix(roles, actions, value, { disabled }),\n    [roles, actions, value, disabled]\n  )\n\n  const roleName = React.useCallback(\n    (key: string) => {\n      const row = roles.find((candidate) => candidate.key === key)\n      return row ? labelText(row.label, key) : key\n    },\n    [roles]\n  )\n  const actionName = React.useCallback(\n    (key: string) => {\n      const column = actions.find((candidate) => candidate.key === key)\n      return column ? labelText(column.label, key) : key\n    },\n    [actions]\n  )\n  const names = React.useMemo(\n    () => ({ role: roleName, action: actionName }),\n    [roleName, actionName]\n  )\n\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  React.useEffect(() => {\n    if (!announcement) return\n    const timer = setTimeout(() => setAnnouncement(\"\"), 1600)\n    return () => clearTimeout(timer)\n  }, [announcement])\n  const say = React.useCallback(\n    (message: string) => {\n      if (!announce) return\n      // Setting a live region to the text it already holds is not a change, so the second identical\n      // refusal in a row would be silent. A zero-width space makes it a different string and leaves\n      // the sentence looking and sounding the same — the trick `column-visibility` uses, for the\n      // same reason.\n      setAnnouncement((current) => (current === message ? `${message}​` : message))\n    },\n    [announce]\n  )\n\n  const commit = React.useCallback(\n    (result: { grants: PermissionGrants; change: PermissionMatrixChange } | null) => {\n      if (!result) return false\n      if (!controlled) setInternal(result.grants)\n      onGrantsChange?.(result.grants, result.change)\n      return true\n    },\n    [controlled, onGrantsChange]\n  )\n\n  // Row and column 0 are the select-all line and the role column, so the grid is one larger than\n  // the matrix on both axes and every interactive thing in the table is reachable the same way.\n  const [focus, setFocus] = React.useState<MatrixFocus>({ row: 1, col: 1 })\n  const cellRefs = React.useRef(new Map<string, { focus?: () => void }>())\n  const moving = React.useRef(false)\n  const posKey = (row: number, col: number) => `${row}:${col}`\n\n  React.useEffect(() => {\n    if (!moving.current) return\n    moving.current = false\n    cellRefs.current.get(posKey(focus.row, focus.col))?.focus?.()\n  }, [focus])\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLTableElement>) => {\n    const next = nextMatrixFocus(focus, event.key, {\n      rows: roles.length,\n      cols: actions.length,\n      ctrl: event.ctrlKey || event.metaKey,\n    })\n    if (!next) return\n    event.preventDefault()\n    moving.current = true\n    setFocus(next)\n  }\n\n  const register = (row: number, col: number) => (node: { focus?: () => void } | null) => {\n    if (node) cellRefs.current.set(posKey(row, col), node)\n    else cellRefs.current.delete(posKey(row, col))\n  }\n\n  const scopeAll = labels.all\n\n  function pressCell(roleKey: string, actionKey: string) {\n    const cell = resolved.cell(roleKey, actionKey)\n    if (!cell) return\n    if (!cell.togglable) {\n      const reason = permissionCellReason(cell, names, labels)\n      if (reason) say(labels.refused(reason))\n      return\n    }\n    const result = togglePermissionCell(roles, actions, value, roleKey, actionKey, { disabled })\n    if (!commit(result) || !result) return\n    const role = roleName(roleKey)\n    const action = actionName(actionKey)\n    if (result.change.granted.length > 0) {\n      say(labels.granted(role, action))\n      return\n    }\n    if (result.change.stillGranted.length > 0) {\n      const after = resolvePermissionMatrix(roles, actions, result.grants, { disabled }).cell(\n        roleKey,\n        actionKey\n      )\n      const reason = after ? permissionCellReason(after, names, labels) : null\n      say(labels.revokedStillGranted(role, action, reason ?? \"\"))\n      return\n    }\n    say(labels.revoked(role, action))\n  }\n\n  function pressScope(\n    result: { grants: PermissionGrants; change: PermissionMatrixChange } | null,\n    summary: PermissionAggregate | undefined,\n    scopeLabel: string\n  ) {\n    if (!result || summary?.togglable === 0) {\n      say(labels.bulkNothing(scopeLabel))\n      return\n    }\n    if (!commit(result)) {\n      say(labels.bulkNothing(scopeLabel))\n      return\n    }\n    if (result.change.granted.length > 0) {\n      say(labels.bulkGranted(result.change.granted.length, scopeLabel))\n    } else {\n      say(\n        labels.bulkRevoked(\n          result.change.revoked.length,\n          scopeLabel,\n          result.change.stillGranted.length\n        )\n      )\n    }\n  }\n\n  const ariaChecked = (state: PermissionAggregate[\"state\"]) =>\n    state === \"mixed\" ? (\"mixed\" as const) : state === \"checked\"\n\n  return (\n    <div className={cn(\"text-foreground\", className)}>\n      <StickyTableHeader\n        maxHeight={maxHeight}\n        offset={offset}\n        stickyColumns={1}\n        className={PINNED_COLUMN_EDGES}\n      >\n        <table\n          role=\"grid\"\n          aria-label={ariaLabelledby ? undefined : (ariaLabel ?? labels.caption)}\n          aria-labelledby={ariaLabelledby}\n          aria-disabled={disabled || undefined}\n          onKeyDown={onKeyDown}\n          className=\"w-full border-collapse text-sm\"\n        >\n          <caption className=\"sr-only\">{labels.caption}</caption>\n          <thead>\n            <tr>\n              <th\n                scope=\"col\"\n                role=\"columnheader\"\n                className=\"bg-background px-3 py-2 text-left align-bottom font-medium\"\n              >\n                {selectAll ? (\n                  <CheckboxCell\n                    ref={register(0, 0)}\n                    label={scopeAll}\n                    state={ariaChecked(resolved.all.state)}\n                    inert={resolved.all.togglable === 0}\n                    tabIndex={focus.row === 0 && focus.col === 0 ? 0 : -1}\n                    onFocus={() => setFocus({ row: 0, col: 0 })}\n                    onPress={() =>\n                      pressScope(\n                        togglePermissionMatrix(roles, actions, value, { disabled }),\n                        resolved.all,\n                        scopeAll\n                      )\n                    }\n                  />\n                ) : (\n                  <span className=\"sr-only\">{labels.caption}</span>\n                )}\n              </th>\n              {actions.map((column, columnIndex) => {\n                const summary = resolved.actions.get(column.key)\n                const labelId = `${reactId}-a${columnIndex}`\n                const descId = `${labelId}-d`\n                const scopeLabel = labels.actionForAllRoles(actionName(column.key))\n                return (\n                  <th\n                    key={column.key}\n                    scope=\"col\"\n                    role=\"columnheader\"\n                    className=\"bg-background px-3 py-2 align-bottom font-medium\"\n                  >\n                    <div className=\"flex flex-col items-center gap-1.5\">\n                      <span id={labelId} className=\"text-xs leading-tight\">\n                        {column.label}\n                      </span>\n                      {column.description && (\n                        <span id={descId} className=\"text-[11px] text-muted-foreground\">\n                          {column.description}\n                        </span>\n                      )}\n                      {actionSelectAll && summary && (\n                        <CheckboxCell\n                          ref={register(0, columnIndex + 1)}\n                          label={scopeLabel}\n                          describedBy={column.description ? descId : undefined}\n                          state={ariaChecked(summary.state)}\n                          inert={summary.togglable === 0}\n                          tabIndex={focus.row === 0 && focus.col === columnIndex + 1 ? 0 : -1}\n                          onFocus={() => setFocus({ row: 0, col: columnIndex + 1 })}\n                          onPress={() =>\n                            pressScope(\n                              togglePermissionAction(roles, actions, value, column.key, {\n                                disabled,\n                              }),\n                              summary,\n                              scopeLabel\n                            )\n                          }\n                        />\n                      )}\n                    </div>\n                  </th>\n                )\n              })}\n            </tr>\n          </thead>\n          <tbody>\n            {roles.map((row, rowIndex) => {\n              const summary = resolved.roles.get(row.key)\n              const labelId = `${reactId}-r${rowIndex}`\n              const descId = `${labelId}-d`\n              const scopeLabel = labels.allForRole(roleName(row.key))\n              return (\n                <tr key={row.key} className=\"border-t border-border\">\n                  <th\n                    scope=\"row\"\n                    role=\"rowheader\"\n                    className=\"bg-background px-3 py-2 text-left font-normal\"\n                  >\n                    <div className=\"flex items-center gap-2\">\n                      {roleSelectAll && summary && (\n                        <CheckboxCell\n                          ref={register(rowIndex + 1, 0)}\n                          label={scopeLabel}\n                          state={ariaChecked(summary.state)}\n                          inert={summary.togglable === 0}\n                          tabIndex={focus.row === rowIndex + 1 && focus.col === 0 ? 0 : -1}\n                          onFocus={() => setFocus({ row: rowIndex + 1, col: 0 })}\n                          onPress={() =>\n                            pressScope(\n                              togglePermissionRole(roles, actions, value, row.key, { disabled }),\n                              summary,\n                              scopeLabel\n                            )\n                          }\n                        />\n                      )}\n                      <span>\n                        <span id={labelId} className=\"font-medium\">\n                          {row.label}\n                        </span>\n                        {row.description && (\n                          <span\n                            id={descId}\n                            className=\"block text-xs text-muted-foreground\"\n                          >\n                            {row.description}\n                          </span>\n                        )}\n                      </span>\n                    </div>\n                  </th>\n                  {actions.map((column, columnIndex) => {\n                    const cell = resolved.cell(row.key, column.key) as PermissionCellState\n                    const reason = permissionCellReason(cell, names, labels)\n                    const reasonId = `${reactId}-c${rowIndex}-${columnIndex}`\n                    return (\n                      <td key={column.key} role=\"gridcell\" className=\"px-3 py-2 text-center\">\n                        <CheckboxCell\n                          ref={register(rowIndex + 1, columnIndex + 1)}\n                          // Named by both of its headers rather than by a string of its own, so the\n                          // two names come from the rendered labels and translate with them. Role\n                          // first: \"Editor, Delete\" is a sentence about the role, which is the\n                          // question being answered one cell at a time.\n                          labelledBy={`${labelId} ${`${reactId}-a${columnIndex}`}`}\n                          describedBy={reason ? reasonId : undefined}\n                          state={cell.granted}\n                          derived={cell.granted && !cell.explicit}\n                          locked={cell.blocked === \"locked\"}\n                          inert={!cell.togglable}\n                          tabIndex={\n                            focus.row === rowIndex + 1 && focus.col === columnIndex + 1 ? 0 : -1\n                          }\n                          onFocus={() => setFocus({ row: rowIndex + 1, col: columnIndex + 1 })}\n                          onPress={() => pressCell(row.key, column.key)}\n                        />\n                        {reason && (\n                          <span id={reasonId} className=\"sr-only\">\n                            {reason}\n                          </span>\n                        )}\n                      </td>\n                    )\n                  })}\n                </tr>\n              )\n            })}\n          </tbody>\n        </table>\n      </StickyTableHeader>\n      {/*\n        Mounted at all times rather than rendered with the message: a live region inserted together\n        with its text is not an update to a region that was already being watched, and the first\n        announcement is the one that goes missing.\n      */}\n      <div aria-live=\"polite\" role=\"status\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </div>\n  )\n}\n\ninterface CheckboxCellProps {\n  label?: string\n  labelledBy?: string\n  describedBy?: string\n  state: boolean | \"mixed\"\n  /** Granted, but by something other than this cell — drawn differently so the tick is readable. */\n  derived?: boolean\n  locked?: boolean\n  /** A press cannot change anything here. */\n  inert?: boolean\n  tabIndex: number\n  onFocus: () => void\n  onPress: () => void\n}\n\n/**\n * One pressable cell: the cells, the row and column select-alls and the corner are all this.\n *\n * `role=\"checkbox\"` on a button rather than an `<input type=\"checkbox\">`, for the reason\n * `nested-checkbox` is built the same way: the third state is the value `aria-checked=\"mixed\"`, and\n * `indeterminate` is a DOM property with no HTML attribute behind it, so React cannot express it in\n * JSX and it has to be written to the node through a ref after mount. Everything that reads markup\n * instead of a live DOM — the server render, the first paint, a snapshot, a crawler — then sees\n * `aria-checked=\"false\"` on a control that is part-selected. Here the state is an attribute and the\n * markup is true at every moment.\n */\nconst CheckboxCell = React.forwardRef<HTMLButtonElement, CheckboxCellProps>(\n  function CheckboxCell(\n    { label, labelledBy, describedBy, state, derived, locked, inert, tabIndex, onFocus, onPress },\n    ref\n  ) {\n    const on = state === true || state === \"mixed\"\n    return (\n      <button\n        ref={ref}\n        type=\"button\"\n        role=\"checkbox\"\n        aria-checked={state === \"mixed\" ? \"mixed\" : state}\n        // aria-disabled, not the `disabled` attribute: a refused cell stays focusable, so somebody\n        // moving through the grid can still find out that the grant exists and what is holding it.\n        // `disabled` would take it out of the grid and leave no way to ask.\n        aria-disabled={inert || undefined}\n        aria-label={labelledBy ? undefined : label}\n        aria-labelledby={labelledBy}\n        aria-describedby={describedBy}\n        tabIndex={tabIndex}\n        onFocus={onFocus}\n        onClick={onPress}\n        className={cn(\n          \"group inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1\",\n          !on && \"border-input bg-background\",\n          on && !derived && \"border-primary bg-primary text-primary-foreground\",\n          // A derived tick is drawn as an outline rather than a filled box, so \"granted here\" and\n          // \"granted somewhere else\" are told apart without reading the tooltip — the distinction\n          // core 2 of this component is about, and the thing a screen reader gets from\n          // aria-describedby instead.\n          on && derived && \"border-primary bg-primary/15 text-primary\",\n          inert ? \"cursor-not-allowed\" : \"cursor-pointer hover:border-primary\"\n        )}\n      >\n        {locked ? (\n          <Lock className=\"h-3 w-3\" aria-hidden=\"true\" />\n        ) : state === \"mixed\" ? (\n          <Minus className=\"h-3.5 w-3.5\" strokeWidth={3} aria-hidden=\"true\" />\n        ) : on ? (\n          <Check className=\"h-3.5 w-3.5\" strokeWidth={3} aria-hidden=\"true\" />\n        ) : null}\n      </button>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
