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