@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Flow, FlowValidation } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ResourceVerb, UnreadableNodes } from "@anchrd/intel-contract/share";
|
|
4
4
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
5
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
6
6
|
import {
|
|
@@ -11,20 +11,20 @@ import {
|
|
|
11
11
|
FileArchive,
|
|
12
12
|
FolderDown,
|
|
13
13
|
FolderUp,
|
|
14
|
+
History,
|
|
14
15
|
Link2,
|
|
15
16
|
Pencil,
|
|
16
17
|
Share2,
|
|
17
18
|
ShieldCheck,
|
|
18
|
-
Trash2,
|
|
19
19
|
} from "lucide-react";
|
|
20
20
|
import type * as React from "react";
|
|
21
21
|
import { useState } from "react";
|
|
22
|
+
import { AccessSummary } from "@/access-summary/access-summary.tsx";
|
|
22
23
|
import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
|
|
23
24
|
import {
|
|
24
25
|
DropdownMenu,
|
|
25
26
|
DropdownMenuContent,
|
|
26
27
|
DropdownMenuItem,
|
|
27
|
-
DropdownMenuSeparator,
|
|
28
28
|
DropdownMenuSub,
|
|
29
29
|
DropdownMenuSubContent,
|
|
30
30
|
DropdownMenuSubTrigger,
|
|
@@ -37,8 +37,8 @@ import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
|
|
|
37
37
|
import { Modal } from "@/modal/modal.tsx";
|
|
38
38
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
39
39
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
40
|
+
import { RelativeTime } from "@/time/relative-time.tsx";
|
|
40
41
|
import { useDateTime } from "@/time/time-context.tsx";
|
|
41
|
-
import { useUserName } from "@/user-name/user-name.ts";
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* What a resource's own actions are, at the place the resource stands (#24).
|
|
@@ -143,6 +143,7 @@ export function ResourceMenu({
|
|
|
143
143
|
const [renaming, setRenaming] = useState(false);
|
|
144
144
|
const [sharing, setSharing] = useState(false);
|
|
145
145
|
const [linksOpen, setLinksOpen] = useState(false);
|
|
146
|
+
const [versionsOpen, setVersionsOpen] = useState(false);
|
|
146
147
|
const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
|
|
147
148
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
148
149
|
// ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
|
|
@@ -315,35 +316,26 @@ export function ResourceMenu({
|
|
|
315
316
|
className={
|
|
316
317
|
variant === "row"
|
|
317
318
|
? "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
|
|
318
|
-
: "inline-flex size-8 shrink-0 items-center justify-center rounded-md
|
|
319
|
+
: "inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-muted outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
319
320
|
}
|
|
320
321
|
>
|
|
321
322
|
<Ellipsis aria-hidden="true" className="size-4" />
|
|
322
323
|
</DropdownMenuTrigger>
|
|
323
|
-
<DropdownMenuContent align="start" className="w-
|
|
324
|
+
<DropdownMenuContent align="start" className="min-w-40 whitespace-nowrap">
|
|
324
325
|
<DropdownMenuItem onSelect={() => setRenaming(true)}>
|
|
325
326
|
<Pencil aria-hidden="true" />
|
|
326
327
|
{i18n.t("resource.rename")}
|
|
327
328
|
</DropdownMenuItem>
|
|
328
|
-
{/*
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
readable without the pointer ever touching the row.
|
|
337
|
-
|
|
338
|
-
⚠️ The dialog behind it stays the main path, not the fallback: touch has no
|
|
339
|
-
`dragstart`, and nothing can be dragged by keyboard at all. Dragging is the shortcut
|
|
340
|
-
for the mouse — the hint says exactly that, in the place that is reachable both ways. */}
|
|
341
|
-
<DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
|
|
329
|
+
{/* The dialog stays the complete keyboard and touch path. Dragging is only a mouse
|
|
330
|
+
shortcut; it does not need a second label beside this action. */}
|
|
331
|
+
{/* ⚠️ `undefined`, and that is the honest answer rather than a missing one: this menu hangs
|
|
332
|
+
on a resource's own title line, so there is no rendered tree level to name. Passing the
|
|
333
|
+
record's parent instead would claim to know a level — and for a shared row it would
|
|
334
|
+
name the sharer's folder, which is exactly the wrong one (#446). `useTreeMove` reads
|
|
335
|
+
the absence and cleans up both levels such a row can be standing in. */}
|
|
336
|
+
<DropdownMenuItem onSelect={() => move.start(entryOf(target), undefined, null)}>
|
|
342
337
|
<CornerLeftUp aria-hidden="true" />
|
|
343
|
-
{i18n.t("
|
|
344
|
-
<span aria-hidden="true" className="ml-auto text-xs text-muted-foreground">
|
|
345
|
-
{i18n.t("tree.move.hint")}
|
|
346
|
-
</span>
|
|
338
|
+
{i18n.t("resource.move")}
|
|
347
339
|
</DropdownMenuItem>
|
|
348
340
|
{/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
|
|
349
341
|
one (#136). The entry sits with rename and move because it acts on the whole thing,
|
|
@@ -357,7 +349,7 @@ export function ResourceMenu({
|
|
|
357
349
|
{isTable ? (
|
|
358
350
|
<DropdownMenuItem onSelect={() => downloadCsv.mutate()}>
|
|
359
351
|
<Download aria-hidden="true" />
|
|
360
|
-
{i18n.t("
|
|
352
|
+
{i18n.t("resource.downloadCsv")}
|
|
361
353
|
</DropdownMenuItem>
|
|
362
354
|
) : null}
|
|
363
355
|
{/* The other half of the same round trip, next to it rather than in the tree's plus
|
|
@@ -385,14 +377,13 @@ export function ResourceMenu({
|
|
|
385
377
|
{node === null ? (
|
|
386
378
|
<DropdownMenuItem onSelect={() => validate.mutate()}>
|
|
387
379
|
<ShieldCheck aria-hidden="true" />
|
|
388
|
-
{i18n.t("
|
|
380
|
+
{i18n.t("resource.validate")}
|
|
389
381
|
</DropdownMenuItem>
|
|
390
382
|
) : null}
|
|
391
|
-
{retrievable || linkable || node ? <DropdownMenuSeparator /> : null}
|
|
392
383
|
{linkable ? (
|
|
393
384
|
<DropdownMenuItem onSelect={() => setLinksOpen(true)}>
|
|
394
385
|
<Link2 aria-hidden="true" />
|
|
395
|
-
{i18n.t("
|
|
386
|
+
{i18n.t("resource.links")}
|
|
396
387
|
</DropdownMenuItem>
|
|
397
388
|
) : null}
|
|
398
389
|
{/* A flow has no share of its own: it is reached through the folder it is filed in, and a
|
|
@@ -401,17 +392,19 @@ export function ResourceMenu({
|
|
|
401
392
|
{node ? (
|
|
402
393
|
<DropdownMenuItem onSelect={() => setSharing(true)}>
|
|
403
394
|
<Share2 aria-hidden="true" />
|
|
404
|
-
{i18n.t("
|
|
395
|
+
{i18n.t("resource.share")}
|
|
405
396
|
</DropdownMenuItem>
|
|
406
397
|
) : null}
|
|
398
|
+
{(node === null || node.kind !== "folder") && (
|
|
399
|
+
<DropdownMenuItem onSelect={() => setVersionsOpen(true)}>
|
|
400
|
+
<History aria-hidden="true" />
|
|
401
|
+
{i18n.t("node.versions")}
|
|
402
|
+
</DropdownMenuItem>
|
|
403
|
+
)}
|
|
407
404
|
{/* A flow is archived like a document (#109). It keeps its versions and its runs; what it
|
|
408
405
|
loses is reach — it leaves the tree, cannot be started, and stops resolving as another
|
|
409
406
|
flow's callee. */}
|
|
410
|
-
<
|
|
411
|
-
<DropdownMenuItem
|
|
412
|
-
onSelect={() => archive.mutate()}
|
|
413
|
-
className="text-destructive focus:text-destructive"
|
|
414
|
-
>
|
|
407
|
+
<DropdownMenuItem onSelect={() => archive.mutate()}>
|
|
415
408
|
<Archive aria-hidden="true" />
|
|
416
409
|
{i18n.t("resource.archive")}
|
|
417
410
|
</DropdownMenuItem>
|
|
@@ -498,10 +491,70 @@ export function ResourceMenu({
|
|
|
498
491
|
) : null}
|
|
499
492
|
{sharing && node ? <SharePanel node={node} close={() => setSharing(false)} /> : null}
|
|
500
493
|
{linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
|
|
494
|
+
{versionsOpen ? (
|
|
495
|
+
<VersionHistory target={target} close={() => setVersionsOpen(false)} />
|
|
496
|
+
) : null}
|
|
501
497
|
</>
|
|
502
498
|
);
|
|
503
499
|
}
|
|
504
500
|
|
|
501
|
+
function VersionHistory({ target, close }: { target: ResourceTarget; close(): void }) {
|
|
502
|
+
const { data } = useIntelRouterContext();
|
|
503
|
+
const i18n = useI18n();
|
|
504
|
+
const id = idOf(target);
|
|
505
|
+
const versions = useQuery({
|
|
506
|
+
queryKey: [target.type === "flow" ? "flow-versions" : "node-versions", id],
|
|
507
|
+
queryFn: async () => {
|
|
508
|
+
const result =
|
|
509
|
+
target.type === "flow" ? await data.listFlowVersions(id) : await data.listNodeVersions(id);
|
|
510
|
+
return result.items.map((version) => ({
|
|
511
|
+
id: version.id,
|
|
512
|
+
sequence: version.sequence,
|
|
513
|
+
createdAt: version.createdAt,
|
|
514
|
+
}));
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
return (
|
|
518
|
+
<aside
|
|
519
|
+
aria-label={i18n.t("node.versions")}
|
|
520
|
+
className="absolute bottom-0 right-0 top-0 z-10 w-80 overflow-y-auto border-l bg-card p-5 shadow-xl"
|
|
521
|
+
>
|
|
522
|
+
<div className="flex items-start justify-between gap-4">
|
|
523
|
+
<h3 className="font-semibold">{i18n.t("node.versions")}</h3>
|
|
524
|
+
<button
|
|
525
|
+
type="button"
|
|
526
|
+
onClick={close}
|
|
527
|
+
aria-label={i18n.t("common.close")}
|
|
528
|
+
className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
529
|
+
>
|
|
530
|
+
×
|
|
531
|
+
</button>
|
|
532
|
+
</div>
|
|
533
|
+
{versions.isPending ? (
|
|
534
|
+
<p className="mt-4 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
535
|
+
) : null}
|
|
536
|
+
{versions.isError ? (
|
|
537
|
+
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
538
|
+
{i18n.t(target.type === "flow" ? "flows.operationFailed" : "node.operationFailed")}
|
|
539
|
+
</p>
|
|
540
|
+
) : null}
|
|
541
|
+
<ol className="mt-4 space-y-3">
|
|
542
|
+
{versions.data?.map((version) => (
|
|
543
|
+
<li key={version.id} className="rounded-md border p-3 text-sm">
|
|
544
|
+
<span className="font-medium">
|
|
545
|
+
{i18n.t("node.version", { sequence: version.sequence })}
|
|
546
|
+
</span>
|
|
547
|
+
<RelativeTime
|
|
548
|
+
value={version.createdAt}
|
|
549
|
+
className="mt-1 block text-xs text-muted-foreground"
|
|
550
|
+
/>
|
|
551
|
+
</li>
|
|
552
|
+
))}
|
|
553
|
+
</ol>
|
|
554
|
+
</aside>
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
505
558
|
// One refusal, over the screen rather than in the menu that is already gone by the time it arrives.
|
|
506
559
|
function MenuFailure({ children }: { children: React.ReactNode }) {
|
|
507
560
|
return (
|
|
@@ -638,54 +691,6 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
|
|
|
638
691
|
// flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
|
|
639
692
|
// server's answer, not this component's: `execute` never appears on a document, because a document
|
|
640
693
|
// has nothing to run.
|
|
641
|
-
/**
|
|
642
|
-
* One row of the grant list.
|
|
643
|
-
*
|
|
644
|
-
* ⚠️ It is a component rather than a line inside the `map` because of `useUserName`, and that hook
|
|
645
|
-
* is the point: until #431 a `user` grant printed its raw id — `2C9lEhT82upO82abPjHFAvs24a7Vdd3J`
|
|
646
|
-
* beside the word "Read" — which tells the reader nothing about who they let in and puts an
|
|
647
|
-
* identifier on a screen that has no reason to carry one. `user-name.ts` already had the rule
|
|
648
|
-
* ("never fall back to the id, show nothing instead", #258); this row was the last place breaking it.
|
|
649
|
-
*
|
|
650
|
-
* ⚠️ Intel can resolve exactly ONE id today: the signed-in person's. A foreign one is not "a name we
|
|
651
|
-
* could probably guess" but genuinely unanswerable — Intel has no user directory (#261) — so what
|
|
652
|
-
* stands there is what is true: an account, unnamed. The day #261 lands, `useUserName` starts
|
|
653
|
-
* answering and this row needs no change.
|
|
654
|
-
*/
|
|
655
|
-
function GrantRow({ grant, onRevoke }: { grant: ResourceGrant; onRevoke(grantId: string): void }) {
|
|
656
|
-
const i18n = useI18n();
|
|
657
|
-
const userName = useUserName(grant.principal.type === "user" ? grant.principal.id : null);
|
|
658
|
-
// ⚠️ An `email` grant keeps showing the ADDRESS, even where a name is known, and that is a
|
|
659
|
-
// decision rather than an oversight (#431, review finding). The grant is bound to the address —
|
|
660
|
-
// `db-grants.ts` matches `lower(principal_id)` against whoever signs in — not to an account.
|
|
661
|
-
// Putting a name there would claim a binding that does not exist, and it would be wrong the moment
|
|
662
|
-
// somebody else verifies that address.
|
|
663
|
-
const who =
|
|
664
|
-
grant.principal.type === "email"
|
|
665
|
-
? grant.principal.email
|
|
666
|
-
: grant.principal.type === "user"
|
|
667
|
-
? (userName ?? i18n.t("node.someUser"))
|
|
668
|
-
: i18n.t("node.organization");
|
|
669
|
-
return (
|
|
670
|
-
<li className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm">
|
|
671
|
-
<span className="min-w-0 truncate">
|
|
672
|
-
{who}
|
|
673
|
-
<span className="ml-2 text-xs text-muted-foreground">
|
|
674
|
-
{i18n.t(`node.verb.${grant.verb}`)}
|
|
675
|
-
</span>
|
|
676
|
-
</span>
|
|
677
|
-
<button
|
|
678
|
-
type="button"
|
|
679
|
-
onClick={() => onRevoke(grant.id)}
|
|
680
|
-
aria-label={i18n.t("node.revokeShare")}
|
|
681
|
-
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
682
|
-
>
|
|
683
|
-
<Trash2 aria-hidden="true" className="size-4" />
|
|
684
|
-
</button>
|
|
685
|
-
</li>
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
694
|
/**
|
|
690
695
|
* Which of the three principals the dialog can SET (#431).
|
|
691
696
|
*
|
|
@@ -753,7 +758,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
753
758
|
// before, and the user would read a permission picture that is not the one in force. Of all the
|
|
754
759
|
// things to be silently wrong about, access is the worst.
|
|
755
760
|
onSettled: async () => {
|
|
756
|
-
await
|
|
761
|
+
await Promise.all([
|
|
762
|
+
queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] }),
|
|
763
|
+
queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
|
|
764
|
+
]);
|
|
757
765
|
},
|
|
758
766
|
});
|
|
759
767
|
const revoke = useMutation({
|
|
@@ -764,7 +772,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
764
772
|
idempotencyKey: crypto.randomUUID(),
|
|
765
773
|
}),
|
|
766
774
|
onSettled: async () => {
|
|
767
|
-
await
|
|
775
|
+
await Promise.all([
|
|
776
|
+
queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] }),
|
|
777
|
+
queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
|
|
778
|
+
]);
|
|
768
779
|
},
|
|
769
780
|
});
|
|
770
781
|
|
|
@@ -820,21 +831,17 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
820
831
|
<p className="mt-1 text-xs text-muted-foreground">{i18n.t("node.shareUnreadableHint")}</p>
|
|
821
832
|
</div>
|
|
822
833
|
)}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
<
|
|
826
|
-
|
|
827
|
-
grant={grant}
|
|
834
|
+
{grants.data && grants.data.items.length > 0 ? (
|
|
835
|
+
<div className="mb-5">
|
|
836
|
+
<AccessSummary
|
|
837
|
+
grants={grants.data.items}
|
|
828
838
|
onRevoke={(grantId) => {
|
|
829
839
|
share.reset();
|
|
830
840
|
revoke.mutate(grantId);
|
|
831
841
|
}}
|
|
832
842
|
/>
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
<li className="text-sm text-muted-foreground">{i18n.t("node.noGrants")}</li>
|
|
836
|
-
)}
|
|
837
|
-
</ul>
|
|
843
|
+
</div>
|
|
844
|
+
) : null}
|
|
838
845
|
<form
|
|
839
846
|
onSubmit={(event) => {
|
|
840
847
|
event.preventDefault();
|
|
@@ -860,7 +867,7 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
860
867
|
name="share-principal"
|
|
861
868
|
checked={principalKind === kind}
|
|
862
869
|
onChange={() => setPrincipalKind(kind)}
|
|
863
|
-
aria-describedby={kind === "
|
|
870
|
+
aria-describedby={kind === "organization" ? "share-organization-hint" : undefined}
|
|
864
871
|
className="size-4 border outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
865
872
|
/>
|
|
866
873
|
<span>
|
|
@@ -882,19 +889,9 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
882
889
|
required
|
|
883
890
|
value={email}
|
|
884
891
|
onChange={(event) => setEmail(event.target.value)}
|
|
885
|
-
aria-describedby="share-email-hint"
|
|
886
892
|
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
887
893
|
/>
|
|
888
894
|
</label>
|
|
889
|
-
{/* ⚠️ What this says is what Intel knows, and no more (#431). It cannot check whether an
|
|
890
|
-
address belongs to anybody — Gate owns identity and offers Intel no lookup (#261) —
|
|
891
|
-
and a dialog that stayed silent about that let a typo become a grant that is valid,
|
|
892
|
-
permanent and completely without effect, with nobody on either side to notice. Naming
|
|
893
|
-
the uncertainty is the honest half of what the ticket asks for; the other half needs
|
|
894
|
-
Gate. */}
|
|
895
|
-
<p id="share-email-hint" className="mt-1 text-xs text-muted-foreground">
|
|
896
|
-
{i18n.t("node.emailHint")}
|
|
897
|
-
</p>
|
|
898
895
|
</div>
|
|
899
896
|
) : (
|
|
900
897
|
/* ⚠️ `aria-live` on the CONTAINER, which is already mounted whenever this branch is on
|
|
@@ -8,61 +8,42 @@ import { Modal } from "@/modal/modal.tsx";
|
|
|
8
8
|
* Saving, in the title line beside the name of the thing being saved (#24).
|
|
9
9
|
*
|
|
10
10
|
* ⚠️ Unsaved is a state that must survive colour blindness, so it is never carried by colour alone.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* The button only exists while there is work to save; it fills, grows a dot and spells the word
|
|
12
|
+
* out. Three signals at once, of which two are readable without seeing any colour at all.
|
|
13
13
|
*
|
|
14
|
-
* ⚠️ The accessible name names the state as well
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* ⚠️ At rest there are TWO states, not one, and `stored` is what tells them apart (#432). A
|
|
19
|
-
* document that has never been written has nothing to save either — and calling that "Saved" is the
|
|
20
|
-
* one sentence an editor must never say untruthfully: whoever reads it closes the tab. The button
|
|
21
|
-
* was already unclickable in that state, which is exactly why it went unnoticed for so long; the
|
|
22
|
-
* tooltip and the accessible name kept claiming a version that does not exist. "Nothing to save"
|
|
23
|
-
* says less and stays true in both readings.
|
|
14
|
+
* ⚠️ The accessible name names the state as well. A screen reader gets neither the fill nor the
|
|
15
|
+
* dot, and saving in progress must not sound like another available action. The visible word stays
|
|
16
|
+
* a prefix of the name, so speech input still reaches the button by what it reads.
|
|
24
17
|
*/
|
|
25
18
|
export function SaveButton({
|
|
26
19
|
dirty,
|
|
27
20
|
saving,
|
|
28
|
-
stored,
|
|
29
21
|
onSave,
|
|
30
22
|
}: {
|
|
31
23
|
dirty: boolean;
|
|
32
24
|
saving: boolean;
|
|
33
|
-
/** Whether what is being edited has content on the server at all. */
|
|
34
|
-
stored: boolean;
|
|
35
25
|
onSave(): void;
|
|
36
26
|
}) {
|
|
37
27
|
const i18n = useI18n();
|
|
38
|
-
|
|
28
|
+
if (!dirty && !saving) return null;
|
|
29
|
+
const label = i18n.t(saving ? "common.saving" : "common.saveDirty");
|
|
39
30
|
return (
|
|
40
31
|
<TooltipProvider delayDuration={300}>
|
|
41
32
|
<Tooltip>
|
|
42
|
-
{/*
|
|
43
|
-
disabled control fires no pointer events — a tooltip hung on it would stay shut in the
|
|
44
|
-
one state where the button carries no words at all. */}
|
|
33
|
+
{/* The wrapper remains the trigger while saving, when the button is deliberately disabled. */}
|
|
45
34
|
<TooltipTrigger asChild>
|
|
46
35
|
<span className="inline-flex">
|
|
47
36
|
<button
|
|
48
37
|
type="button"
|
|
49
|
-
disabled={
|
|
38
|
+
disabled={saving}
|
|
50
39
|
onClick={onSave}
|
|
51
40
|
aria-label={label}
|
|
52
41
|
data-dirty={dirty}
|
|
53
|
-
className=
|
|
54
|
-
dirty
|
|
55
|
-
? "inline-flex h-8 items-center gap-2 rounded-md bg-primary px-2.5 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-70"
|
|
56
|
-
: "inline-flex size-8 items-center justify-center rounded-md border bg-background text-muted-foreground outline-none disabled:opacity-60"
|
|
57
|
-
}
|
|
42
|
+
className="inline-flex h-8 items-center gap-2 rounded-md bg-primary px-2.5 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-70"
|
|
58
43
|
>
|
|
59
|
-
|
|
60
|
-
<span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-current" />
|
|
61
|
-
) : null}
|
|
44
|
+
<span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-current" />
|
|
62
45
|
<Save aria-hidden="true" className="size-4 shrink-0" />
|
|
63
|
-
{
|
|
64
|
-
<span>{saving ? i18n.t("common.saving") : i18n.t("common.save")}</span>
|
|
65
|
-
) : null}
|
|
46
|
+
<span>{saving ? i18n.t("common.saving") : i18n.t("common.save")}</span>
|
|
66
47
|
</button>
|
|
67
48
|
</span>
|
|
68
49
|
</TooltipTrigger>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext, useEffect, useState } from "react";
|
|
2
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { useDateTime } from "./time-context.tsx";
|
|
5
|
+
|
|
6
|
+
const ClockContext = createContext<number | null>(null);
|
|
7
|
+
|
|
8
|
+
export function RelativeClockProvider({ children }: { children: ReactNode }) {
|
|
9
|
+
const [now, setNow] = useState(Date.now);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
|
12
|
+
return () => window.clearInterval(timer);
|
|
13
|
+
}, []);
|
|
14
|
+
return <ClockContext value={now}>{children}</ClockContext>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function RelativeTime({ value, className }: { value: string; className?: string }) {
|
|
18
|
+
const clock = useContext(ClockContext);
|
|
19
|
+
const dateTime = useDateTime();
|
|
20
|
+
const now = clock ?? Date.now();
|
|
21
|
+
return (
|
|
22
|
+
<TooltipProvider delayDuration={300}>
|
|
23
|
+
<Tooltip>
|
|
24
|
+
<TooltipTrigger asChild>
|
|
25
|
+
<time
|
|
26
|
+
dateTime={value}
|
|
27
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: The exact timestamp tooltip must be keyboard reachable, including inside clickable rows.
|
|
28
|
+
tabIndex={0}
|
|
29
|
+
className={cn(
|
|
30
|
+
"rounded-sm text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{dateTime.relative(value, now)}
|
|
35
|
+
</time>
|
|
36
|
+
</TooltipTrigger>
|
|
37
|
+
<TooltipContent>{dateTime.at(value)}</TooltipContent>
|
|
38
|
+
</Tooltip>
|
|
39
|
+
</TooltipProvider>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
DeviceZone,
|
|
5
5
|
formatDate,
|
|
6
6
|
formatDateTime,
|
|
7
|
+
formatRelativeTime,
|
|
7
8
|
readZoneChoice,
|
|
8
9
|
rememberZoneChoice,
|
|
9
10
|
type TimeZoneStore,
|
|
@@ -58,7 +59,11 @@ export function useTimeZone(): ZoneSelection | null {
|
|
|
58
59
|
* AND zone itself, and the first one that forgets shows the device's zone again — which is the state
|
|
59
60
|
* before #467, only harder to find.
|
|
60
61
|
*/
|
|
61
|
-
export function useDateTime(): {
|
|
62
|
+
export function useDateTime(): {
|
|
63
|
+
at: (value: string) => string;
|
|
64
|
+
on: (value: string) => string;
|
|
65
|
+
relative: (value: string, now: number) => string;
|
|
66
|
+
} {
|
|
62
67
|
const i18n = useI18n();
|
|
63
68
|
const zone = useContext(ZoneContext);
|
|
64
69
|
const choice = zone?.current ?? DeviceZone;
|
|
@@ -66,6 +71,7 @@ export function useDateTime(): { at: (value: string) => string; on: (value: stri
|
|
|
66
71
|
() => ({
|
|
67
72
|
at: (value: string) => formatDateTime(value, i18n.locale, choice),
|
|
68
73
|
on: (value: string) => formatDate(value, i18n.locale, choice),
|
|
74
|
+
relative: (value: string, now: number) => formatRelativeTime(value, i18n.locale, choice, now),
|
|
69
75
|
}),
|
|
70
76
|
[i18n.locale, choice],
|
|
71
77
|
);
|
package/src/time/time.ts
CHANGED
|
@@ -115,6 +115,31 @@ export function formatDate(value: string, locale: string, choice: ZoneChoice): s
|
|
|
115
115
|
return date.toLocaleDateString(locale, { timeZone: resolveZone(choice) });
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
const Minute = 60_000;
|
|
119
|
+
const Hour = 60 * Minute;
|
|
120
|
+
const Day = 24 * Hour;
|
|
121
|
+
const Month = 30 * Day;
|
|
122
|
+
|
|
123
|
+
/** A compact list value. Thirty days is the explicit month boundary; calendar labels stay in the tooltip. */
|
|
124
|
+
export function formatRelativeTime(
|
|
125
|
+
value: string,
|
|
126
|
+
locale: string,
|
|
127
|
+
choice: ZoneChoice,
|
|
128
|
+
now: number,
|
|
129
|
+
): string {
|
|
130
|
+
const date = new Date(value);
|
|
131
|
+
const target = date.getTime();
|
|
132
|
+
if (Number.isNaN(target)) return value;
|
|
133
|
+
const difference = target - now;
|
|
134
|
+
const distance = Math.abs(difference);
|
|
135
|
+
const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "short" });
|
|
136
|
+
if (distance < Minute) return relative.format(0, "second");
|
|
137
|
+
if (distance < Hour) return relative.format(Math.trunc(difference / Minute), "minute");
|
|
138
|
+
if (distance < Day) return relative.format(Math.trunc(difference / Hour), "hour");
|
|
139
|
+
if (distance < Month) return relative.format(Math.trunc(difference / Day), "day");
|
|
140
|
+
return formatDate(value, locale, choice);
|
|
141
|
+
}
|
|
142
|
+
|
|
118
143
|
// ⚠️ A short curated list rather than `Intl.supportedValuesOf("timeZone")` — that is over 400
|
|
119
144
|
// entries, and a dropdown of 400 rows without a search is not a choice but an imposition. A zone
|
|
120
145
|
// missing here is added once it has been missed three times; until then the device's zone is the
|