@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 +2 -2
- package/src/access-summary/access-summary.tsx +282 -0
- package/src/app/app-tree/app-tree.tsx +64 -35
- package/src/app/tree-move/tree-move.tsx +92 -21
- package/src/app/view-toggle/view-toggle.tsx +1 -1
- package/src/archive/archive.tsx +85 -26
- package/src/data/intel-data-provider/intel-data-provider.ts +21 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +9 -0
- package/src/flow-runs/flow-runs.tsx +5 -5
- package/src/flows/flows.tsx +19 -22
- package/src/folder-contents/folder-contents.tsx +18 -6
- package/src/i18n/de.json +25 -5
- package/src/i18n/en.json +25 -5
- package/src/i18n/es.json +26 -6
- package/src/main.tsx +6 -3
- package/src/node-editor/node-editor.tsx +4 -11
- package/src/node-table/node-table.tsx +3 -2
- package/src/nodes/nodes.tsx +5 -78
- package/src/resource-menu/resource-menu.tsx +102 -105
- package/src/save-button/save-button.tsx +12 -31
- package/src/time/relative-time.tsx +41 -0
- package/src/time/time-context.tsx +7 -1
- package/src/time/time.ts +25 -0
- package/src/title-row/title-row.tsx +128 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "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.
|
|
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<
|
|
103
|
-
const draggedRef = useRef<
|
|
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:
|
|
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
|
-
{/*
|
|
512
|
-
|
|
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 ${
|
|
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={() =>
|
|
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"
|