@anchrd/intel-ui 0.23.0 → 0.28.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.23.0",
3
+ "version": "0.28.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "typecheck": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@anchrd/intel-contract": "^0.16.0",
36
+ "@anchrd/intel-contract": "^0.17.0",
37
37
  "@blocknote/core": "^0.52.1",
38
38
  "@blocknote/react": "^0.52.1",
39
39
  "@blocknote/shadcn": "^0.52.1",
@@ -0,0 +1,282 @@
1
+ import type { ResourceGrant } from "@anchrd/intel-contract/share";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useId, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { AppLogo } from "@/branding/branding.tsx";
6
+ import {
7
+ Tooltip,
8
+ TooltipContent,
9
+ TooltipProvider,
10
+ TooltipTrigger,
11
+ } from "@/components/ui/tooltip.tsx";
12
+ import { useI18n } from "@/i18n/i18n-context.tsx";
13
+ import { useIntelRouterContext } from "@/router/router-context.ts";
14
+ import { useUserName } from "@/user-name/user-name.ts";
15
+
16
+ type Principal = ResourceGrant["principal"];
17
+
18
+ type GrantGroup = {
19
+ key: string;
20
+ principal: Principal;
21
+ grants: ResourceGrant[];
22
+ owner: boolean;
23
+ };
24
+
25
+ function principalKey(principal: Principal): string {
26
+ if (principal.type === "organization") return "organization";
27
+ if (principal.type === "email") return `email:${principal.email.toLowerCase()}`;
28
+ return `user:${principal.id}`;
29
+ }
30
+
31
+ function groupGrants(grants: ResourceGrant[], ownerIds: string[] = []): GrantGroup[] {
32
+ const grouped = new Map<string, GrantGroup>();
33
+ for (const ownerId of ownerIds) {
34
+ grouped.set(`user:${ownerId}`, {
35
+ key: `user:${ownerId}`,
36
+ principal: { type: "user", id: ownerId },
37
+ grants: [],
38
+ owner: true,
39
+ });
40
+ }
41
+ for (const grant of grants) {
42
+ const key = principalKey(grant.principal);
43
+ const group = grouped.get(key);
44
+ if (group) group.grants.push(grant);
45
+ else grouped.set(key, { key, principal: grant.principal, grants: [grant], owner: false });
46
+ }
47
+ return [...grouped.values()];
48
+ }
49
+
50
+ function initials(value: string): string {
51
+ const parts = value
52
+ .replace(/@.*$/, "")
53
+ .split(/[\s._+-]+/)
54
+ .filter(Boolean);
55
+ if (parts.length === 0) return "?";
56
+ if (parts.length === 1) return (parts[0]?.slice(0, 2) ?? "?").toUpperCase();
57
+ return `${parts[0]?.[0] ?? ""}${parts.at(-1)?.[0] ?? ""}`.toUpperCase();
58
+ }
59
+
60
+ function usePrincipalLabel(principal: Principal): string {
61
+ const i18n = useI18n();
62
+ const userName = useUserName(principal.type === "user" ? principal.id : null);
63
+ if (principal.type === "organization") return i18n.t("node.organization");
64
+ if (principal.type === "email") return principal.email;
65
+ return userName ?? i18n.t("node.someUser");
66
+ }
67
+
68
+ const circleTones = [
69
+ "bg-muted text-muted-foreground",
70
+ "bg-accent text-accent-foreground",
71
+ "bg-secondary text-secondary-foreground",
72
+ ] as const;
73
+
74
+ function AccessCircle({
75
+ group,
76
+ position,
77
+ count,
78
+ expanded,
79
+ controls,
80
+ activate,
81
+ }: {
82
+ group: GrantGroup;
83
+ position: number;
84
+ count: number;
85
+ expanded: boolean;
86
+ controls: string;
87
+ activate(element: HTMLButtonElement): void;
88
+ }) {
89
+ const i18n = useI18n();
90
+ const label = usePrincipalLabel(group.principal);
91
+ const circle = (
92
+ <button
93
+ type="button"
94
+ aria-label={position === 0 ? `${i18n.t("node.accessDetails", { count })}: ${label}` : label}
95
+ aria-expanded={expanded}
96
+ aria-controls={controls}
97
+ data-organization-access={group.principal.type === "organization" ? "" : undefined}
98
+ onClick={(event) => activate(event.currentTarget)}
99
+ className={`relative flex size-7 items-center justify-center rounded-full text-xs font-medium ring-2 ring-background outline-none transition hover:z-10 hover:scale-105 focus-visible:z-10 focus-visible:ring-ring ${circleTones[position % circleTones.length]}`}
100
+ >
101
+ {group.principal.type === "organization" ? (
102
+ <AppLogo className="size-4 rounded-none bg-transparent text-current [&_svg]:size-3" />
103
+ ) : (
104
+ initials(label)
105
+ )}
106
+ </button>
107
+ );
108
+ return (
109
+ <TooltipProvider delayDuration={300}>
110
+ <Tooltip>
111
+ <TooltipTrigger asChild>{circle}</TooltipTrigger>
112
+ <TooltipContent>
113
+ {group.principal.type === "organization" ? i18n.t("node.organizationAccess") : label}
114
+ </TooltipContent>
115
+ </Tooltip>
116
+ </TooltipProvider>
117
+ );
118
+ }
119
+
120
+ function AccessDetail({
121
+ group,
122
+ onRevoke,
123
+ }: {
124
+ group: GrantGroup;
125
+ onRevoke?(grantId: string): void;
126
+ }) {
127
+ const i18n = useI18n();
128
+ const label = usePrincipalLabel(group.principal);
129
+ return (
130
+ <li className="flex items-start justify-between gap-3 text-sm">
131
+ <span className="min-w-0">
132
+ <span className="block truncate font-medium">{label}</span>
133
+ <span className="text-xs text-muted-foreground">
134
+ {[
135
+ ...(group.owner ? [i18n.t("node.owner")] : []),
136
+ ...group.grants.map((grant) => i18n.t(`node.verb.${grant.verb}`)),
137
+ ].join(", ")}
138
+ </span>
139
+ </span>
140
+ {onRevoke ? (
141
+ <span className="flex flex-wrap justify-end gap-1">
142
+ {group.grants.map((grant) => (
143
+ <button
144
+ key={grant.id}
145
+ type="button"
146
+ onClick={() => onRevoke(grant.id)}
147
+ aria-label={i18n.t("node.revokeVerbFor", {
148
+ verb: i18n.t(`node.verb.${grant.verb}`),
149
+ recipient: label,
150
+ })}
151
+ className="rounded px-2 py-1 text-xs text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
152
+ >
153
+ {i18n.t("node.revokeVerb", { verb: i18n.t(`node.verb.${grant.verb}`) })}
154
+ </button>
155
+ ))}
156
+ </span>
157
+ ) : null}
158
+ </li>
159
+ );
160
+ }
161
+
162
+ export function AccessSummary({
163
+ grants,
164
+ ownerId,
165
+ ownerIds,
166
+ onRevoke,
167
+ }: {
168
+ grants: ResourceGrant[];
169
+ ownerId?: string;
170
+ ownerIds?: string[];
171
+ onRevoke?(grantId: string): void;
172
+ }) {
173
+ const i18n = useI18n();
174
+ const groups = groupGrants(grants, ownerIds ?? (ownerId ? [ownerId] : []));
175
+ const [open, setOpen] = useState(false);
176
+ const [position, setPosition] = useState({ top: 8, left: 8 });
177
+ const detailsId = useId();
178
+ const organizationDescriptionId = useId();
179
+ if (groups.length === 0) return null;
180
+ const visible = groups.slice(0, 3);
181
+ const overflow = groups.length - visible.length;
182
+ const toggle = (element: HTMLButtonElement) => {
183
+ setOpen((current) => {
184
+ if (!current) {
185
+ const rect = element.getBoundingClientRect();
186
+ const panelHeight = Math.min(320, window.innerHeight - 16);
187
+ const below = window.innerHeight - rect.bottom;
188
+ setPosition({
189
+ top: below >= panelHeight ? rect.bottom + 4 : Math.max(8, rect.top - panelHeight - 4),
190
+ left: Math.max(8, Math.min(rect.left, window.innerWidth - 328)),
191
+ });
192
+ }
193
+ return !current;
194
+ });
195
+ };
196
+
197
+ return (
198
+ <div className="relative flex w-fit items-center -space-x-2" data-access-summary="">
199
+ {visible.map((group, position) => (
200
+ <AccessCircle
201
+ key={group.key}
202
+ group={group}
203
+ position={position}
204
+ count={groups.length}
205
+ expanded={open}
206
+ controls={detailsId}
207
+ activate={toggle}
208
+ />
209
+ ))}
210
+ {overflow > 0 ? (
211
+ <button
212
+ type="button"
213
+ aria-label={i18n.t("node.accessDetails", { count: groups.length })}
214
+ aria-expanded={open}
215
+ aria-controls={detailsId}
216
+ onClick={(event) => toggle(event.currentTarget)}
217
+ className="relative flex size-7 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground ring-2 ring-background outline-none transition hover:z-10 hover:scale-105 focus-visible:z-10 focus-visible:ring-ring"
218
+ >
219
+ +{overflow}
220
+ </button>
221
+ ) : null}
222
+ {groups.some((group) => group.principal.type === "organization") ? (
223
+ <span id={organizationDescriptionId} className="sr-only">
224
+ {i18n.t("node.organizationAccess")}
225
+ </span>
226
+ ) : null}
227
+ {open
228
+ ? createPortal(
229
+ <div
230
+ id={detailsId}
231
+ data-access-details=""
232
+ className="fixed z-50 max-h-[calc(100dvh-1rem)] w-80 overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md"
233
+ style={position}
234
+ >
235
+ <ul className="space-y-3">
236
+ {groups.map((group) => (
237
+ <AccessDetail key={group.key} group={group} {...(onRevoke ? { onRevoke } : {})} />
238
+ ))}
239
+ </ul>
240
+ </div>,
241
+ document.querySelector('[role="dialog"]') ?? document.body,
242
+ )
243
+ : null}
244
+ </div>
245
+ );
246
+ }
247
+
248
+ /**
249
+ * The compact access view for a resource the reader can manage.
250
+ *
251
+ * `node_grant_list` is itself authorized: a reader who may see the node but may not inspect its
252
+ * grants gets no names here. Treating that refusal as an empty summary is deliberate; rendering an
253
+ * error or retry would disclose that there are grants to ask about (#484).
254
+ */
255
+ export function ResourceAccessSummary({
256
+ resourceId,
257
+ ownerId,
258
+ }: {
259
+ resourceId: string;
260
+ ownerId: string;
261
+ }) {
262
+ const { data } = useIntelRouterContext();
263
+ const grants = useQuery({
264
+ queryKey: ["effective-node-grants", resourceId],
265
+ queryFn: () => data.listEffectiveAccess(resourceId),
266
+ retry: false,
267
+ // Time cannot change this answer locally. Share mutations invalidate the whole effective prefix
268
+ // because changing one folder also changes every descendant summary already on screen.
269
+ staleTime: Number.POSITIVE_INFINITY,
270
+ });
271
+ if (!grants.data) return null;
272
+ return (
273
+ <AccessSummary
274
+ grants={grants.data.items}
275
+ ownerIds={
276
+ grants.data.ownerIds.includes(ownerId)
277
+ ? grants.data.ownerIds
278
+ : [ownerId, ...grants.data.ownerIds]
279
+ }
280
+ />
281
+ );
282
+ }
@@ -85,6 +85,16 @@ function levelKey(level: Level): readonly unknown[] {
85
85
  return level.type === "flow" ? ["flow-calls", level.id] : treeLevelKey(level.id);
86
86
  }
87
87
 
88
+ // What a drag carries: the row, and the LEVEL it was picked up from.
89
+ //
90
+ // ⚠️ The two are not the same, and that is the whole of #446. Since #429 the root level also shows
91
+ // rows whose own record names a folder this reader may never see — a shared document reaches the
92
+ // root only through the share. Asking `parentOf` again during the move answers with that invisible
93
+ // folder, and then the root offers "move to the top" to a row already at the top, while the level
94
+ // it actually left is never cleaned up. The level is known where the row is rendered; carrying it
95
+ // is cheaper than deriving it wrongly.
96
+ type Carried = { entry: TreeEntry; level: string | null };
97
+
88
98
  // What a row puts on the clipboard for anybody outside the tree. Its own media type rather than
89
99
  // `text/plain`, so a drop target that means "move this row" and one that means "make a node for
90
100
  // this" cannot be confused by the same payload.
@@ -99,8 +109,8 @@ export function AppTree() {
99
109
  const [creating, setCreating] = useState<Creating | null>(null);
100
110
  const [uploadTo, setUploadTo] = useState<string | null>(null);
101
111
  const uploadInput = useRef<HTMLInputElement>(null);
102
- const [dragged, setDragged] = useState<TreeEntry | null>(null);
103
- const draggedRef = useRef<TreeEntry | null>(null);
112
+ const [dragged, setDragged] = useState<Carried | null>(null);
113
+ const draggedRef = useRef<Carried | null>(null);
104
114
  // Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
105
115
  // folder row. Three answers, because "the root" and "nothing" are not the same drop.
106
116
  const [over, setOver] = useState<string | null | undefined>(undefined);
@@ -273,14 +283,15 @@ export function AppTree() {
273
283
  // What a drop on this target would do. `undefined` means nothing is being dragged, so the target
274
284
  // is not a target at all.
275
285
  function verdictFor(
276
- carried: TreeEntry | null,
286
+ carried: Carried | null,
277
287
  targetId: string | null,
278
288
  targetAncestors: ReadonlySet<string>,
279
289
  ) {
280
290
  if (!carried) return undefined;
281
291
  return moveVerdict({
282
- draggedId: carried.id,
283
- draggedParentId: parentOf(carried),
292
+ draggedId: carried.entry.id,
293
+ draggedParentId: parentOf(carried.entry),
294
+ draggedLevel: carried.level,
284
295
  targetId,
285
296
  targetAncestors,
286
297
  });
@@ -313,7 +324,7 @@ export function AppTree() {
313
324
  setDragged(null);
314
325
  draggedRef.current = null;
315
326
  if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
316
- move.start(carried, target);
327
+ move.start(carried.entry, carried.level, target);
317
328
  }
318
329
  },
319
330
  },
@@ -436,7 +447,7 @@ export function AppTree() {
436
447
  // default cursor while a drag is in progress, which is the answer "not here" without a word.
437
448
  const drop =
438
449
  isFolder && !derived ? dropHandlers({ id: entry.id, title: entry.title }, ancestors) : null;
439
- const isDragged = dragged?.id === entry.id;
450
+ const isDragged = dragged?.entry.id === entry.id;
440
451
  // While a drag is in progress every row says which of the three it is: the row being carried,
441
452
  // a folder that would take it, or something that would not. Silence on the last two is what
442
453
  // turns a drag into a guess that only the drop answers.
@@ -465,10 +476,41 @@ export function AppTree() {
465
476
  row carries the fill and `flex-1` on the label pushes the actions to the end *inside* it.
466
477
  The button keeps its own hover and active fill switched off rather than doubled, so the
467
478
  row shows one surface instead of two overlapping ones. */}
479
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: Native drag is the pointer shortcut;
480
+ the nested button and move dialog remain the keyboard path. */}
468
481
  <div
482
+ draggable={!derived}
483
+ onDragStart={(event) => {
484
+ // ⚠️ `draggable={!derived}` is not enough on its own: `dragstart` bubbles, so a drag
485
+ // begun on a descendant of a derived row would still land here — and carry a level
486
+ // (`["tree", <flow-id>]`) that nothing ever wrote. That is the very class of key #446
487
+ // is about, so the handler refuses it rather than trusting the attribute.
488
+ if (derived) return;
489
+ // Native drag initiation on form controls is not interoperable: Safari can leave a
490
+ // draggable button looking grabbable without ever starting the drag. The row owns the
491
+ // gesture while its button remains the keyboard-reachable navigation control (#483).
492
+ event.dataTransfer.effectAllowed = "copyMove";
493
+ event.dataTransfer.setData("text/plain", entry.id);
494
+ event.dataTransfer.setData(
495
+ TreeEntryMediaType,
496
+ JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
497
+ );
498
+ // The level travels with the row, read from the level this row was rendered into —
499
+ // never from its own record, which for a shared row names a folder the reader cannot
500
+ // see (#446).
501
+ const carried: Carried = { entry, level: parent.id };
502
+ draggedRef.current = carried;
503
+ setDragged(carried);
504
+ }}
505
+ onDragEnd={() => {
506
+ draggedRef.current = null;
507
+ setDragged(null);
508
+ setOver(undefined);
509
+ }}
510
+ {...(drop?.props ?? {})}
469
511
  className={`group/row flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
470
512
  isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : ""
471
- }`}
513
+ } ${derived ? "" : "cursor-grab active:cursor-grabbing"}`}
472
514
  >
473
515
  {expandable ? (
474
516
  <button
@@ -508,37 +550,14 @@ export function AppTree() {
508
550
  // 2px left of one with an arrow, and nothing said why.
509
551
  <span aria-hidden="true" className={TREE_GUTTER} />
510
552
  )}
511
- {/* The row's own control is what is grabbed and what is dropped on: a real button, so the
512
- gesture rides on something that is already focusable and named rather than on a bare
513
- div that assistive technology would have to be told about twice. */}
553
+ {/* Navigation stays a real button. The enclosing row carries the native drag gesture;
554
+ putting `draggable` on this form control is precisely the Safari failure from #483. */}
514
555
  <SidebarMenuButton
515
556
  isActive={isActive}
516
- draggable={!derived}
517
- onDragStart={(event) => {
518
- // ⚠️ Two formats, one gesture. `text/plain` is what a drop inside the tree reads —
519
- // that is a MOVE, and it only ever needed the id. The flow canvas needs the kind as
520
- // well, to know which node to make, and resolving an id there would mean a second
521
- // read of something the drag already knows. `effectAllowed` says both are allowed;
522
- // each drop target picks the one it means (#75).
523
- event.dataTransfer.effectAllowed = "copyMove";
524
- event.dataTransfer.setData("text/plain", entry.id);
525
- event.dataTransfer.setData(
526
- TreeEntryMediaType,
527
- JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
528
- );
529
- draggedRef.current = entry;
530
- setDragged(entry);
531
- }}
532
- onDragEnd={() => {
533
- draggedRef.current = null;
534
- setDragged(null);
535
- setOver(undefined);
536
- }}
537
- {...(drop?.props ?? {})}
538
557
  data-drop={
539
558
  dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
540
559
  }
541
- className={`min-w-0 flex-1 hover:bg-transparent data-[active=true]:bg-transparent ${derived ? "" : "cursor-grab active:cursor-grabbing"} ${isDragged ? "opacity-50" : ""} ${highlight}`}
560
+ className={`min-w-0 flex-1 hover:bg-transparent data-[active=true]:bg-transparent ${isDragged ? "opacity-50" : ""} ${highlight}`}
542
561
  onClick={() => void navigate({ to: area, search: { select: entry.id } })}
543
562
  >
544
563
  <Icon aria-hidden="true" className="size-4 shrink-0" />
@@ -588,7 +607,17 @@ export function AppTree() {
588
607
  <button
589
608
  type="button"
590
609
  {...rootDrop.props}
591
- onClick={() => move.start(dragged, { id: null, title: i18n.t("tree.move.root") })}
610
+ onClick={() => {
611
+ // The strip is drawn for every verdict — greyed out when it would refuse — so the click
612
+ // has to read it too. Without this, a click on the grey strip performs exactly the write
613
+ // the drop path refuses, and `initial` being set skips the picker where the verdict is
614
+ // otherwise enforced.
615
+ if (rootDrop.verdict !== "ok") return;
616
+ move.start(dragged.entry, dragged.level, {
617
+ id: null,
618
+ title: i18n.t("tree.move.root"),
619
+ });
620
+ }}
592
621
  data-drop={rootDrop.verdict}
593
622
  className={`mb-1 flex w-full items-center gap-1 rounded-md border border-dashed px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${
594
623
  rootDrop.verdict === "ok"