@anchrd/intel-ui 0.25.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 +1 -1
- 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 +4 -4
- package/src/data/intel-data-provider/intel-data-provider.ts +4 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +2 -0
- package/src/flow-runs/flow-runs.tsx +5 -5
- package/src/flows/flows.tsx +19 -22
- package/src/folder-contents/folder-contents.tsx +6 -6
- package/src/i18n/de.json +9 -2
- package/src/i18n/en.json +9 -2
- package/src/i18n/es.json +10 -3
- 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 +85 -31
- 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 +124 -4
|
@@ -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
|
|
@@ -1,7 +1,55 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type HTMLAttributes,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
2
13
|
import { ResourceAccessSummary } from "@/access-summary/access-summary.tsx";
|
|
14
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
3
16
|
import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
|
|
4
17
|
|
|
18
|
+
const TitleRowScrollContext = createContext<{
|
|
19
|
+
scrolled: boolean;
|
|
20
|
+
setScrolled(scrolled: boolean): void;
|
|
21
|
+
} | null>(null);
|
|
22
|
+
|
|
23
|
+
export function TitleRowFrame({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
24
|
+
const [scrolled, setScrolled] = useState(false);
|
|
25
|
+
const value = useMemo(() => ({ scrolled, setScrolled }), [scrolled]);
|
|
26
|
+
return (
|
|
27
|
+
<TitleRowScrollContext.Provider value={value}>
|
|
28
|
+
<div className={className} {...props} />
|
|
29
|
+
</TitleRowScrollContext.Provider>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function TitleRowScrollArea({
|
|
34
|
+
className,
|
|
35
|
+
onScroll,
|
|
36
|
+
...props
|
|
37
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
38
|
+
const scroll = useContext(TitleRowScrollContext);
|
|
39
|
+
const setScrolled = scroll?.setScrolled;
|
|
40
|
+
useEffect(() => () => setScrolled?.(false), [setScrolled]);
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
className={className}
|
|
44
|
+
onScroll={(event) => {
|
|
45
|
+
scroll?.setScrolled(event.currentTarget.scrollTop > 0);
|
|
46
|
+
onScroll?.(event);
|
|
47
|
+
}}
|
|
48
|
+
{...props}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
5
53
|
/**
|
|
6
54
|
* The one line an open thing gets: its name on the left, everything that acts on it on the right
|
|
7
55
|
* (#53).
|
|
@@ -26,10 +74,23 @@ export function TitleRow({
|
|
|
26
74
|
title: string;
|
|
27
75
|
description?: string | null;
|
|
28
76
|
target: ResourceTarget;
|
|
29
|
-
children?:
|
|
77
|
+
children?: ReactNode;
|
|
30
78
|
}) {
|
|
79
|
+
const scroll = useContext(TitleRowScrollContext);
|
|
80
|
+
const setScrolled = scroll?.setScrolled;
|
|
81
|
+
const targetId = target.type === "flow" ? target.flow.id : target.node.id;
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
void targetId;
|
|
84
|
+
setScrolled?.(false);
|
|
85
|
+
}, [setScrolled, targetId]);
|
|
31
86
|
return (
|
|
32
|
-
<div
|
|
87
|
+
<div
|
|
88
|
+
data-scrolled={scroll?.scrolled || undefined}
|
|
89
|
+
className={cn(
|
|
90
|
+
"relative z-10 flex shrink-0 items-start justify-between gap-5 px-6 py-4 transition-shadow",
|
|
91
|
+
scroll?.scrolled && "shadow-[inset_0_-1px_0_var(--border)]",
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
33
94
|
<div className="flex min-w-0 items-center gap-3">
|
|
34
95
|
<div className="min-w-0">
|
|
35
96
|
<div className="flex min-w-0 flex-wrap items-baseline gap-x-3">
|
|
@@ -39,7 +100,7 @@ export function TitleRow({
|
|
|
39
100
|
) : null}
|
|
40
101
|
<span data-slot="title-meta" className="text-sm text-muted-foreground" />
|
|
41
102
|
</div>
|
|
42
|
-
{description ? <
|
|
103
|
+
{description ? <DescriptionDisclosure key={targetId} description={description} /> : null}
|
|
43
104
|
</div>
|
|
44
105
|
</div>
|
|
45
106
|
{/* ⚠️ Document order is tab order here — nothing carries a `tabIndex`. The menu is therefore
|
|
@@ -53,3 +114,62 @@ export function TitleRow({
|
|
|
53
114
|
</div>
|
|
54
115
|
);
|
|
55
116
|
}
|
|
117
|
+
|
|
118
|
+
function DescriptionDisclosure({ description }: { description: string }) {
|
|
119
|
+
const i18n = useI18n();
|
|
120
|
+
const descriptionId = useId();
|
|
121
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
122
|
+
const measureRef = useRef<HTMLSpanElement>(null);
|
|
123
|
+
const [clipped, setClipped] = useState(false);
|
|
124
|
+
const [expanded, setExpanded] = useState(false);
|
|
125
|
+
|
|
126
|
+
useLayoutEffect(() => {
|
|
127
|
+
const container = containerRef.current;
|
|
128
|
+
const measure = measureRef.current;
|
|
129
|
+
if (!container || !measure) return;
|
|
130
|
+
|
|
131
|
+
const update = () => setClipped(measure.scrollWidth > container.clientWidth);
|
|
132
|
+
update();
|
|
133
|
+
const observer = new ResizeObserver(update);
|
|
134
|
+
observer.observe(container);
|
|
135
|
+
observer.observe(measure);
|
|
136
|
+
return () => observer.disconnect();
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
ref={containerRef}
|
|
142
|
+
data-description-container="true"
|
|
143
|
+
className={cn(
|
|
144
|
+
"relative mt-1 min-w-0 text-sm text-muted-foreground",
|
|
145
|
+
expanded && "flex flex-wrap items-baseline gap-x-1",
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
<span
|
|
149
|
+
ref={measureRef}
|
|
150
|
+
data-description-measure="true"
|
|
151
|
+
aria-hidden="true"
|
|
152
|
+
className="pointer-events-none invisible absolute whitespace-nowrap"
|
|
153
|
+
>
|
|
154
|
+
{description}
|
|
155
|
+
</span>
|
|
156
|
+
<span id={descriptionId} className={cn(clipped && !expanded && "block truncate")}>
|
|
157
|
+
{description}
|
|
158
|
+
</span>
|
|
159
|
+
{clipped ? (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
aria-controls={descriptionId}
|
|
163
|
+
aria-expanded={expanded}
|
|
164
|
+
onClick={() => setExpanded((current) => !current)}
|
|
165
|
+
className={cn(
|
|
166
|
+
"shrink-0 font-medium text-foreground outline-none hover:underline focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring",
|
|
167
|
+
!expanded && "absolute bottom-0 right-0 bg-background pl-1",
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
{i18n.t(expanded ? "title.descriptionLess" : "title.descriptionMore")}
|
|
171
|
+
</button>
|
|
172
|
+
) : null}
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|