{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "permission-matrix",
  "title": "Permission Matrix",
  "description": "Role and access management grid of roles against permissions, with tri-state group toggles, sticky headers, and inherited permissions.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "badge",
    "input",
    "@drift-ui/use-controllable-state",
    "@drift-ui/shape"
  ],
  "files": [
    {
      "path": "registry/default/ui/permission-matrix.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  CheckIcon,\n  ChevronRightIcon,\n  MinusIcon,\n  SearchIcon,\n  XIcon,\n} from \"lucide-react\"\n\nimport { cn } from \"@/registry/default/lib/utils\"\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport { Badge } from \"@/registry/default/ui/badge\"\nimport { Input } from \"@/registry/default/ui/input\"\nimport {\n  filterPermissionGroups,\n  getGridNextCoordinates,\n  getGroupStatus,\n  getPermissionStatus,\n  resolveRoleInheritance,\n  toggleGroupGrants,\n  togglePermissionGrant,\n  type PermissionAction,\n  type PermissionGrants,\n  type PermissionGroup,\n  type PermissionRole,\n  type PermissionStatus,\n} from \"@/registry/default/lib/permission-matrix.ts\"\n\nexport type {\n  PermissionAction,\n  PermissionGrants,\n  PermissionGroup,\n  PermissionRole,\n  PermissionStatus,\n}\n\nexport interface PermissionMatrixProps extends Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"value\" | \"onChange\"\n> {\n  roles: PermissionRole[]\n  permissions: PermissionGroup[]\n  value?: PermissionGrants\n  defaultValue?: PermissionGrants\n  onValueChange?: (value: PermissionGrants) => void\n\n  readOnly?: boolean\n  disabled?: boolean\n  searchable?: boolean\n  searchPlaceholder?: string\n  allowOverrideInherited?: boolean\n\n  expandedGroups?: string[]\n  defaultExpandedGroups?: string[]\n  onExpandedGroupsChange?: (groups: string[]) => void\n\n  \"aria-label\"?: string\n}\n\ntype MatrixRow =\n  | {\n      type: \"group\"\n      id: string\n      group: PermissionGroup\n    }\n  | {\n      type: \"action\"\n      id: string\n      group: PermissionGroup\n      action: PermissionAction\n    }\n\nexport function PermissionMatrix({\n  roles = [],\n  permissions = [],\n  value: valueProp,\n  defaultValue = {},\n  onValueChange,\n  readOnly = false,\n  disabled = false,\n  searchable = true,\n  searchPlaceholder = \"Filter permissions...\",\n  allowOverrideInherited = true,\n  expandedGroups: expandedProp,\n  defaultExpandedGroups,\n  onExpandedGroupsChange,\n  \"aria-label\": ariaLabel = \"Permissions Matrix\",\n  className,\n  ...props\n}: PermissionMatrixProps) {\n  const { value: grants, setValue: setGrants } =\n    useControllableState<PermissionGrants>({\n      value: valueProp,\n      defaultValue,\n      onChange: onValueChange,\n    })\n\n  const [searchQuery, setSearchQuery] = React.useState(\"\")\n\n  const initialExpanded = React.useMemo(() => {\n    if (defaultExpandedGroups) return defaultExpandedGroups\n    return permissions.map((g) => g.id)\n  }, [defaultExpandedGroups, permissions])\n\n  const { value: expanded, setValue: setExpanded } = useControllableState<\n    string[]\n  >({\n    value: expandedProp,\n    defaultValue: initialExpanded,\n    onChange: onExpandedGroupsChange,\n  })\n\n  const inheritanceMap = React.useMemo(\n    () => resolveRoleInheritance(roles),\n    [roles]\n  )\n\n  const actionMap = React.useMemo(() => {\n    const map = new Map<string, PermissionAction>()\n    for (const group of permissions) {\n      for (const action of group.permissions) {\n        map.set(action.id, action)\n      }\n    }\n    return map\n  }, [permissions])\n\n  const filteredGroups = React.useMemo(\n    () => filterPermissionGroups(permissions, searchQuery),\n    [permissions, searchQuery]\n  )\n\n  // Automatically treat groups as expanded when searching\n  const effectiveExpanded = React.useMemo(() => {\n    if (searchQuery.trim()) {\n      return filteredGroups.map((g) => g.id)\n    }\n    return expanded\n  }, [searchQuery, filteredGroups, expanded])\n\n  function toggleGroupExpansion(groupId: string) {\n    if (searchQuery.trim()) return\n    const isExpanded = effectiveExpanded.includes(groupId)\n    const next = isExpanded\n      ? effectiveExpanded.filter((id: string) => id !== groupId)\n      : [...effectiveExpanded, groupId]\n    setExpanded(next)\n  }\n\n  // Flatten rows for grid representation\n  const rows: MatrixRow[] = React.useMemo(() => {\n    const list: MatrixRow[] = []\n    for (const group of filteredGroups) {\n      list.push({ type: \"group\", id: `group-${group.id}`, group })\n      if (effectiveExpanded.includes(group.id)) {\n        for (const action of group.permissions) {\n          list.push({\n            type: \"action\",\n            id: `action-${group.id}-${action.id}`,\n            group,\n            action,\n          })\n        }\n      }\n    }\n    return list\n  }, [filteredGroups, effectiveExpanded])\n\n  // Grid coordinates: row 0 is header row, row 1..rows.length are body rows.\n  // Col 0 is permissions column, Col 1..roles.length are role columns.\n  const totalRows = rows.length + 1\n  const totalCols = roles.length + 1\n\n  const [focusedCell, setFocusedCell] = React.useState<[number, number]>([0, 0])\n  const cellRefs = React.useRef<Map<string, HTMLElement>>(new Map())\n\n  function getCellKey(r: number, c: number) {\n    return `${r}:${c}`\n  }\n\n  function handleCellKeyDown(\n    e: React.KeyboardEvent,\n    rowIndex: number,\n    colIndex: number\n  ) {\n    const key = e.key\n\n    if (\n      [\n        \"ArrowUp\",\n        \"ArrowDown\",\n        \"ArrowLeft\",\n        \"ArrowRight\",\n        \"Home\",\n        \"End\",\n        \"PageUp\",\n        \"PageDown\",\n      ].includes(key)\n    ) {\n      e.preventDefault()\n      const [nextRow, nextCol] = getGridNextCoordinates(\n        [rowIndex, colIndex],\n        key,\n        totalRows,\n        totalCols\n      )\n      setFocusedCell([nextRow, nextCol])\n      cellRefs.current.get(getCellKey(nextRow, nextCol))?.focus()\n      return\n    }\n\n    if (key === \" \" || key === \"Enter\") {\n      // Toggle if on interactive body cell\n      if (rowIndex > 0) {\n        e.preventDefault()\n        const row = rows[rowIndex - 1]\n        if (!row) return\n\n        if (colIndex === 0 && row.type === \"group\") {\n          toggleGroupExpansion(row.group.id)\n        } else if (colIndex > 0) {\n          const role = roles[colIndex - 1]\n          if (!role || role.disabled || disabled || readOnly) return\n\n          if (row.type === \"group\") {\n            handleToggleGroup(role.id, row.group)\n          } else if (row.type === \"action\") {\n            if (row.action.disabled) return\n            handleToggleAction(role.id, row.action.id)\n          }\n        }\n      }\n    }\n  }\n\n  function handleToggleAction(roleId: string, actionId: string) {\n    if (disabled || readOnly) return\n    const action = actionMap.get(actionId)\n    if (action?.disabled) return\n\n    const status = getPermissionStatus(roleId, actionId, grants, roles)\n    if (status.inherited && !allowOverrideInherited) return\n\n    const next = togglePermissionGrant(grants, roleId, actionId, roles)\n    setGrants(next)\n  }\n\n  function handleToggleGroup(roleId: string, group: PermissionGroup) {\n    if (disabled || readOnly) return\n    const next = toggleGroupGrants(\n      grants,\n      roleId,\n      group,\n      roles,\n      allowOverrideInherited\n    )\n    setGrants(next)\n  }\n\n  return (\n    <div\n      data-slot=\"permission-matrix\"\n      className={cn(\"flex flex-col gap-3\", className)}\n      {...props}\n    >\n      {searchable && (\n        <div className=\"flex items-center justify-between gap-2\">\n          <div className=\"relative w-full max-w-xs\">\n            <SearchIcon className=\"absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground\" />\n            <Input\n              type=\"search\"\n              value={searchQuery}\n              onChange={(e) => setSearchQuery(e.target.value)}\n              placeholder={searchPlaceholder}\n              aria-label={searchPlaceholder}\n              className=\"h-control-sm py-0 pl-8 text-xs\"\n            />\n            {searchQuery && (\n              <button\n                type=\"button\"\n                className=\"absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground\"\n                onClick={() => setSearchQuery(\"\")}\n                aria-label=\"Clear search\"\n              >\n                <XIcon className=\"size-3\" />\n              </button>\n            )}\n          </div>\n          <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n            <span className=\"flex items-center gap-1\">\n              <span className=\"size-2 rounded-full bg-primary\" />\n              Direct\n            </span>\n            <span className=\"flex items-center gap-1\">\n              <span className=\"size-2 rounded-full border border-muted-foreground/60 bg-muted-foreground/40\" />\n              Inherited\n            </span>\n          </div>\n        </div>\n      )}\n\n      <div className=\"relative max-h-[540px] w-full overflow-auto rounded-md border border-border bg-card shadow-xs\">\n        <table\n          role=\"grid\"\n          aria-label={ariaLabel}\n          aria-rowcount={rows.length === 0 ? 2 : totalRows}\n          aria-colcount={totalCols}\n          className=\"w-full border-collapse text-left text-sm\"\n        >\n          {/* Table Header: Roles */}\n          <thead className=\"sticky top-0 z-20 bg-background shadow-xs\">\n            <tr role=\"row\" aria-rowindex={1} className=\"border-b border-border\">\n              <th\n                role=\"columnheader\"\n                aria-colindex={1}\n                tabIndex={focusedCell[0] === 0 && focusedCell[1] === 0 ? 0 : -1}\n                ref={(el) => {\n                  if (el) cellRefs.current.set(getCellKey(0, 0), el)\n                }}\n                onKeyDown={(e) => handleCellKeyDown(e, 0, 0)}\n                onClick={() => setFocusedCell([0, 0])}\n                className=\"sticky left-0 z-30 min-w-[240px] bg-background px-4 py-3 font-semibold text-foreground outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n              >\n                Permissions\n              </th>\n              {roles.map((role, roleIndex) => {\n                const colIdx = roleIndex + 1\n                const isFocused =\n                  focusedCell[0] === 0 && focusedCell[1] === colIdx\n                const ancestors = inheritanceMap.get(role.id) ?? []\n                const ancestorNames = ancestors\n                  .map((id) => roles.find((r) => r.id === id)?.name ?? id)\n                  .join(\", \")\n\n                return (\n                  <th\n                    key={role.id}\n                    role=\"columnheader\"\n                    aria-colindex={colIdx + 1}\n                    tabIndex={isFocused ? 0 : -1}\n                    ref={(el) => {\n                      if (el) cellRefs.current.set(getCellKey(0, colIdx), el)\n                    }}\n                    onKeyDown={(e) => handleCellKeyDown(e, 0, colIdx)}\n                    onClick={() => setFocusedCell([0, colIdx])}\n                    className=\"min-w-[130px] px-3 py-3 text-center font-medium text-foreground outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n                  >\n                    <div className=\"flex flex-col items-center justify-center gap-0.5\">\n                      <span className=\"font-semibold\">{role.name}</span>\n                      {ancestorNames && (\n                        <span\n                          className=\"max-w-[120px] truncate text-[11px] font-normal text-muted-foreground\"\n                          title={`Inherits from ${ancestorNames}`}\n                        >\n                          inherits {ancestorNames}\n                        </span>\n                      )}\n                      {role.description && !ancestorNames && (\n                        <span className=\"max-w-[120px] truncate text-[11px] font-normal text-muted-foreground\">\n                          {role.description}\n                        </span>\n                      )}\n                    </div>\n                  </th>\n                )\n              })}\n            </tr>\n          </thead>\n\n          {/* Table Body: Groups and Action Rows */}\n          <tbody>\n            {rows.length === 0 ? (\n              <tr role=\"row\" aria-rowindex={2}>\n                <td\n                  role=\"gridcell\"\n                  colSpan={totalCols}\n                  className=\"px-4 py-8 text-center text-sm text-muted-foreground\"\n                >\n                  No matching permissions found.\n                </td>\n              </tr>\n            ) : (\n              rows.map((row, rIndex) => {\n                const rowIndex = rIndex + 1\n\n                if (row.type === \"group\") {\n                  const isExpanded = effectiveExpanded.includes(row.group.id)\n                  const isHeaderFocused =\n                    focusedCell[0] === rowIndex && focusedCell[1] === 0\n\n                  return (\n                    <tr\n                      key={row.id}\n                      role=\"row\"\n                      aria-rowindex={rowIndex + 1}\n                      className=\"border-b border-border bg-muted/40 font-medium transition-colors hover:bg-muted/60\"\n                    >\n                      {/* Group Row Header */}\n                      <th\n                        role=\"rowheader\"\n                        aria-colindex={1}\n                        tabIndex={isHeaderFocused ? 0 : -1}\n                        ref={(el) => {\n                          if (el)\n                            cellRefs.current.set(getCellKey(rowIndex, 0), el)\n                        }}\n                        onKeyDown={(e) => handleCellKeyDown(e, rowIndex, 0)}\n                        onClick={() => {\n                          setFocusedCell([rowIndex, 0])\n                          toggleGroupExpansion(row.group.id)\n                        }}\n                        className=\"sticky left-0 z-10 min-w-[240px] bg-muted/90 px-3 py-2 text-foreground outline-hidden backdrop-blur-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n                      >\n                        <div className=\"flex cursor-pointer items-center gap-2 select-none\">\n                          <button\n                            type=\"button\"\n                            tabIndex={-1}\n                            className=\"flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground\"\n                            aria-label={\n                              isExpanded\n                                ? `Collapse ${row.group.name}`\n                                : `Expand ${row.group.name}`\n                            }\n                          >\n                            <ChevronRightIcon\n                              className={cn(\n                                \"size-4 transition-transform duration-150\",\n                                isExpanded && \"rotate-90\"\n                              )}\n                            />\n                          </button>\n                          <span className=\"text-xs font-semibold tracking-wide text-foreground/80 uppercase\">\n                            {row.group.name}\n                          </span>\n                          <Badge\n                            variant=\"secondary\"\n                            className=\"h-4.5 px-1.5 text-[10px] font-normal text-muted-foreground\"\n                          >\n                            {row.group.permissions.length}\n                          </Badge>\n                        </div>\n                      </th>\n\n                      {/* Group Checkbox Per Role */}\n                      {roles.map((role, roleIndex) => {\n                        const colIdx = roleIndex + 1\n                        const isCellFocused =\n                          focusedCell[0] === rowIndex &&\n                          focusedCell[1] === colIdx\n                        const groupStatus = getGroupStatus(\n                          role.id,\n                          row.group,\n                          grants,\n                          roles\n                        )\n                        const isRoleDisabled =\n                          role.disabled || disabled || readOnly\n\n                        return (\n                          <td\n                            key={role.id}\n                            role=\"gridcell\"\n                            aria-colindex={colIdx + 1}\n                            aria-label={`${row.group.name} group permissions for ${role.name}`}\n                            tabIndex={isCellFocused ? 0 : -1}\n                            ref={(el) => {\n                              if (el)\n                                cellRefs.current.set(\n                                  getCellKey(rowIndex, colIdx),\n                                  el\n                                )\n                            }}\n                            onKeyDown={(e) =>\n                              handleCellKeyDown(e, rowIndex, colIdx)\n                            }\n                            onClick={() => {\n                              setFocusedCell([rowIndex, colIdx])\n                              if (!isRoleDisabled) {\n                                handleToggleGroup(role.id, row.group)\n                              }\n                            }}\n                            className=\"cursor-pointer px-3 py-2 text-center outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n                          >\n                            <div className=\"flex items-center justify-center\">\n                              <button\n                                type=\"button\"\n                                role=\"checkbox\"\n                                tabIndex={-1}\n                                aria-checked={\n                                  groupStatus === \"indeterminate\"\n                                    ? \"mixed\"\n                                    : groupStatus === \"checked\"\n                                }\n                                aria-label={`${row.group.name} group permissions for ${role.name}`}\n                                disabled={isRoleDisabled}\n                                className={cn(\n                                  \"flex size-4.5 items-center justify-center rounded-sm border border-input transition-colors focus-visible:outline-hidden\",\n                                  groupStatus === \"checked\" &&\n                                    \"border-primary bg-primary text-primary-foreground\",\n                                  groupStatus === \"indeterminate\" &&\n                                    \"border-primary/50 bg-primary/10 text-primary\",\n                                  isRoleDisabled &&\n                                    \"cursor-not-allowed opacity-50\"\n                                )}\n                              >\n                                {groupStatus === \"checked\" && (\n                                  <CheckIcon className=\"size-3.5 stroke-3\" />\n                                )}\n                                {groupStatus === \"indeterminate\" && (\n                                  <MinusIcon className=\"size-3.5 stroke-3\" />\n                                )}\n                              </button>\n                            </div>\n                          </td>\n                        )\n                      })}\n                    </tr>\n                  )\n                }\n\n                // Individual Action Permission Row\n                const isHeaderFocused =\n                  focusedCell[0] === rowIndex && focusedCell[1] === 0\n\n                return (\n                  <tr\n                    key={row.id}\n                    role=\"row\"\n                    aria-rowindex={rowIndex + 1}\n                    className=\"border-b border-border/60 transition-colors hover:bg-muted/20\"\n                  >\n                    {/* Action Row Header */}\n                    <th\n                      role=\"rowheader\"\n                      aria-colindex={1}\n                      tabIndex={isHeaderFocused ? 0 : -1}\n                      ref={(el) => {\n                        if (el)\n                          cellRefs.current.set(getCellKey(rowIndex, 0), el)\n                      }}\n                      onKeyDown={(e) => handleCellKeyDown(e, rowIndex, 0)}\n                      onClick={() => setFocusedCell([rowIndex, 0])}\n                      className=\"sticky left-0 z-10 min-w-[240px] bg-background px-4 py-2 text-left font-normal text-foreground outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n                    >\n                      <div className=\"flex flex-col pl-6\">\n                        <span className=\"text-sm font-medium text-foreground\">\n                          {row.action.name}\n                        </span>\n                        {row.action.description && (\n                          <span className=\"line-clamp-1 text-xs text-muted-foreground\">\n                            {row.action.description}\n                          </span>\n                        )}\n                      </div>\n                    </th>\n\n                    {/* Action Checkbox Per Role */}\n                    {roles.map((role, roleIndex) => {\n                      const colIdx = roleIndex + 1\n                      const isCellFocused =\n                        focusedCell[0] === rowIndex && focusedCell[1] === colIdx\n                      const status = getPermissionStatus(\n                        role.id,\n                        row.action.id,\n                        grants,\n                        roles\n                      )\n                      const isRoleDisabled =\n                        role.disabled ||\n                        row.action.disabled ||\n                        disabled ||\n                        readOnly ||\n                        (status.inherited && !allowOverrideInherited)\n\n                      return (\n                        <td\n                          key={role.id}\n                          role=\"gridcell\"\n                          aria-colindex={colIdx + 1}\n                          aria-label={`${row.action.name} for ${role.name}`}\n                          tabIndex={isCellFocused ? 0 : -1}\n                          ref={(el) => {\n                            if (el)\n                              cellRefs.current.set(\n                                getCellKey(rowIndex, colIdx),\n                                el\n                              )\n                          }}\n                          onKeyDown={(e) =>\n                            handleCellKeyDown(e, rowIndex, colIdx)\n                          }\n                          onClick={() => {\n                            setFocusedCell([rowIndex, colIdx])\n                            if (!isRoleDisabled) {\n                              handleToggleAction(role.id, row.action.id)\n                            }\n                          }}\n                          className=\"cursor-pointer px-3 py-2 text-center outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n                        >\n                          <div className=\"flex items-center justify-center\">\n                            <button\n                              type=\"button\"\n                              role=\"checkbox\"\n                              tabIndex={-1}\n                              aria-checked={status.granted}\n                              aria-label={`${row.action.name} for ${role.name}: ${\n                                status.inherited\n                                  ? `Inherited from ${status.inheritedFrom}`\n                                  : status.granted\n                                    ? \"Granted\"\n                                    : \"Not granted\"\n                              }`}\n                              disabled={isRoleDisabled}\n                              title={\n                                status.inherited\n                                  ? `Inherited from ${status.inheritedFrom}`\n                                  : undefined\n                              }\n                              className={cn(\n                                \"relative flex size-4.5 items-center justify-center rounded-sm border transition-colors focus-visible:outline-hidden\",\n                                status.direct &&\n                                  \"border-primary bg-primary text-primary-foreground\",\n                                status.inherited &&\n                                  \"border-dashed border-muted-foreground/60 bg-muted/80 text-muted-foreground\",\n                                !status.granted &&\n                                  \"border-input bg-background hover:bg-accent\",\n                                isRoleDisabled &&\n                                  \"cursor-not-allowed opacity-50\"\n                              )}\n                            >\n                              {status.direct && (\n                                <CheckIcon className=\"size-3.5 stroke-3\" />\n                              )}\n                              {status.inherited && (\n                                <CheckIcon className=\"stroke-2.5 size-3.5\" />\n                              )}\n                            </button>\n                          </div>\n                        </td>\n                      )\n                    })}\n                  </tr>\n                )\n              })\n            )}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/permission-matrix.ts",
      "content": "export interface PermissionRole {\n  id: string\n  name: string\n  description?: string\n  disabled?: boolean\n  inheritsFrom?: string | string[]\n}\n\nexport interface PermissionAction {\n  id: string\n  name: string\n  description?: string\n  disabled?: boolean\n}\n\nexport interface PermissionGroup {\n  id: string\n  name: string\n  description?: string\n  permissions: PermissionAction[]\n}\n\nexport type PermissionGrants = Record<string, string[]>\n\nexport interface PermissionStatus {\n  granted: boolean\n  direct: boolean\n  inherited: boolean\n  inheritedFrom?: string\n  overridden?: boolean\n}\n\n/**\n * Resolves role inheritance chains recursively.\n * Detects cycles to prevent infinite loops.\n */\nexport function resolveRoleInheritance(\n  roles: PermissionRole[]\n): Map<string, string[]> {\n  const roleMap = new Map(roles.map((r) => [r.id, r]))\n  const result = new Map<string, string[]>()\n\n  for (const role of roles) {\n    const ancestors: string[] = []\n    const visited = new Set<string>([role.id])\n    const queue = Array.isArray(role.inheritsFrom)\n      ? [...role.inheritsFrom]\n      : role.inheritsFrom\n        ? [role.inheritsFrom]\n        : []\n\n    while (queue.length > 0) {\n      const currentId = queue.shift()!\n      if (!visited.has(currentId)) {\n        visited.add(currentId)\n        ancestors.push(currentId)\n        const parentRole = roleMap.get(currentId)\n        if (parentRole?.inheritsFrom) {\n          const nextParents = Array.isArray(parentRole.inheritsFrom)\n            ? parentRole.inheritsFrom\n            : [parentRole.inheritsFrom]\n          queue.push(...nextParents)\n        }\n      }\n    }\n    result.set(role.id, ancestors)\n  }\n\n  return result\n}\n\nfunction findGrantSource(\n  roleId: string,\n  permissionId: string,\n  grants: PermissionGrants,\n  roleMap: Map<string, PermissionRole>,\n  visited: Set<string>\n): string | undefined {\n  if (visited.has(roleId)) return undefined\n  visited.add(roleId)\n\n  const direct = grants[roleId] ?? []\n  if (direct.includes(`!${permissionId}`)) return undefined\n  if (direct.includes(permissionId)) return roleId\n\n  const inheritsFrom = roleMap.get(roleId)?.inheritsFrom\n  const parents = Array.isArray(inheritsFrom)\n    ? inheritsFrom\n    : inheritsFrom\n      ? [inheritsFrom]\n      : []\n\n  for (const parentId of parents) {\n    const source = findGrantSource(\n      parentId,\n      permissionId,\n      grants,\n      roleMap,\n      visited\n    )\n    if (source) return source\n  }\n  return undefined\n}\n\n/**\n * Computes whether a permission is granted directly or through an ancestor role.\n */\nexport function getPermissionStatus(\n  roleId: string,\n  permissionId: string,\n  grants: PermissionGrants,\n  roles: PermissionRole[] = []\n): PermissionStatus {\n  if ((grants[roleId] ?? []).includes(`!${permissionId}`)) {\n    return {\n      granted: false,\n      direct: false,\n      inherited: false,\n      overridden: true,\n    }\n  }\n\n  const roleMap = new Map(roles.map((r) => [r.id, r]))\n  const source = findGrantSource(\n    roleId,\n    permissionId,\n    grants,\n    roleMap,\n    new Set()\n  )\n\n  if (!source) {\n    return {\n      granted: false,\n      direct: false,\n      inherited: false,\n    }\n  }\n\n  if (source === roleId) {\n    return {\n      granted: true,\n      direct: true,\n      inherited: false,\n    }\n  }\n\n  return {\n    granted: true,\n    direct: false,\n    inherited: true,\n    inheritedFrom: roleMap.get(source)?.name ?? source,\n  }\n}\n\n/**\n * Calculates the tri-state check status for an entire permission group under a specific role.\n */\nexport function getGroupStatus(\n  roleId: string,\n  group: PermissionGroup,\n  grants: PermissionGrants,\n  roles: PermissionRole[] = []\n): \"checked\" | \"unchecked\" | \"indeterminate\" {\n  if (group.permissions.length === 0) {\n    return \"unchecked\"\n  }\n\n  let grantedCount = 0\n\n  for (const action of group.permissions) {\n    const status = getPermissionStatus(roleId, action.id, grants, roles)\n    if (status.granted) {\n      grantedCount++\n    }\n  }\n\n  if (grantedCount === 0) return \"unchecked\"\n  if (grantedCount === group.permissions.length) return \"checked\"\n  return \"indeterminate\"\n}\n\n/**\n * Toggles an individual permission grant for a role, returning an updated grants record.\n * Supports overriding inherited permissions by adding or removing an explicit deny (!id).\n */\nexport function togglePermissionGrant(\n  grants: PermissionGrants,\n  roleId: string,\n  permissionId: string,\n  roles: PermissionRole[] = []\n): PermissionGrants {\n  const current = grants[roleId] ?? []\n  const denyId = `!${permissionId}`\n  const status = getPermissionStatus(roleId, permissionId, grants, roles)\n\n  let next: string[]\n  if (current.includes(denyId)) {\n    // Explicit deny exists -> remove it to restore inherited grant or ungranted\n    next = current.filter((id) => id !== denyId)\n  } else if (status.inherited) {\n    // Inherited grant -> override with explicit deny\n    next = [...current.filter((id) => id !== permissionId), denyId]\n  } else if (current.includes(permissionId)) {\n    // Direct grant -> remove\n    next = current.filter((id) => id !== permissionId)\n  } else {\n    // Ungranted -> add grant\n    next = [...current.filter((id) => id !== denyId), permissionId]\n  }\n\n  return {\n    ...grants,\n    [roleId]: next,\n  }\n}\n\n/**\n * Toggles all permissions in a group for a role.\n * If currently \"checked\", ungrants permissions in the group (denying inherited).\n * If \"unchecked\" or \"indeterminate\", grants all ungranted permissions in the group.\n */\nexport function toggleGroupGrants(\n  grants: PermissionGrants,\n  roleId: string,\n  group: PermissionGroup,\n  roles: PermissionRole[] = [],\n  allowOverrideInherited = true\n): PermissionGrants {\n  const status = getGroupStatus(roleId, group, grants, roles)\n  const currentDirect = grants[roleId] ?? []\n  const groupActionIds = group.permissions\n    .filter((p) => !p.disabled)\n    .map((p) => p.id)\n\n  if (status === \"checked\") {\n    // Ungrant all permissions in this group\n    const next = currentDirect.filter((id) => !groupActionIds.includes(id))\n    for (const actionId of groupActionIds) {\n      const pStatus = getPermissionStatus(roleId, actionId, grants, roles)\n      if (pStatus.inherited && allowOverrideInherited) {\n        next.push(`!${actionId}`)\n      }\n    }\n    return {\n      ...grants,\n      [roleId]: next,\n    }\n  }\n\n  // Grant all permissions in this group: remove denies and grant ungranted\n  const denyIds = groupActionIds.map((a) => `!${a}`)\n  const next = currentDirect.filter((id) => !denyIds.includes(id))\n  for (const actionId of groupActionIds) {\n    const pStatus = getPermissionStatus(\n      roleId,\n      actionId,\n      { ...grants, [roleId]: next },\n      roles\n    )\n    if (!pStatus.granted && !next.includes(actionId)) {\n      next.push(actionId)\n    }\n  }\n\n  return {\n    ...grants,\n    [roleId]: next,\n  }\n}\n\n/**\n * Toggles all permissions in the matrix for a role.\n */\nexport function toggleAllGrants(\n  grants: PermissionGrants,\n  roleId: string,\n  allPermissions: PermissionAction[],\n  shouldGrant?: boolean\n): PermissionGrants {\n  const current = grants[roleId] ?? []\n  const enabledPermissions = allPermissions.filter((p) => !p.disabled)\n  const allIds = enabledPermissions.map((p) => p.id)\n  const grant = shouldGrant ?? !allIds.every((id) => current.includes(id))\n\n  const denyIds = allIds.map((id) => `!${id}`)\n\n  return {\n    ...grants,\n    [roleId]: grant\n      ? Array.from(\n          new Set([...current.filter((id) => !denyIds.includes(id)), ...allIds])\n        )\n      : current.filter((id) => !allIds.includes(id)),\n  }\n}\n\n/**\n * Filters permission groups and their child permissions by search query.\n */\nexport function filterPermissionGroups(\n  groups: PermissionGroup[],\n  searchQuery: string\n): PermissionGroup[] {\n  const query = searchQuery.trim().toLowerCase()\n  if (!query) return groups\n\n  return groups\n    .map((group) => {\n      const groupMatches =\n        group.name.toLowerCase().includes(query) ||\n        (group.description?.toLowerCase().includes(query) ?? false)\n\n      if (groupMatches) {\n        return group\n      }\n\n      const matchingPermissions = group.permissions.filter(\n        (action) =>\n          action.name.toLowerCase().includes(query) ||\n          action.id.toLowerCase().includes(query) ||\n          (action.description?.toLowerCase().includes(query) ?? false)\n      )\n\n      if (matchingPermissions.length > 0) {\n        return {\n          ...group,\n          permissions: matchingPermissions,\n        }\n      }\n\n      return null\n    })\n    .filter((g): g is PermissionGroup => g !== null)\n}\n\n/**\n * Computes next cell coordinates in a grid for 2D keyboard navigation.\n */\nexport function getGridNextCoordinates(\n  current: [number, number],\n  key: string,\n  rowCount: number,\n  colCount: number\n): [number, number] {\n  const [row, col] = current\n\n  switch (key) {\n    case \"ArrowUp\":\n      return [Math.max(0, row - 1), col]\n    case \"ArrowDown\":\n      return [Math.min(rowCount - 1, row + 1), col]\n    case \"ArrowLeft\":\n      return [row, Math.max(0, col - 1)]\n    case \"ArrowRight\":\n      return [row, Math.min(colCount - 1, col + 1)]\n    case \"Home\":\n      return [row, 0]\n    case \"End\":\n      return [row, colCount - 1]\n    case \"PageUp\":\n      return [0, col]\n    case \"PageDown\":\n      return [rowCount - 1, col]\n    default:\n      return current\n  }\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}