@dowel-ui/react 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +9 -5
  2. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts +52 -0
  3. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts.map +1 -0
  4. package/dist/components/ai-extraction-review/ai-extraction-review.js +249 -0
  5. package/dist/components/ai-extraction-review/ai-extraction-review.js.map +1 -0
  6. package/dist/components/ai-extraction-review/extraction-model.d.ts +94 -0
  7. package/dist/components/ai-extraction-review/extraction-model.d.ts.map +1 -0
  8. package/dist/components/ai-extraction-review/extraction-model.js +91 -0
  9. package/dist/components/ai-extraction-review/extraction-model.js.map +1 -0
  10. package/dist/components/ai-extraction-review/index.d.ts +3 -0
  11. package/dist/components/ai-extraction-review/index.js +3 -0
  12. package/dist/components/ai-extraction-review/meta.js +17 -0
  13. package/dist/components/ai-extraction-review/meta.js.map +1 -0
  14. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts +72 -0
  15. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts.map +1 -0
  16. package/dist/components/ai-suggested-value/ai-suggested-value.js +126 -0
  17. package/dist/components/ai-suggested-value/ai-suggested-value.js.map +1 -0
  18. package/dist/components/ai-suggested-value/index.d.ts +2 -0
  19. package/dist/components/ai-suggested-value/index.js +2 -0
  20. package/dist/components/ai-suggested-value/meta.js +17 -0
  21. package/dist/components/ai-suggested-value/meta.js.map +1 -0
  22. package/dist/components/alert/alert.d.ts +1 -1
  23. package/dist/components/avatar/avatar.d.ts +1 -1
  24. package/dist/components/badge/badge.d.ts +1 -1
  25. package/dist/components/button/button.d.ts +1 -1
  26. package/dist/components/confirm-typed/confirm-typed.d.ts +47 -0
  27. package/dist/components/confirm-typed/confirm-typed.d.ts.map +1 -0
  28. package/dist/components/confirm-typed/confirm-typed.js +106 -0
  29. package/dist/components/confirm-typed/confirm-typed.js.map +1 -0
  30. package/dist/components/confirm-typed/index.d.ts +2 -0
  31. package/dist/components/confirm-typed/index.js +2 -0
  32. package/dist/components/confirm-typed/meta.js +17 -0
  33. package/dist/components/confirm-typed/meta.js.map +1 -0
  34. package/dist/components/cron-editor/cron-editor.d.ts +36 -0
  35. package/dist/components/cron-editor/cron-editor.d.ts.map +1 -0
  36. package/dist/components/cron-editor/cron-editor.js +493 -0
  37. package/dist/components/cron-editor/cron-editor.js.map +1 -0
  38. package/dist/components/cron-editor/cron-expression.d.ts +82 -0
  39. package/dist/components/cron-editor/cron-expression.d.ts.map +1 -0
  40. package/dist/components/cron-editor/cron-expression.js +352 -0
  41. package/dist/components/cron-editor/cron-expression.js.map +1 -0
  42. package/dist/components/cron-editor/index.d.ts +3 -0
  43. package/dist/components/cron-editor/index.js +3 -0
  44. package/dist/components/cron-editor/meta.js +17 -0
  45. package/dist/components/cron-editor/meta.js.map +1 -0
  46. package/dist/components/dns-record/dns-record.d.ts +61 -0
  47. package/dist/components/dns-record/dns-record.d.ts.map +1 -0
  48. package/dist/components/dns-record/dns-record.js +227 -0
  49. package/dist/components/dns-record/dns-record.js.map +1 -0
  50. package/dist/components/dns-record/index.d.ts +2 -0
  51. package/dist/components/dns-record/index.js +2 -0
  52. package/dist/components/dns-record/meta.js +17 -0
  53. package/dist/components/dns-record/meta.js.map +1 -0
  54. package/dist/components/permission-matrix/index.d.ts +3 -0
  55. package/dist/components/permission-matrix/index.js +3 -0
  56. package/dist/components/permission-matrix/meta.js +17 -0
  57. package/dist/components/permission-matrix/meta.js.map +1 -0
  58. package/dist/components/permission-matrix/permission-matrix.d.ts +46 -0
  59. package/dist/components/permission-matrix/permission-matrix.d.ts.map +1 -0
  60. package/dist/components/permission-matrix/permission-matrix.js +199 -0
  61. package/dist/components/permission-matrix/permission-matrix.js.map +1 -0
  62. package/dist/components/permission-matrix/permission-model.d.ts +53 -0
  63. package/dist/components/permission-matrix/permission-model.d.ts.map +1 -0
  64. package/dist/components/permission-matrix/permission-model.js +64 -0
  65. package/dist/components/permission-matrix/permission-model.js.map +1 -0
  66. package/dist/components/progress/progress.d.ts +1 -1
  67. package/dist/components/secret-field/index.d.ts +2 -0
  68. package/dist/components/secret-field/index.js +2 -0
  69. package/dist/components/secret-field/meta.js +17 -0
  70. package/dist/components/secret-field/meta.js.map +1 -0
  71. package/dist/components/secret-field/secret-field.d.ts +58 -0
  72. package/dist/components/secret-field/secret-field.d.ts.map +1 -0
  73. package/dist/components/secret-field/secret-field.js +168 -0
  74. package/dist/components/secret-field/secret-field.js.map +1 -0
  75. package/dist/components/session-expiry/index.d.ts +2 -0
  76. package/dist/components/session-expiry/index.js +2 -0
  77. package/dist/components/session-expiry/meta.js +17 -0
  78. package/dist/components/session-expiry/meta.js.map +1 -0
  79. package/dist/components/session-expiry/session-expiry.d.ts +64 -0
  80. package/dist/components/session-expiry/session-expiry.d.ts.map +1 -0
  81. package/dist/components/session-expiry/session-expiry.js +185 -0
  82. package/dist/components/session-expiry/session-expiry.js.map +1 -0
  83. package/dist/components/shortcut-recorder/index.d.ts +3 -0
  84. package/dist/components/shortcut-recorder/index.js +3 -0
  85. package/dist/components/shortcut-recorder/meta.js +17 -0
  86. package/dist/components/shortcut-recorder/meta.js.map +1 -0
  87. package/dist/components/shortcut-recorder/shortcut-model.d.ts +67 -0
  88. package/dist/components/shortcut-recorder/shortcut-model.d.ts.map +1 -0
  89. package/dist/components/shortcut-recorder/shortcut-model.js +168 -0
  90. package/dist/components/shortcut-recorder/shortcut-model.js.map +1 -0
  91. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts +47 -0
  92. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts.map +1 -0
  93. package/dist/components/shortcut-recorder/shortcut-recorder.js +212 -0
  94. package/dist/components/shortcut-recorder/shortcut-recorder.js.map +1 -0
  95. package/dist/components/spinner/spinner.d.ts +1 -1
  96. package/dist/components/sync-status/index.d.ts +2 -0
  97. package/dist/components/sync-status/index.js +2 -0
  98. package/dist/components/sync-status/meta.js +17 -0
  99. package/dist/components/sync-status/meta.js.map +1 -0
  100. package/dist/components/sync-status/sync-status.d.ts +65 -0
  101. package/dist/components/sync-status/sync-status.d.ts.map +1 -0
  102. package/dist/components/sync-status/sync-status.js +103 -0
  103. package/dist/components/sync-status/sync-status.js.map +1 -0
  104. package/dist/components/toast/toast.d.ts +1 -1
  105. package/dist/index.d.ts +25 -1
  106. package/dist/index.js +16 -2
  107. package/dist/registry/components.d.ts.map +1 -1
  108. package/dist/registry/components.js +73 -53
  109. package/dist/registry/components.js.map +1 -1
  110. package/package.json +4 -4
  111. package/src/components/ai-extraction-review/ai-extraction-review.tsx +492 -0
  112. package/src/components/ai-extraction-review/extraction-model.ts +159 -0
  113. package/src/components/ai-extraction-review/index.ts +13 -0
  114. package/src/components/ai-extraction-review/meta.ts +24 -0
  115. package/src/components/ai-suggested-value/ai-suggested-value.tsx +295 -0
  116. package/src/components/ai-suggested-value/index.ts +6 -0
  117. package/src/components/ai-suggested-value/meta.ts +24 -0
  118. package/src/components/confirm-typed/confirm-typed.tsx +191 -0
  119. package/src/components/confirm-typed/index.ts +1 -0
  120. package/src/components/confirm-typed/meta.ts +22 -0
  121. package/src/components/cron-editor/cron-editor.tsx +676 -0
  122. package/src/components/cron-editor/cron-expression.ts +552 -0
  123. package/src/components/cron-editor/index.ts +23 -0
  124. package/src/components/cron-editor/meta.ts +23 -0
  125. package/src/components/dns-record/dns-record.tsx +315 -0
  126. package/src/components/dns-record/index.ts +7 -0
  127. package/src/components/dns-record/meta.ts +21 -0
  128. package/src/components/permission-matrix/index.ts +14 -0
  129. package/src/components/permission-matrix/meta.ts +23 -0
  130. package/src/components/permission-matrix/permission-matrix.tsx +368 -0
  131. package/src/components/permission-matrix/permission-model.ts +120 -0
  132. package/src/components/secret-field/index.ts +6 -0
  133. package/src/components/secret-field/meta.ts +21 -0
  134. package/src/components/secret-field/secret-field.tsx +284 -0
  135. package/src/components/session-expiry/index.ts +9 -0
  136. package/src/components/session-expiry/meta.ts +23 -0
  137. package/src/components/session-expiry/session-expiry.tsx +321 -0
  138. package/src/components/shortcut-recorder/index.ts +19 -0
  139. package/src/components/shortcut-recorder/meta.ts +22 -0
  140. package/src/components/shortcut-recorder/shortcut-model.ts +204 -0
  141. package/src/components/shortcut-recorder/shortcut-recorder.tsx +336 -0
  142. package/src/components/sync-status/index.ts +9 -0
  143. package/src/components/sync-status/meta.ts +21 -0
  144. package/src/components/sync-status/sync-status.tsx +217 -0
  145. package/src/index.ts +10 -0
  146. package/src/registry/components.ts +20 -0
@@ -0,0 +1,368 @@
1
+ "use client";
2
+
3
+ import {
4
+ useId,
5
+ useMemo,
6
+ useState,
7
+ type ComponentPropsWithRef,
8
+ type KeyboardEvent,
9
+ } from "react";
10
+
11
+ import { Checkbox } from "@/components/checkbox";
12
+ import { cn } from "@/lib/utils";
13
+
14
+ import {
15
+ cellState,
16
+ countGranted,
17
+ groupPermissions,
18
+ groupState,
19
+ type Grants,
20
+ type Permission,
21
+ type Role,
22
+ } from "./permission-model";
23
+
24
+ /**
25
+ * Roles across, permissions down, a checkbox at every crossing.
26
+ *
27
+ * Every admin panel has one and every admin panel builds it, because the
28
+ * hard part is not the checkboxes. It is that a role can inherit from
29
+ * another, so a box can be ticked without anyone having ticked it; that an
30
+ * Owner has everything and none of it can be unticked; that a section of
31
+ * eight permissions wants one control for all of them; and that sixty
32
+ * checkboxes are sixty tab stops unless something is done about it.
33
+ *
34
+ * Something is done about it. This is a grid in the WAI-ARIA sense — one tab
35
+ * stop, arrow keys between cells, Home and End along a row — which is the
36
+ * right call here and was the wrong one for the diff viewer: a diff is read,
37
+ * a matrix is operated. Every checkbox is named by both coordinates, "Delete
38
+ * projects for Editor", so a reader arriving by arrow key knows where they
39
+ * are without re-reading the headers.
40
+ *
41
+ * A grant that arrived by inheritance is shown as a checked box that cannot
42
+ * be unchecked here, with the role it came from said beside it and in the
43
+ * box's description. A disabled control would be the obvious rendering, and
44
+ * it would take the box out of the tab order and the arrow-key path, so a
45
+ * keyboard user would step over the one cell whose state needs explaining.
46
+ *
47
+ * Changes are reported, never applied. A group toggle reports every
48
+ * permission it touched in one call, so an application can save it as one
49
+ * change rather than eight.
50
+ */
51
+
52
+ export interface PermissionMatrixProps extends Omit<
53
+ ComponentPropsWithRef<"table">,
54
+ "onChange"
55
+ > {
56
+ /** Names the matrix: "Permissions for Acme". */
57
+ label: string;
58
+ roles: Role[];
59
+ permissions: Permission[];
60
+ grants: Grants;
61
+ /**
62
+ * A change to make. Several ids arrive together when a group toggle was
63
+ * used. Omit to render the matrix as a record.
64
+ */
65
+ onChange?: (roleId: string, permissionIds: string[], granted: boolean) => void;
66
+ }
67
+
68
+ type MatrixRow =
69
+ | { kind: "group"; label: string; permissions: Permission[] }
70
+ | { kind: "permission"; permission: Permission };
71
+
72
+ const ARROWS: Record<string, [number, number]> = {
73
+ ArrowUp: [-1, 0],
74
+ ArrowDown: [1, 0],
75
+ ArrowLeft: [0, -1],
76
+ ArrowRight: [0, 1],
77
+ };
78
+
79
+ export function PermissionMatrix({
80
+ className,
81
+ label,
82
+ roles,
83
+ permissions,
84
+ grants,
85
+ onChange,
86
+ ...props
87
+ }: PermissionMatrixProps) {
88
+ const uid = useId();
89
+
90
+ const rows = useMemo<MatrixRow[]>(
91
+ () =>
92
+ groupPermissions(permissions).flatMap((group): MatrixRow[] => [
93
+ ...(group.group !== undefined
94
+ ? [{ kind: "group" as const, label: group.group, permissions: group.permissions }]
95
+ : []),
96
+ ...group.permissions.map((permission) => ({ kind: "permission" as const, permission })),
97
+ ]),
98
+ [permissions],
99
+ );
100
+
101
+ // One tab stop. The cell that last had focus is the one Tab returns to.
102
+ const [active, setActive] = useState<[number, number]>([0, 0]);
103
+
104
+ const onKeyDown = (event: KeyboardEvent<HTMLTableElement>) => {
105
+ const target = event.target as HTMLElement;
106
+ const row = Number(target.dataset.row);
107
+ const col = Number(target.dataset.col);
108
+ if (Number.isNaN(row) || Number.isNaN(col)) return;
109
+
110
+ let next: [number, number] | null = null;
111
+ const arrow = ARROWS[event.key];
112
+ if (arrow) {
113
+ next = [row + arrow[0], col + arrow[1]];
114
+ } else if (event.key === "Home") {
115
+ next = event.ctrlKey ? [0, 0] : [row, 0];
116
+ } else if (event.key === "End") {
117
+ next = event.ctrlKey ? [rows.length - 1, roles.length - 1] : [row, roles.length - 1];
118
+ }
119
+ if (!next) return;
120
+
121
+ const [nextRow, nextCol] = next;
122
+ if (nextRow < 0 || nextRow >= rows.length || nextCol < 0 || nextCol >= roles.length) {
123
+ event.preventDefault();
124
+ return;
125
+ }
126
+ event.preventDefault();
127
+ const cell = event.currentTarget.querySelector<HTMLElement>(
128
+ `[data-row="${String(nextRow)}"][data-col="${String(nextCol)}"]`,
129
+ );
130
+ cell?.focus();
131
+ };
132
+
133
+ return (
134
+ <div className="relative w-full overflow-x-auto">
135
+ <table
136
+ data-slot="permission-matrix"
137
+ role="grid"
138
+ aria-label={label}
139
+ aria-readonly={onChange ? undefined : true}
140
+ onKeyDown={onKeyDown}
141
+ className={cn("w-full border-collapse text-sm", className)}
142
+ {...props}
143
+ >
144
+ <thead>
145
+ <tr role="row" className="border-b border-border">
146
+ <th
147
+ role="columnheader"
148
+ scope="col"
149
+ className="px-3 py-2 text-left text-xs font-medium"
150
+ >
151
+ Permission
152
+ </th>
153
+ {roles.map((role) => {
154
+ const granted = countGranted(roles, grants, role, permissions);
155
+ return (
156
+ <th
157
+ key={role.id}
158
+ role="columnheader"
159
+ scope="col"
160
+ className="px-3 py-2 text-center text-xs font-medium"
161
+ >
162
+ {role.label}
163
+ <span className="block font-normal text-muted-foreground">
164
+ {role.locked ? (
165
+ "all, locked"
166
+ ) : (
167
+ <>
168
+ {String(granted)}/{String(permissions.length)}
169
+ <span className="sr-only"> granted</span>
170
+ </>
171
+ )}
172
+ </span>
173
+ </th>
174
+ );
175
+ })}
176
+ </tr>
177
+ </thead>
178
+ <tbody>
179
+ {rows.map((row, rowIndex) =>
180
+ row.kind === "group" ? (
181
+ <tr
182
+ key={`group-${row.label}`}
183
+ role="row"
184
+ className="border-b border-border bg-muted/40"
185
+ >
186
+ <th
187
+ role="rowheader"
188
+ scope="row"
189
+ className="px-3 py-1.5 text-left text-xs font-medium"
190
+ >
191
+ {row.label}
192
+ <span className="sr-only">, all</span>
193
+ </th>
194
+ {roles.map((role, colIndex) => (
195
+ <GroupCell
196
+ key={role.id}
197
+ role={role}
198
+ roles={roles}
199
+ grants={grants}
200
+ group={row.label}
201
+ permissions={row.permissions}
202
+ position={[rowIndex, colIndex]}
203
+ active={active[0] === rowIndex && active[1] === colIndex}
204
+ onActivate={setActive}
205
+ onChange={onChange}
206
+ />
207
+ ))}
208
+ </tr>
209
+ ) : (
210
+ <tr
211
+ key={row.permission.id}
212
+ role="row"
213
+ className="border-b border-border last:border-0 hover:bg-muted/30"
214
+ >
215
+ <th
216
+ role="rowheader"
217
+ scope="row"
218
+ className={cn(
219
+ "px-3 py-2 text-left font-normal",
220
+ row.permission.group !== undefined && "pl-6",
221
+ )}
222
+ >
223
+ {row.permission.label}
224
+ {row.permission.description ? (
225
+ <span className="block text-xs text-muted-foreground">
226
+ {row.permission.description}
227
+ </span>
228
+ ) : null}
229
+ </th>
230
+ {roles.map((role, colIndex) => (
231
+ <PermissionCell
232
+ key={role.id}
233
+ id={`${uid}-${role.id}-${row.permission.id}`}
234
+ role={role}
235
+ roles={roles}
236
+ grants={grants}
237
+ permission={row.permission}
238
+ position={[rowIndex, colIndex]}
239
+ active={active[0] === rowIndex && active[1] === colIndex}
240
+ onActivate={setActive}
241
+ onChange={onChange}
242
+ />
243
+ ))}
244
+ </tr>
245
+ ),
246
+ )}
247
+ </tbody>
248
+ </table>
249
+ </div>
250
+ );
251
+ }
252
+
253
+ interface CellProps {
254
+ role: Role;
255
+ roles: Role[];
256
+ grants: Grants;
257
+ position: [number, number];
258
+ active: boolean;
259
+ onActivate: (position: [number, number]) => void;
260
+ onChange?: (roleId: string, permissionIds: string[], granted: boolean) => void;
261
+ }
262
+
263
+ function PermissionCell({
264
+ id,
265
+ role,
266
+ roles,
267
+ grants,
268
+ permission,
269
+ position,
270
+ active,
271
+ onActivate,
272
+ onChange,
273
+ }: CellProps & { id: string; permission: Permission }) {
274
+ const state = cellState(roles, grants, role, permission.id);
275
+ const fixed = state.fixed || !onChange;
276
+ const noteId = `${id}-note`;
277
+
278
+ const note = role.locked
279
+ ? `${role.label} has every permission; this cannot be changed.`
280
+ : state.inheritedFrom
281
+ ? `Inherited from ${state.inheritedFrom.label}.`
282
+ : null;
283
+
284
+ return (
285
+ <td
286
+ role="gridcell"
287
+ data-slot="permission-cell"
288
+ data-granted={state.granted || undefined}
289
+ data-inherited={state.inheritedFrom ? "" : undefined}
290
+ className="px-3 py-2 text-center align-middle"
291
+ >
292
+ <Checkbox
293
+ id={id}
294
+ checked={state.granted}
295
+ aria-label={`${permission.label} for ${role.label}`}
296
+ aria-describedby={note ? noteId : undefined}
297
+ // Fixed cells stay focusable, so the arrow-key path and the tab order
298
+ // do not step over the one cell whose state needs explaining.
299
+ aria-disabled={fixed || undefined}
300
+ tabIndex={active ? 0 : -1}
301
+ data-row={position[0]}
302
+ data-col={position[1]}
303
+ className={cn(fixed && "opacity-55")}
304
+ onFocus={() => {
305
+ onActivate(position);
306
+ }}
307
+ onCheckedChange={(next) => {
308
+ if (fixed) return;
309
+ onChange?.(role.id, [permission.id], next === true);
310
+ }}
311
+ />
312
+ {state.inheritedFrom ? (
313
+ <span id={noteId} className="mt-0.5 block text-2xs text-muted-foreground">
314
+ via {state.inheritedFrom.label}
315
+ </span>
316
+ ) : note ? (
317
+ <span id={noteId} className="sr-only">
318
+ {note}
319
+ </span>
320
+ ) : null}
321
+ </td>
322
+ );
323
+ }
324
+
325
+ function GroupCell({
326
+ role,
327
+ roles,
328
+ grants,
329
+ group,
330
+ permissions,
331
+ position,
332
+ active,
333
+ onActivate,
334
+ onChange,
335
+ }: CellProps & { group: string; permissions: Permission[] }) {
336
+ const ids = permissions.map((permission) => permission.id);
337
+ const state = groupState(roles, grants, role, ids);
338
+ // Only what can actually change here: inherited grants stay either way.
339
+ const changeable = permissions
340
+ .filter((permission) => !cellState(roles, grants, role, permission.id).fixed)
341
+ .map((permission) => permission.id);
342
+ const fixed = role.locked || !onChange || changeable.length === 0;
343
+
344
+ return (
345
+ <td
346
+ role="gridcell"
347
+ data-slot="permission-group-cell"
348
+ className="px-3 py-1.5 text-center align-middle"
349
+ >
350
+ <Checkbox
351
+ checked={state === "all" ? true : state === "some" ? "indeterminate" : false}
352
+ aria-label={`All ${group} for ${role.label}`}
353
+ aria-disabled={fixed || undefined}
354
+ tabIndex={active ? 0 : -1}
355
+ data-row={position[0]}
356
+ data-col={position[1]}
357
+ className={cn(fixed && "opacity-55")}
358
+ onFocus={() => {
359
+ onActivate(position);
360
+ }}
361
+ onCheckedChange={(next) => {
362
+ if (fixed) return;
363
+ onChange?.(role.id, changeable, next === true);
364
+ }}
365
+ />
366
+ </td>
367
+ );
368
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Roles, permissions and who has what — resolved, so the grid only draws.
3
+ *
4
+ * Pure, so inheritance and the group states are tested without rendering
5
+ * anything, and so a server can answer "may this role do this" from the same
6
+ * grants the matrix edits, with the same rule for inheritance.
7
+ */
8
+
9
+ export interface Permission {
10
+ id: string;
11
+ label: string;
12
+ description?: string;
13
+ /** Permissions with the same group share a heading and an all-or-none toggle. */
14
+ group?: string;
15
+ }
16
+
17
+ export interface Role {
18
+ id: string;
19
+ label: string;
20
+ /** Roles whose grants this one includes. Resolved transitively. */
21
+ inherits?: string[];
22
+ /** Has every permission, and none of them can be changed. An Owner. */
23
+ locked?: boolean;
24
+ }
25
+
26
+ /** Explicit grants, by role id. Inherited ones are not listed here. */
27
+ export type Grants = Record<string, string[]>;
28
+
29
+ export interface CellState {
30
+ /** Whether the role can do it, by any route. */
31
+ granted: boolean;
32
+ /** The role it comes from, when it is not explicit here. */
33
+ inheritedFrom: Role | null;
34
+ /** Cannot be changed here — locked role, or inherited. */
35
+ fixed: boolean;
36
+ }
37
+
38
+ export type GroupState = "all" | "some" | "none";
39
+
40
+ function roleById(roles: Role[], id: string): Role | undefined {
41
+ return roles.find((role) => role.id === id);
42
+ }
43
+
44
+ /**
45
+ * The role a permission is inherited from, or null. Depth-first through
46
+ * `inherits`, cycle-safe, so two roles that inherit each other resolve rather
47
+ * than recurse forever — a configuration a form can produce by accident.
48
+ */
49
+ export function inheritedFrom(
50
+ roles: Role[],
51
+ grants: Grants,
52
+ role: Role,
53
+ permissionId: string,
54
+ seen: Set<string> = new Set(),
55
+ ): Role | null {
56
+ seen.add(role.id);
57
+ for (const parentId of role.inherits ?? []) {
58
+ if (seen.has(parentId)) continue;
59
+ const parent = roleById(roles, parentId);
60
+ if (!parent) continue;
61
+ if (parent.locked || (grants[parent.id] ?? []).includes(permissionId)) return parent;
62
+ const further = inheritedFrom(roles, grants, parent, permissionId, seen);
63
+ if (further) return further;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ export function cellState(
69
+ roles: Role[],
70
+ grants: Grants,
71
+ role: Role,
72
+ permissionId: string,
73
+ ): CellState {
74
+ if (role.locked) return { granted: true, inheritedFrom: null, fixed: true };
75
+ const explicit = (grants[role.id] ?? []).includes(permissionId);
76
+ if (explicit) return { granted: true, inheritedFrom: null, fixed: false };
77
+ const from = inheritedFrom(roles, grants, role, permissionId);
78
+ return { granted: from !== null, inheritedFrom: from, fixed: from !== null };
79
+ }
80
+
81
+ export function groupState(
82
+ roles: Role[],
83
+ grants: Grants,
84
+ role: Role,
85
+ permissionIds: string[],
86
+ ): GroupState {
87
+ const granted = permissionIds.filter(
88
+ (id) => cellState(roles, grants, role, id).granted,
89
+ ).length;
90
+ if (granted === 0) return "none";
91
+ return granted === permissionIds.length ? "all" : "some";
92
+ }
93
+
94
+ export function countGranted(
95
+ roles: Role[],
96
+ grants: Grants,
97
+ role: Role,
98
+ permissions: Permission[],
99
+ ): number {
100
+ return permissions.filter(
101
+ (permission) => cellState(roles, grants, role, permission.id).granted,
102
+ ).length;
103
+ }
104
+
105
+ export interface PermissionGroup {
106
+ /** Undefined for permissions declared without one. */
107
+ group: string | undefined;
108
+ permissions: Permission[];
109
+ }
110
+
111
+ /** Permissions by group, in the order the groups first appear. */
112
+ export function groupPermissions(permissions: Permission[]): PermissionGroup[] {
113
+ const groups: PermissionGroup[] = [];
114
+ for (const permission of permissions) {
115
+ const existing = groups.find((candidate) => candidate.group === permission.group);
116
+ if (existing) existing.permissions.push(permission);
117
+ else groups.push({ group: permission.group, permissions: [permission] });
118
+ }
119
+ return groups;
120
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ SecretField,
3
+ maskSecret,
4
+ type SecretFieldProps,
5
+ type SecretFieldState,
6
+ } from "./secret-field";
@@ -0,0 +1,21 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "secret-field",
5
+ title: "Secret Field",
6
+ description: "An API key in its three states: shown once, hidden but revealable, and gone.",
7
+ category: "form",
8
+ status: "stable",
9
+ dependencies: [],
10
+ registryDependencies: ["button", "input"],
11
+ files: ["secret-field.tsx"],
12
+ a11y:
13
+ "The value sits in a read-only input rather than a disabled one, so it can be focused, " +
14
+ "selected and read, and focusing a shown value selects all of it for copying by hand. The " +
15
+ "field's state — shown once, hidden, revealed, gone — is a sentence attached as its " +
16
+ "description, never a colour or an icon. Copying is announced through a status region that " +
17
+ "exists before the first copy, and failure is announced too, with what to do instead, because " +
18
+ "the clipboard can be refused. Every button carries the field's label in its name, so a " +
19
+ "settings page with several keys has several distinct Copy buttons. Regenerating is confirmed " +
20
+ "inline in a named group that states the consequence, since it revokes the current key.",
21
+ });