@anchrd/intel-ui 0.25.0 → 0.29.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.
@@ -1,10 +1,9 @@
1
1
  import type { Node } from "@anchrd/intel-contract/node";
2
2
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
3
  import { useNavigate, useRouterState } from "@tanstack/react-router";
4
- import { Download, History, Paperclip } from "lucide-react";
5
- import { lazy, Suspense, useState } from "react";
4
+ import { Download, Paperclip } from "lucide-react";
5
+ import { lazy, Suspense } from "react";
6
6
  import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
7
- import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
8
7
  import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
9
8
  import { FolderContents } from "@/folder-contents/folder-contents.tsx";
10
9
  import { GraphPane } from "@/graph-pane/graph-pane.tsx";
@@ -12,8 +11,7 @@ import { useI18n } from "@/i18n/i18n-context.tsx";
12
11
  import { NodeTablePanel } from "@/node-table/node-table.tsx";
13
12
  import { useIntelRouterContext } from "@/router/router-context.ts";
14
13
  import { graphViewFrom, selectedFrom } from "@/router/selection-search.ts";
15
- import { useDateTime } from "@/time/time-context.tsx";
16
- import { TitleRow } from "@/title-row/title-row.tsx";
14
+ import { TitleRow, TitleRowFrame } from "@/title-row/title-row.tsx";
17
15
 
18
16
  const NodeEditor = lazy(async () => ({
19
17
  default: (await import("@/node-editor/node-editor.tsx")).NodeEditor,
@@ -33,7 +31,6 @@ export function Nodes() {
33
31
  }),
34
32
  });
35
33
  const selectedId = selection.id;
36
- const [versionsOpen, setVersionsOpen] = useState(false);
37
34
  const document = useQuery({
38
35
  queryKey: ["node", selectedId],
39
36
  queryFn: () => data.getNode(selectedId ?? ""),
@@ -53,7 +50,7 @@ export function Nodes() {
53
50
  enabled: graphable && selection.graph,
54
51
  });
55
52
  return (
56
- <div className="flex h-full min-h-0 flex-col">
53
+ <TitleRowFrame className="flex h-full min-h-0 flex-col">
57
54
  <section className="relative flex min-h-0 min-w-0 flex-1 flex-col bg-card">
58
55
  {/* ⚠️ Only the root draws its graph on its own. A folder's graph hangs *under* the folder's
59
56
  title line rather than in place of it (#58): the line is the one place its menu lives,
@@ -122,9 +119,6 @@ export function Nodes() {
122
119
  document. What the four loose icons used to do is now one menu (#24) — and since #58
123
120
  this line is the only place that menu appears at all, for every kind including a
124
121
  folder, whose whole screen is this line.
125
- ⚠️ The order of the right-hand group is `TitleRow`'s (#53), not this screen's: the
126
- version button is handed over as a child and lands before the menu whatever else
127
- shows up later.
128
122
  ⚠️ #454: the toggle now stands HERE. It used to stand in the global header, beside
129
123
  the search — where what applies everywhere stands. But a graph view does not apply
130
124
  everywhere, it is a view onto THIS item. It is only drawn where there is a graph: on
@@ -135,22 +129,6 @@ export function Nodes() {
135
129
  target={{ type: "node", node: selected }}
136
130
  >
137
131
  {graphable && <ViewToggle />}
138
- {selected.kind !== "folder" && (
139
- <TooltipProvider delayDuration={300}>
140
- <Tooltip>
141
- <TooltipTrigger
142
- type="button"
143
- onClick={() => setVersionsOpen((value) => !value)}
144
- aria-label={i18n.t("node.versions")}
145
- aria-expanded={versionsOpen}
146
- className="inline-flex size-8 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
147
- >
148
- <History aria-hidden="true" className="size-4" />
149
- </TooltipTrigger>
150
- <TooltipContent>{i18n.t("node.versions")}</TooltipContent>
151
- </Tooltip>
152
- </TooltipProvider>
153
- )}
154
132
  </TitleRow>
155
133
  {graphable && selection.graph ? (
156
134
  <GraphPane
@@ -185,13 +163,10 @@ export function Nodes() {
185
163
  />
186
164
  </Suspense>
187
165
  ) : null}
188
- {versionsOpen && selected.kind !== "folder" && (
189
- <VersionHistory nodeId={selected.id} close={() => setVersionsOpen(false)} />
190
- )}
191
166
  </>
192
167
  )}
193
168
  </section>
194
- </div>
169
+ </TitleRowFrame>
195
170
  );
196
171
  }
197
172
 
@@ -235,51 +210,3 @@ function AttachmentPanel({ node }: { node: Node }) {
235
210
  </div>
236
211
  );
237
212
  }
238
-
239
- function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
240
- const { data } = useIntelRouterContext();
241
- const i18n = useI18n();
242
- const dateTime = useDateTime();
243
- const versions = useQuery({
244
- queryKey: ["node-versions", nodeId],
245
- queryFn: () => data.listNodeVersions(nodeId),
246
- });
247
- return (
248
- <aside
249
- aria-label={i18n.t("node.versions")}
250
- className="absolute bottom-0 right-0 top-0 z-10 w-80 overflow-y-auto border-l bg-card p-5 shadow-xl"
251
- >
252
- <div className="flex items-start justify-between gap-4">
253
- <h3 className="font-semibold">{i18n.t("node.versions")}</h3>
254
- <button
255
- type="button"
256
- onClick={close}
257
- aria-label={i18n.t("common.close")}
258
- className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
259
- >
260
- ×
261
- </button>
262
- </div>
263
- {versions.isPending ? (
264
- <p className="mt-4 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
265
- ) : null}
266
- {versions.isError ? (
267
- <p role="alert" className="mt-4 text-sm text-destructive">
268
- {i18n.t("node.operationFailed")}
269
- </p>
270
- ) : null}
271
- <ol className="mt-4 space-y-3">
272
- {versions.data?.items.map((version) => (
273
- <li key={version.id} className="rounded-md border p-3 text-sm">
274
- <span className="font-medium">
275
- {i18n.t("node.version", { sequence: version.sequence })}
276
- </span>
277
- <time className="mt-1 block text-xs text-muted-foreground" dateTime={version.createdAt}>
278
- {dateTime.at(version.createdAt)}
279
- </time>
280
- </li>
281
- ))}
282
- </ol>
283
- </aside>
284
- );
285
- }
@@ -11,6 +11,7 @@ import {
11
11
  FileArchive,
12
12
  FolderDown,
13
13
  FolderUp,
14
+ History,
14
15
  Link2,
15
16
  Pencil,
16
17
  Share2,
@@ -19,12 +20,11 @@ import {
19
20
  import type * as React from "react";
20
21
  import { useState } from "react";
21
22
  import { AccessSummary } from "@/access-summary/access-summary.tsx";
22
- import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
23
+ import { allTreeLevelsKey, 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,6 +37,7 @@ 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
42
 
42
43
  /**
@@ -79,10 +80,6 @@ function titleOf(target: ResourceTarget): string {
79
80
  return target.type === "node" ? target.node.title : target.flow.title;
80
81
  }
81
82
 
82
- function parentOfTarget(target: ResourceTarget): string | null {
83
- return target.type === "node" ? target.node.parentId : target.flow.parentId;
84
- }
85
-
86
83
  /**
87
84
  * Which sentence explains a verb.
88
85
  *
@@ -142,6 +139,7 @@ export function ResourceMenu({
142
139
  const [renaming, setRenaming] = useState(false);
143
140
  const [sharing, setSharing] = useState(false);
144
141
  const [linksOpen, setLinksOpen] = useState(false);
142
+ const [versionsOpen, setVersionsOpen] = useState(false);
145
143
  const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
146
144
  const pathname = useRouterState({ select: (state) => state.location.pathname });
147
145
  // ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
@@ -173,9 +171,14 @@ export function ResourceMenu({
173
171
  // reads the record, and the flat collections behind the search, the link picker and the relation
174
172
  // view all carry the title as well — a rename that only reached the tree would leave yesterday's
175
173
  // name standing on three other screens.
174
+ //
175
+ // ⚠️ Every level rather than the one this row sits in: archiving the last row of a folder takes
176
+ // that folder's arrow away, and the arrow is drawn from the folder's own row one level higher
177
+ // (`allTreeLevelsKey`, #519). Renaming needs no more than the one level — but two invalidations
178
+ // that differ per entry are how one of them goes missing.
176
179
  async function refresh(): Promise<void> {
177
180
  await Promise.all([
178
- queryClient.invalidateQueries({ queryKey: ["tree", parentOfTarget(target)] }),
181
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
179
182
  queryClient.invalidateQueries({
180
183
  queryKey: target.type === "flow" ? ["flow", id] : ["node", id],
181
184
  }),
@@ -314,35 +317,26 @@ export function ResourceMenu({
314
317
  className={
315
318
  variant === "row"
316
319
  ? "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"
317
- : "inline-flex size-8 shrink-0 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
320
+ : "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"
318
321
  }
319
322
  >
320
323
  <Ellipsis aria-hidden="true" className="size-4" />
321
324
  </DropdownMenuTrigger>
322
- <DropdownMenuContent align="start" className="w-52">
325
+ <DropdownMenuContent align="start" className="min-w-40 whitespace-nowrap">
323
326
  <DropdownMenuItem onSelect={() => setRenaming(true)}>
324
327
  <Pencil aria-hidden="true" />
325
328
  {i18n.t("resource.rename")}
326
329
  </DropdownMenuItem>
327
- {/* ⚠️ #455: the hint is the actual change of the ticket. Dragging in the tree has been
328
- built and shipped since 2026-08-02 Jack asked for it anyway, so the feature was not
329
- the problem, its discoverability was. Nothing about a row says it is movable before you
330
- touch it: `cursor-grab` is only visible once the pointer is already on it, and the root
331
- drop target appears only while something is being carried.
332
-
333
- A menu is where you learn what can be done with a thing the same place an application
334
- names its keyboard shortcuts. No handle and no extra icon (Jack's instruction), and
335
- readable without the pointer ever touching the row.
336
-
337
- ⚠️ The dialog behind it stays the main path, not the fallback: touch has no
338
- `dragstart`, and nothing can be dragged by keyboard at all. Dragging is the shortcut
339
- for the mouse — the hint says exactly that, in the place that is reachable both ways. */}
340
- <DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
330
+ {/* The dialog stays the complete keyboard and touch path. Dragging is only a mouse
331
+ shortcut; it does not need a second label beside this action. */}
332
+ {/* ⚠️ `undefined`, and that is the honest answer rather than a missing one: this menu hangs
333
+ on a resource's own title line, so there is no rendered tree level to name. Passing the
334
+ record's parent instead would claim to know a level — and for a shared row it would
335
+ name the sharer's folder, which is exactly the wrong one (#446). `useTreeMove` reads
336
+ the absence and cleans up both levels such a row can be standing in. */}
337
+ <DropdownMenuItem onSelect={() => move.start(entryOf(target), undefined, null)}>
341
338
  <CornerLeftUp aria-hidden="true" />
342
- {i18n.t("tree.move.action")}
343
- <span aria-hidden="true" className="ml-auto text-xs text-muted-foreground">
344
- {i18n.t("tree.move.hint")}
345
- </span>
339
+ {i18n.t("resource.move")}
346
340
  </DropdownMenuItem>
347
341
  {/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
348
342
  one (#136). The entry sits with rename and move because it acts on the whole thing,
@@ -356,7 +350,7 @@ export function ResourceMenu({
356
350
  {isTable ? (
357
351
  <DropdownMenuItem onSelect={() => downloadCsv.mutate()}>
358
352
  <Download aria-hidden="true" />
359
- {i18n.t("node.table.download")}
353
+ {i18n.t("resource.downloadCsv")}
360
354
  </DropdownMenuItem>
361
355
  ) : null}
362
356
  {/* The other half of the same round trip, next to it rather than in the tree's plus
@@ -384,14 +378,13 @@ export function ResourceMenu({
384
378
  {node === null ? (
385
379
  <DropdownMenuItem onSelect={() => validate.mutate()}>
386
380
  <ShieldCheck aria-hidden="true" />
387
- {i18n.t("flows.validate")}
381
+ {i18n.t("resource.validate")}
388
382
  </DropdownMenuItem>
389
383
  ) : null}
390
- {retrievable || linkable || node ? <DropdownMenuSeparator /> : null}
391
384
  {linkable ? (
392
385
  <DropdownMenuItem onSelect={() => setLinksOpen(true)}>
393
386
  <Link2 aria-hidden="true" />
394
- {i18n.t("node.links")}
387
+ {i18n.t("resource.links")}
395
388
  </DropdownMenuItem>
396
389
  ) : null}
397
390
  {/* A flow has no share of its own: it is reached through the folder it is filed in, and a
@@ -400,17 +393,19 @@ export function ResourceMenu({
400
393
  {node ? (
401
394
  <DropdownMenuItem onSelect={() => setSharing(true)}>
402
395
  <Share2 aria-hidden="true" />
403
- {i18n.t("node.share")}
396
+ {i18n.t("resource.share")}
404
397
  </DropdownMenuItem>
405
398
  ) : null}
399
+ {(node === null || node.kind !== "folder") && (
400
+ <DropdownMenuItem onSelect={() => setVersionsOpen(true)}>
401
+ <History aria-hidden="true" />
402
+ {i18n.t("node.versions")}
403
+ </DropdownMenuItem>
404
+ )}
406
405
  {/* A flow is archived like a document (#109). It keeps its versions and its runs; what it
407
406
  loses is reach — it leaves the tree, cannot be started, and stops resolving as another
408
407
  flow's callee. */}
409
- <DropdownMenuSeparator />
410
- <DropdownMenuItem
411
- onSelect={() => archive.mutate()}
412
- className="text-destructive focus:text-destructive"
413
- >
408
+ <DropdownMenuItem onSelect={() => archive.mutate()}>
414
409
  <Archive aria-hidden="true" />
415
410
  {i18n.t("resource.archive")}
416
411
  </DropdownMenuItem>
@@ -497,10 +492,70 @@ export function ResourceMenu({
497
492
  ) : null}
498
493
  {sharing && node ? <SharePanel node={node} close={() => setSharing(false)} /> : null}
499
494
  {linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
495
+ {versionsOpen ? (
496
+ <VersionHistory target={target} close={() => setVersionsOpen(false)} />
497
+ ) : null}
500
498
  </>
501
499
  );
502
500
  }
503
501
 
502
+ function VersionHistory({ target, close }: { target: ResourceTarget; close(): void }) {
503
+ const { data } = useIntelRouterContext();
504
+ const i18n = useI18n();
505
+ const id = idOf(target);
506
+ const versions = useQuery({
507
+ queryKey: [target.type === "flow" ? "flow-versions" : "node-versions", id],
508
+ queryFn: async () => {
509
+ const result =
510
+ target.type === "flow" ? await data.listFlowVersions(id) : await data.listNodeVersions(id);
511
+ return result.items.map((version) => ({
512
+ id: version.id,
513
+ sequence: version.sequence,
514
+ createdAt: version.createdAt,
515
+ }));
516
+ },
517
+ });
518
+ return (
519
+ <aside
520
+ aria-label={i18n.t("node.versions")}
521
+ className="absolute bottom-0 right-0 top-0 z-10 w-80 overflow-y-auto border-l bg-card p-5 shadow-xl"
522
+ >
523
+ <div className="flex items-start justify-between gap-4">
524
+ <h3 className="font-semibold">{i18n.t("node.versions")}</h3>
525
+ <button
526
+ type="button"
527
+ onClick={close}
528
+ aria-label={i18n.t("common.close")}
529
+ className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
530
+ >
531
+ ×
532
+ </button>
533
+ </div>
534
+ {versions.isPending ? (
535
+ <p className="mt-4 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
536
+ ) : null}
537
+ {versions.isError ? (
538
+ <p role="alert" className="mt-4 text-sm text-destructive">
539
+ {i18n.t(target.type === "flow" ? "flows.operationFailed" : "node.operationFailed")}
540
+ </p>
541
+ ) : null}
542
+ <ol className="mt-4 space-y-3">
543
+ {versions.data?.map((version) => (
544
+ <li key={version.id} className="rounded-md border p-3 text-sm">
545
+ <span className="font-medium">
546
+ {i18n.t("node.version", { sequence: version.sequence })}
547
+ </span>
548
+ <RelativeTime
549
+ value={version.createdAt}
550
+ className="mt-1 block text-xs text-muted-foreground"
551
+ />
552
+ </li>
553
+ ))}
554
+ </ol>
555
+ </aside>
556
+ );
557
+ }
558
+
504
559
  // One refusal, over the screen rather than in the menu that is already gone by the time it arrives.
505
560
  function MenuFailure({ children }: { children: React.ReactNode }) {
506
561
  return (
@@ -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
- * At rest the button is a quiet icon; with something to save it fills, grows a dot and spells the
12
- * word out. Three changes at once, of which two are readable without seeing any colour at all.
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 "Saved" and "Save unsaved changes" are two
15
- * different pieces of information, and a screen reader gets neither the fill nor the dot. The
16
- * visible word stays a prefix of it, so speech input still reaches the button by what it reads.
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
- const label = i18n.t(dirty ? "common.saveDirty" : stored ? "common.saved" : "common.saveNothing");
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
- {/* ⚠️ The wrapper is the trigger, not the button. At rest the button is disabled, and a
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={!dirty || saving}
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
- {dirty ? (
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
- {dirty ? (
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(): { at: (value: string) => string; on: (value: string) => string } {
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