@anchrd/intel-ui 0.22.0 → 0.23.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/app/app-tree/app-tree.tsx +47 -8
- package/src/app/app.tsx +7 -38
- package/src/app/settings-dialog/settings-dialog.tsx +38 -5
- package/src/app/user-footer/user-footer.tsx +31 -13
- package/src/app-root/app-root.tsx +109 -0
- package/src/app-root/app-root.types.ts +27 -0
- package/src/archive/archive.tsx +76 -2
- package/src/data/intel-data-provider/intel-data-provider.ts +16 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +8 -0
- package/src/flow-runs/flow-runs.tsx +3 -1
- package/src/flows/flows.tsx +5 -11
- package/src/folder-contents/folder-contents.tsx +3 -1
- package/src/i18n/de.json +14 -3
- package/src/i18n/en.json +14 -3
- package/src/i18n/es.json +14 -3
- package/src/main.tsx +26 -32
- package/src/node-table/node-table.tsx +10 -50
- package/src/nodes/nodes.tsx +21 -18
- package/src/resource-menu/resource-menu.tsx +62 -1
- package/src/time/time-context.tsx +72 -0
- package/src/time/time.ts +140 -0
- package/src/components/ui/breadcrumb.tsx +0 -102
|
@@ -6,6 +6,7 @@ import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
|
6
6
|
import {
|
|
7
7
|
Archive,
|
|
8
8
|
CornerLeftUp,
|
|
9
|
+
Download,
|
|
9
10
|
Ellipsis,
|
|
10
11
|
FileArchive,
|
|
11
12
|
FolderDown,
|
|
@@ -36,6 +37,7 @@ import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
|
|
|
36
37
|
import { Modal } from "@/modal/modal.tsx";
|
|
37
38
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
38
39
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
40
|
+
import { useDateTime } from "@/time/time-context.tsx";
|
|
39
41
|
import { useUserName } from "@/user-name/user-name.ts";
|
|
40
42
|
|
|
41
43
|
/**
|
|
@@ -135,6 +137,7 @@ export function ResourceMenu({
|
|
|
135
137
|
}) {
|
|
136
138
|
const { data } = useIntelRouterContext();
|
|
137
139
|
const i18n = useI18n();
|
|
140
|
+
const dateTime = useDateTime();
|
|
138
141
|
const queryClient = useQueryClient();
|
|
139
142
|
const navigate = useNavigate();
|
|
140
143
|
const [renaming, setRenaming] = useState(false);
|
|
@@ -266,6 +269,37 @@ export function ResourceMenu({
|
|
|
266
269
|
},
|
|
267
270
|
});
|
|
268
271
|
|
|
272
|
+
// #454: a table's CSV export — an ENTRY, not a button of its own. It was the only control WITH
|
|
273
|
+
// TEXT beside two icon buttons in the title line, and it was needed no more often than what sits
|
|
274
|
+
// in here anyway; it was merely wider. Intel has made the same decision twice already (#363 for
|
|
275
|
+
// status and archive, #346 for the import).
|
|
276
|
+
//
|
|
277
|
+
// ⚠️ It lives HERE and no longer in `NodeTablePanel`: there it hung off the table query in order
|
|
278
|
+
// to prevent an empty export. The condition survives the move without a second query — the
|
|
279
|
+
// canonical content IS the CSV, and if it is empty nothing is downloaded, it is refused instead.
|
|
280
|
+
// A file of zero bytes is the worse answer.
|
|
281
|
+
const downloadCsv = useMutation({
|
|
282
|
+
mutationFn: async () => {
|
|
283
|
+
if (target.type !== "node") throw new Error("csv export is a node's");
|
|
284
|
+
const document_ = await data.getNode(target.node.id);
|
|
285
|
+
const content = document_.content ?? "";
|
|
286
|
+
if (content === "") throw new Error("nothing to export yet");
|
|
287
|
+
return new Blob([content], { type: "text/csv;charset=utf-8" });
|
|
288
|
+
},
|
|
289
|
+
onSuccess: (blob) => {
|
|
290
|
+
const url = URL.createObjectURL(blob);
|
|
291
|
+
const anchor = document.createElement("a");
|
|
292
|
+
anchor.href = url;
|
|
293
|
+
// No doubled `.csv` when the title already carries the extension — unchanged from #40.
|
|
294
|
+
anchor.download = title.toLowerCase().endsWith(".csv") ? title : `${title}.csv`;
|
|
295
|
+
document.body.append(anchor);
|
|
296
|
+
anchor.click();
|
|
297
|
+
anchor.remove();
|
|
298
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
const isTable = target.type === "node" && target.node.kind === "table";
|
|
302
|
+
|
|
269
303
|
const failure = rename.isError
|
|
270
304
|
? null // the rename dialog words its own refusal, beside the field that caused it
|
|
271
305
|
: archive.isError
|
|
@@ -291,9 +325,25 @@ export function ResourceMenu({
|
|
|
291
325
|
<Pencil aria-hidden="true" />
|
|
292
326
|
{i18n.t("resource.rename")}
|
|
293
327
|
</DropdownMenuItem>
|
|
328
|
+
{/* ⚠️ #455: the hint is the actual change of the ticket. Dragging in the tree has been
|
|
329
|
+
built and shipped since 2026-08-02 — Jack asked for it anyway, so the feature was not
|
|
330
|
+
the problem, its discoverability was. Nothing about a row says it is movable before you
|
|
331
|
+
touch it: `cursor-grab` is only visible once the pointer is already on it, and the root
|
|
332
|
+
drop target appears only while something is being carried.
|
|
333
|
+
|
|
334
|
+
A menu is where you learn what can be done with a thing — the same place an application
|
|
335
|
+
names its keyboard shortcuts. No handle and no extra icon (Jack's instruction), and
|
|
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. */}
|
|
294
341
|
<DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
|
|
295
342
|
<CornerLeftUp aria-hidden="true" />
|
|
296
343
|
{i18n.t("tree.move.action")}
|
|
344
|
+
<span aria-hidden="true" className="ml-auto text-xs text-muted-foreground">
|
|
345
|
+
{i18n.t("tree.move.hint")}
|
|
346
|
+
</span>
|
|
297
347
|
</DropdownMenuItem>
|
|
298
348
|
{/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
|
|
299
349
|
one (#136). The entry sits with rename and move because it acts on the whole thing,
|
|
@@ -302,6 +352,14 @@ export function ResourceMenu({
|
|
|
302
352
|
<FolderDown aria-hidden="true" />
|
|
303
353
|
{i18n.t("resource.export")}
|
|
304
354
|
</DropdownMenuItem>
|
|
355
|
+
{/* Only a table has a CSV — and it stands beside the bundle export because both do the
|
|
356
|
+
same thing: hand out the whole thing, not a part of its content. */}
|
|
357
|
+
{isTable ? (
|
|
358
|
+
<DropdownMenuItem onSelect={() => downloadCsv.mutate()}>
|
|
359
|
+
<Download aria-hidden="true" />
|
|
360
|
+
{i18n.t("node.table.download")}
|
|
361
|
+
</DropdownMenuItem>
|
|
362
|
+
) : null}
|
|
305
363
|
{/* The other half of the same round trip, next to it rather than in the tree's plus
|
|
306
364
|
(#346): one word in the menu, both sources under it. */}
|
|
307
365
|
{importable ? (
|
|
@@ -367,6 +425,9 @@ export function ResourceMenu({
|
|
|
367
425
|
{/* Its own sentence, not `resourceErrorKey`'s: nothing was changed, something failed to
|
|
368
426
|
arrive, and "the change was not saved" would send the reader looking for a change. */}
|
|
369
427
|
{exportBundle.isError ? <MenuFailure>{i18n.t("resource.exportFailed")}</MenuFailure> : null}
|
|
428
|
+
{downloadCsv.isError ? (
|
|
429
|
+
<MenuFailure>{i18n.t("node.table.downloadFailed")}</MenuFailure>
|
|
430
|
+
) : null}
|
|
370
431
|
{/* The import's own sentence, and its own pickers — only where the entry exists, because two
|
|
371
432
|
file inputs on every document's menu would be two elements nothing can ever open. */}
|
|
372
433
|
{bundleImport.isError ? <MenuFailure>{i18n.t("tree.importFailed")}</MenuFailure> : null}
|
|
@@ -417,7 +478,7 @@ export function ResourceMenu({
|
|
|
417
478
|
{/* A snapshot says when it was taken, or it will be read as a standing verdict. */}
|
|
418
479
|
<p className="mt-4 text-xs text-muted-foreground">
|
|
419
480
|
{i18n.t("flows.validateWhen", {
|
|
420
|
-
when:
|
|
481
|
+
when: dateTime.at(validation.checkedAt),
|
|
421
482
|
})}
|
|
422
483
|
</p>
|
|
423
484
|
</Modal>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
|
|
2
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
3
|
+
import {
|
|
4
|
+
DeviceZone,
|
|
5
|
+
formatDate,
|
|
6
|
+
formatDateTime,
|
|
7
|
+
readZoneChoice,
|
|
8
|
+
rememberZoneChoice,
|
|
9
|
+
type TimeZoneStore,
|
|
10
|
+
type ZoneChoice,
|
|
11
|
+
} from "./time.ts";
|
|
12
|
+
|
|
13
|
+
// The React seam for the zone. Like language and appearance it is CLIENT state — a preference in
|
|
14
|
+
// `localStorage`, never on Intel's HTTP surface (`packages/ui/CLAUDE.md`).
|
|
15
|
+
//
|
|
16
|
+
// ⚠️ Deliberately NOT inside the i18n context, although both concern presentation: the language
|
|
17
|
+
// decides HOW a time is written, the zone WHICH time it is. Merged, the zone would hang off the
|
|
18
|
+
// catalogue and a language switch would move the clock.
|
|
19
|
+
|
|
20
|
+
export interface ZoneSelection {
|
|
21
|
+
current: ZoneChoice;
|
|
22
|
+
select(choice: ZoneChoice): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ZoneContext = createContext<ZoneSelection | null>(null);
|
|
26
|
+
|
|
27
|
+
export function TimeZoneProvider({
|
|
28
|
+
store,
|
|
29
|
+
children,
|
|
30
|
+
}: {
|
|
31
|
+
store?: TimeZoneStore;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}) {
|
|
34
|
+
const [choice, setChoice] = useState<ZoneChoice>(() => readZoneChoice(store));
|
|
35
|
+
const selection = useMemo<ZoneSelection>(
|
|
36
|
+
() => ({
|
|
37
|
+
current: choice,
|
|
38
|
+
select: (next) => {
|
|
39
|
+
rememberZoneChoice(next, store);
|
|
40
|
+
setChoice(next);
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
[choice, store],
|
|
44
|
+
);
|
|
45
|
+
return <ZoneContext value={selection}>{children}</ZoneContext>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `null` without a provider — not a stand-in value: the settings dialog asks exactly whether there
|
|
49
|
+
// is anything to choose at all (the same rule `useTheme` follows).
|
|
50
|
+
export function useTimeZone(): ZoneSelection | null {
|
|
51
|
+
return useContext(ZoneContext);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The formatter every surface showing a time uses.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ A hook rather than a function with parameters: otherwise every cell would have to fetch locale
|
|
58
|
+
* AND zone itself, and the first one that forgets shows the device's zone again — which is the state
|
|
59
|
+
* before #467, only harder to find.
|
|
60
|
+
*/
|
|
61
|
+
export function useDateTime(): { at: (value: string) => string; on: (value: string) => string } {
|
|
62
|
+
const i18n = useI18n();
|
|
63
|
+
const zone = useContext(ZoneContext);
|
|
64
|
+
const choice = zone?.current ?? DeviceZone;
|
|
65
|
+
return useMemo(
|
|
66
|
+
() => ({
|
|
67
|
+
at: (value: string) => formatDateTime(value, i18n.locale, choice),
|
|
68
|
+
on: (value: string) => formatDate(value, i18n.locale, choice),
|
|
69
|
+
}),
|
|
70
|
+
[i18n.locale, choice],
|
|
71
|
+
);
|
|
72
|
+
}
|
package/src/time/time.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The ONE place a timestamp becomes a readable sentence (#467).
|
|
2
|
+
//
|
|
3
|
+
// Every surface used to call `toLocaleString(locale)` for itself, which silently takes the DEVICE's
|
|
4
|
+
// zone. Three things follow that are not true:
|
|
5
|
+
//
|
|
6
|
+
// 1. Whoever travels, or uses a device in another zone, reads different times for the same
|
|
7
|
+
// events. For a record of what happened that is where it stops being evidence.
|
|
8
|
+
// 2. Nowhere does it say WHICH zone applies. A time without a zone is a number, not a statement —
|
|
9
|
+
// and it looks exactly like a real one.
|
|
10
|
+
// 3. Two people looking at the same entry see different times and cannot refer to one in
|
|
11
|
+
// conversation.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ **The zone is a LENS for reading, never a change to the value.** What is stored and sent stays
|
|
14
|
+
// UTC; this module only formats.
|
|
15
|
+
//
|
|
16
|
+
// ⚠️ The comment in `settings-dialog.tsx` is what this file answers: the timezone left with the
|
|
17
|
+
// Agents (#388) because nothing read it, and "a preference that changes nothing on screen is worse
|
|
18
|
+
// than none". This exists BEFORE the preference does, and that order is the whole of #467.
|
|
19
|
+
|
|
20
|
+
export interface TimeZoneStore {
|
|
21
|
+
getItem(key: string): string | null;
|
|
22
|
+
setItem(key: string, value: string): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const StorageKey = "intel.timezone";
|
|
26
|
+
|
|
27
|
+
// "This device's zone" as an explicit choice rather than an empty field: an empty field would look
|
|
28
|
+
// as if nothing were set, and invite somebody to set what already holds.
|
|
29
|
+
export const DeviceZone = "device";
|
|
30
|
+
export type ZoneChoice = string;
|
|
31
|
+
|
|
32
|
+
// ⚠️ Falling back to UTC rather than throwing: in an environment without Intl a time in UTC is
|
|
33
|
+
// still a time, while a thrown exception is an empty screen.
|
|
34
|
+
export function deviceZone(): string {
|
|
35
|
+
try {
|
|
36
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
37
|
+
} catch {
|
|
38
|
+
return "UTC";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The stored value comes out of `localStorage` and may have been edited by hand, written by an older
|
|
43
|
+
// build, or name a zone this runtime does not know.
|
|
44
|
+
export function isKnownZone(zone: string): boolean {
|
|
45
|
+
if (zone === DeviceZone) return true;
|
|
46
|
+
try {
|
|
47
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readZoneChoice(store?: TimeZoneStore): ZoneChoice {
|
|
55
|
+
try {
|
|
56
|
+
const stored = store?.getItem(StorageKey);
|
|
57
|
+
return stored && isKnownZone(stored) ? stored : DeviceZone;
|
|
58
|
+
} catch {
|
|
59
|
+
return DeviceZone;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function rememberZoneChoice(choice: ZoneChoice, store?: TimeZoneStore): void {
|
|
64
|
+
try {
|
|
65
|
+
store?.setItem(StorageKey, choice);
|
|
66
|
+
} catch {
|
|
67
|
+
// Without storage the choice holds for this session. Less than the reader wanted, more than an
|
|
68
|
+
// error they cannot switch off.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveZone(choice: ZoneChoice): string {
|
|
73
|
+
return choice === DeviceZone || !isKnownZone(choice) ? deviceZone() : choice;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A timestamp as a readable sentence — WITH its zone.
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ The abbreviation is not decoration: a shifted time without one is precisely the silent
|
|
80
|
+
* misstatement this ticket is written against. And it comes from the runtime rather than from a
|
|
81
|
+
* string in the code — the runtime knows daylight saving, an appended label would be wrong for half
|
|
82
|
+
* the year.
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ Individual components rather than `dateStyle`/`timeStyle`: Intl refuses the two style
|
|
85
|
+
* shorthands TOGETHER with `timeZoneName` and throws `Invalid option` — inside a table cell that
|
|
86
|
+
* takes the whole list with it. Found the hard way in `anchrd/gate#286`, where it read as "the row
|
|
87
|
+
* is missing" rather than as a formatting error.
|
|
88
|
+
*/
|
|
89
|
+
export function formatDateTime(value: string, locale: string, choice: ZoneChoice): string {
|
|
90
|
+
const date = new Date(value);
|
|
91
|
+
// An unreadable timestamp travels through rather than rendering as "Invalid Date": the raw value
|
|
92
|
+
// at least says what stood there.
|
|
93
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
94
|
+
return date.toLocaleString(locale, {
|
|
95
|
+
timeZone: resolveZone(choice),
|
|
96
|
+
year: "numeric",
|
|
97
|
+
month: "short",
|
|
98
|
+
day: "2-digit",
|
|
99
|
+
hour: "2-digit",
|
|
100
|
+
minute: "2-digit",
|
|
101
|
+
timeZoneName: "short",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The date alone, for the column that only ever showed one (the folder table's "Changed").
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ It still resolves through the chosen zone, and that is the point rather than a detail: near
|
|
109
|
+
* midnight a date is exactly the value that differs between two zones, and a bare date carries no
|
|
110
|
+
* abbreviation to warn anybody.
|
|
111
|
+
*/
|
|
112
|
+
export function formatDate(value: string, locale: string, choice: ZoneChoice): string {
|
|
113
|
+
const date = new Date(value);
|
|
114
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
115
|
+
return date.toLocaleDateString(locale, { timeZone: resolveZone(choice) });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ⚠️ A short curated list rather than `Intl.supportedValuesOf("timeZone")` — that is over 400
|
|
119
|
+
// entries, and a dropdown of 400 rows without a search is not a choice but an imposition. A zone
|
|
120
|
+
// missing here is added once it has been missed three times; until then the device's zone is the
|
|
121
|
+
// answer, and it is the right one for almost everybody. Ordered by offset, so the list reads like a
|
|
122
|
+
// map.
|
|
123
|
+
export const ZoneOptions: readonly string[] = [
|
|
124
|
+
"UTC",
|
|
125
|
+
"Europe/Lisbon",
|
|
126
|
+
"Europe/Berlin",
|
|
127
|
+
"Europe/Athens",
|
|
128
|
+
"Europe/Moscow",
|
|
129
|
+
"Asia/Dubai",
|
|
130
|
+
"Asia/Kolkata",
|
|
131
|
+
"Asia/Singapore",
|
|
132
|
+
"Asia/Tokyo",
|
|
133
|
+
"Australia/Sydney",
|
|
134
|
+
"Pacific/Auckland",
|
|
135
|
+
"America/Sao_Paulo",
|
|
136
|
+
"America/New_York",
|
|
137
|
+
"America/Chicago",
|
|
138
|
+
"America/Denver",
|
|
139
|
+
"America/Los_Angeles",
|
|
140
|
+
];
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
|
2
|
-
import { Slot } from "radix-ui";
|
|
3
|
-
import type * as React from "react";
|
|
4
|
-
|
|
5
|
-
import { cn } from "@/lib/utils";
|
|
6
|
-
|
|
7
|
-
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
|
8
|
-
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|
12
|
-
return (
|
|
13
|
-
<ol
|
|
14
|
-
data-slot="breadcrumb-list"
|
|
15
|
-
className={cn(
|
|
16
|
-
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
|
|
17
|
-
className,
|
|
18
|
-
)}
|
|
19
|
-
{...props}
|
|
20
|
-
/>
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
|
25
|
-
return (
|
|
26
|
-
<li
|
|
27
|
-
data-slot="breadcrumb-item"
|
|
28
|
-
className={cn("inline-flex items-center gap-1.5", className)}
|
|
29
|
-
{...props}
|
|
30
|
-
/>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function BreadcrumbLink({
|
|
35
|
-
asChild,
|
|
36
|
-
className,
|
|
37
|
-
...props
|
|
38
|
-
}: React.ComponentProps<"a"> & {
|
|
39
|
-
asChild?: boolean;
|
|
40
|
-
}) {
|
|
41
|
-
const Comp = asChild ? Slot.Root : "a";
|
|
42
|
-
|
|
43
|
-
return (
|
|
44
|
-
<Comp
|
|
45
|
-
data-slot="breadcrumb-link"
|
|
46
|
-
className={cn("transition-colors hover:text-foreground", className)}
|
|
47
|
-
{...props}
|
|
48
|
-
/>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
|
53
|
-
return (
|
|
54
|
-
<span
|
|
55
|
-
data-slot="breadcrumb-page"
|
|
56
|
-
role="link"
|
|
57
|
-
aria-disabled="true"
|
|
58
|
-
aria-current="page"
|
|
59
|
-
className={cn("font-normal text-foreground", className)}
|
|
60
|
-
{...props}
|
|
61
|
-
/>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
|
66
|
-
return (
|
|
67
|
-
<li
|
|
68
|
-
data-slot="breadcrumb-separator"
|
|
69
|
-
role="presentation"
|
|
70
|
-
aria-hidden="true"
|
|
71
|
-
className={cn("[&>svg]:size-3.5", className)}
|
|
72
|
-
{...props}
|
|
73
|
-
>
|
|
74
|
-
{children ?? <ChevronRight />}
|
|
75
|
-
</li>
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
|
80
|
-
return (
|
|
81
|
-
<span
|
|
82
|
-
data-slot="breadcrumb-ellipsis"
|
|
83
|
-
role="presentation"
|
|
84
|
-
aria-hidden="true"
|
|
85
|
-
className={cn("flex size-9 items-center justify-center", className)}
|
|
86
|
-
{...props}
|
|
87
|
-
>
|
|
88
|
-
<MoreHorizontal className="size-4" />
|
|
89
|
-
<span className="sr-only">More</span>
|
|
90
|
-
</span>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export {
|
|
95
|
-
Breadcrumb,
|
|
96
|
-
BreadcrumbEllipsis,
|
|
97
|
-
BreadcrumbItem,
|
|
98
|
-
BreadcrumbLink,
|
|
99
|
-
BreadcrumbList,
|
|
100
|
-
BreadcrumbPage,
|
|
101
|
-
BreadcrumbSeparator,
|
|
102
|
-
};
|