@hgdc/ui 0.3.0 → 0.5.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.
Files changed (53) hide show
  1. package/README.md +56 -24
  2. package/dist/account-menu.d.ts +15 -2
  3. package/dist/account-menu.js +8 -2
  4. package/dist/address-fields.d.ts +50 -0
  5. package/dist/address-fields.js +79 -0
  6. package/dist/chart-path.d.ts +9 -0
  7. package/dist/chart-path.js +46 -0
  8. package/dist/color-field.d.ts +32 -0
  9. package/dist/color-field.js +80 -0
  10. package/dist/command-menu.d.ts +4 -2
  11. package/dist/command-menu.js +5 -3
  12. package/dist/copy-button.d.ts +5 -2
  13. package/dist/copy-button.js +5 -2
  14. package/dist/data-table.d.ts +70 -0
  15. package/dist/data-table.js +46 -0
  16. package/dist/date-range-picker.d.ts +50 -0
  17. package/dist/date-range-picker.js +118 -0
  18. package/dist/date-time-field.d.ts +18 -0
  19. package/dist/date-time-field.js +31 -0
  20. package/dist/destructive-dialog.d.ts +28 -0
  21. package/dist/destructive-dialog.js +74 -0
  22. package/dist/dns-record-table.d.ts +30 -0
  23. package/dist/dns-record-table.js +33 -0
  24. package/dist/error-card.d.ts +23 -0
  25. package/dist/error-card.js +35 -0
  26. package/dist/filter-bar.d.ts +50 -0
  27. package/dist/filter-bar.js +88 -0
  28. package/dist/log-viewer.d.ts +39 -0
  29. package/dist/log-viewer.js +114 -0
  30. package/dist/middle-truncate.d.ts +17 -0
  31. package/dist/middle-truncate.js +25 -0
  32. package/dist/money-field.d.ts +31 -0
  33. package/dist/money-field.js +98 -0
  34. package/dist/products.d.ts +44 -0
  35. package/dist/products.js +47 -0
  36. package/dist/progress.d.ts +38 -0
  37. package/dist/progress.js +31 -0
  38. package/dist/rank-bar.d.ts +28 -0
  39. package/dist/rank-bar.js +31 -0
  40. package/dist/settings-section.d.ts +40 -0
  41. package/dist/settings-section.js +48 -0
  42. package/dist/sonner.d.ts +20 -1
  43. package/dist/sonner.js +43 -2
  44. package/dist/sparkline.d.ts +21 -0
  45. package/dist/sparkline.js +28 -0
  46. package/dist/status.d.ts +31 -8
  47. package/dist/status.js +19 -3
  48. package/dist/time-chart.d.ts +7 -1
  49. package/dist/time-chart.js +7 -42
  50. package/dist/uptime-bar.d.ts +5 -0
  51. package/dist/uptime-bar.js +33 -1
  52. package/package.json +1 -1
  53. package/styles.css +19 -0
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import ArrowDown01Icon from "@hugeicons/core-free-icons/ArrowDown01Icon";
3
+ import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
4
+ import Search01Icon from "@hugeicons/core-free-icons/Search01Icon";
5
+ import { HugeiconsIcon } from "@hugeicons/react";
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { Button, buttonVariants } from "./button.js";
8
+ import { cn } from "./cn.js";
9
+ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "./dropdown-menu.js";
10
+ import { Input } from "./input.js";
11
+ /** Le texte de la pastille : « Statut », « Statut : Erreur », « Statut : Erreur +2 ». */
12
+ export function filterSummary(filter, selected = []) {
13
+ const labels = filter.options.filter((o) => selected.includes(o.value)).map((o) => o.label);
14
+ const first = labels[0];
15
+ if (first === undefined)
16
+ return filter.label;
17
+ return labels.length === 1 ? `${filter.label} : ${first}` : `${filter.label} : ${first} +${labels.length - 1}`;
18
+ }
19
+ /** Nombre de critères posés, recherche comprise : sert à afficher « Effacer » et l'état vide. */
20
+ export function activeFilterCount(values = {}, search = "") {
21
+ const filters = Object.values(values).filter((v) => v !== undefined && v.length > 0).length;
22
+ return filters + (search.trim() === "" ? 0 : 1);
23
+ }
24
+ /** Ajoute ou retire une valeur d'un filtre à choix multiple, dans l'ordre des options. */
25
+ export function toggleFilterValue(filter, selected = [], value, checked) {
26
+ const next = new Set(selected);
27
+ if (checked)
28
+ next.add(value);
29
+ else
30
+ next.delete(value);
31
+ return filter.options.map((o) => o.value).filter((v) => next.has(v));
32
+ }
33
+ /**
34
+ * Recherche qui suit la saisie sans écrire dans l'URL à chaque touche : la valeur part après
35
+ * `delay` ms de calme. Si l'URL change de son côté (retour arrière), le champ la reprend.
36
+ */
37
+ function useDebouncedSearch(value, onChange, delay) {
38
+ const [draft, setDraft] = useState(value);
39
+ const [seen, setSeen] = useState(value);
40
+ if (value !== seen) {
41
+ setSeen(value);
42
+ setDraft(value);
43
+ }
44
+ const latest = useRef(onChange);
45
+ useEffect(() => {
46
+ latest.current = onChange;
47
+ });
48
+ useEffect(() => {
49
+ if (draft === value)
50
+ return;
51
+ const timer = setTimeout(() => latest.current(draft), delay);
52
+ return () => clearTimeout(timer);
53
+ }, [draft, value, delay]);
54
+ const flush = (next) => {
55
+ setDraft(next);
56
+ if (next !== value)
57
+ latest.current(next);
58
+ };
59
+ return { draft, setDraft, flush };
60
+ }
61
+ function SearchField({ value, onChange, placeholder = "Rechercher…", label = "Rechercher", delay = 250, }) {
62
+ const { draft, setDraft, flush } = useDebouncedSearch(value, onChange, delay);
63
+ return (_jsxs("div", { className: "relative min-w-0 flex-1 basis-full sm:basis-56 sm:max-w-sm", children: [_jsx(HugeiconsIcon, { icon: Search01Icon, strokeWidth: 2, "aria-hidden": true, className: "pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { type: "search", "aria-label": label, placeholder: placeholder, autoComplete: "off", spellCheck: false, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: (event) => {
64
+ if (event.key === "Enter")
65
+ flush(draft);
66
+ if (event.key === "Escape" && draft !== "") {
67
+ event.preventDefault();
68
+ flush("");
69
+ }
70
+ }, className: "h-10 pr-9 pl-10 lg:h-9 [&::-webkit-search-cancel-button]:appearance-none" }), draft !== "" ? (_jsx("button", { type: "button", "aria-label": "Vider la recherche", onClick: () => flush(""), className: "absolute top-1/2 right-1.5 grid size-7 -translate-y-1/2 place-items-center rounded-full text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", children: _jsx(HugeiconsIcon, { icon: Cancel01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-3.5" }) })) : null] }));
71
+ }
72
+ function FilterPill({ filter, selected = [], onChange, }) {
73
+ const active = selected.length > 0;
74
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { "data-slot": "filter-pill", "data-active": active || undefined, className: cn(buttonVariants({ variant: "outline", size: "sm" }), "max-w-64 gap-1 pr-2.5 font-normal text-muted-foreground", active && "bg-foreground/[0.06] font-medium text-foreground ring-0 dark:bg-foreground/[0.08]"), children: [_jsx("span", { className: "truncate", children: filterSummary(filter, selected) }), _jsx(HugeiconsIcon, { icon: ArrowDown01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-3.5 opacity-60" })] }), _jsx(DropdownMenuContent, { className: "w-auto", children: filter.single ? (_jsxs(DropdownMenuRadioGroup, { label: filter.label, value: selected[0] ?? "", onValueChange: (value) => onChange(value === "" ? [] : [value]), children: [_jsx(DropdownMenuRadioItem, { value: "", children: "Tous" }), filter.options.map((option) => (_jsx(DropdownMenuRadioItem, { value: option.value, children: option.label }, option.value)))] })) : (_jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: filter.label }), filter.options.map((option) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.includes(option.value), onCheckedChange: (checked) => onChange(toggleFilterValue(filter, selected, option.value, checked)), children: option.label }, option.value)))] })) })] }));
75
+ }
76
+ /**
77
+ * Recherche et filtres en pastilles au-dessus d'une liste. Entièrement contrôlée, sans routeur :
78
+ * le produit lit les valeurs dans les paramètres d'URL et les y réécrit dans les callbacks, pour
79
+ * que le partage, le rafraîchissement et le retour arrière gardent la vue.
80
+ *
81
+ * La recherche n'écrit qu'après 250 ms de calme (Entrée pour tout de suite, Échap pour vider).
82
+ * Un filtre à choix multiple reste ouvert pendant qu'on coche ; `single` en fait un choix unique
83
+ * avec « Tous ». « Effacer » apparaît dès qu'un critère est posé.
84
+ */
85
+ export function FilterBar({ search, filters = [], values = {}, onFilterChange, onReset, actions, className, }) {
86
+ const count = activeFilterCount(values, search?.value);
87
+ return (_jsxs("div", { role: "search", "data-slot": "filter-bar", className: cn("flex flex-wrap items-center gap-2", className), children: [search ? _jsx(SearchField, { ...search }) : null, filters.map((filter) => (_jsx(FilterPill, { filter: filter, ...(values[filter.key] !== undefined ? { selected: values[filter.key] } : {}), onChange: (next) => onFilterChange?.(filter.key, next) }, filter.key))), onReset && count > 0 ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: onReset, className: "text-muted-foreground", children: "Effacer" })) : null, actions ? _jsx("div", { className: "ml-auto flex items-center gap-2", children: actions }) : null] }));
88
+ }
@@ -0,0 +1,39 @@
1
+ export type LogLevel = "debug" | "info" | "warn" | "error";
2
+ export type LogLine = {
3
+ id: string;
4
+ /** Millisecondes ou `Date`. */
5
+ time: number | Date;
6
+ level?: LogLevel;
7
+ message: string;
8
+ };
9
+ export declare const LOG_LEVEL_LABEL: Record<LogLevel, string>;
10
+ /** Les lignes qui passent les niveaux choisis (aucun : tous) et contiennent la recherche. */
11
+ export declare function filterLogLines(lines: ReadonlyArray<LogLine>, { levels, query }: {
12
+ levels?: ReadonlyArray<LogLevel>;
13
+ query?: string;
14
+ }): ReadonlyArray<LogLine>;
15
+ /** Le texte copié : une ligne par entrée, heure, niveau, message. */
16
+ export declare function formatLogLines(lines: ReadonlyArray<LogLine>, formatTime?: (time: number | Date) => string): string;
17
+ /** Les lignes à rendre pour une position de défilement : `[début, fin)`. */
18
+ export declare function visibleRange(scrollTop: number, viewport: number, count: number): [start: number, end: number];
19
+ /**
20
+ * Journaux d'un déploiement, d'une fonction, d'un envoi : flux en chasse fixe, heure et niveau
21
+ * en tête de ligne. Filtre par niveau, recherche (surlignée), copie de ce qui est affiché.
22
+ *
23
+ * `live` : le flux grossit encore. « Suivre » garde le bas en vue ; remonter dans le journal
24
+ * l'arrête, « Suivre » ou le bouton du bas le reprend.
25
+ *
26
+ * Seules les lignes visibles sont dans la page (hauteur de ligne fixe, lignes longues défilant
27
+ * de côté) : dix mille lignes coûtent autant que cinquante.
28
+ */
29
+ export declare function LogViewer({ lines, label, live, height, formatTime, emptyLabel, className, }: {
30
+ lines: ReadonlyArray<LogLine>;
31
+ /** Nom du journal pour les lecteurs d'écran. */
32
+ label?: string;
33
+ live?: boolean;
34
+ /** Hauteur de la zone des lignes, en pixels. */
35
+ height?: number;
36
+ formatTime?: (time: number | Date) => string;
37
+ emptyLabel?: string;
38
+ className?: string;
39
+ }): React.JSX.Element;
@@ -0,0 +1,114 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Toggle } from "@base-ui/react/toggle";
3
+ import { ToggleGroup } from "@base-ui/react/toggle-group";
4
+ import ArrowDownDoubleIcon from "@hugeicons/core-free-icons/ArrowDownDoubleIcon";
5
+ import Copy01Icon from "@hugeicons/core-free-icons/Copy01Icon";
6
+ import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
7
+ import { HugeiconsIcon } from "@hugeicons/react";
8
+ import { useDeferredValue, useLayoutEffect, useMemo, useRef, useState } from "react";
9
+ import { buttonVariants } from "./button.js";
10
+ import { cn } from "./cn.js";
11
+ import { useCopy } from "./copy-button.js";
12
+ import { Input } from "./input.js";
13
+ export const LOG_LEVEL_LABEL = {
14
+ debug: "Debug",
15
+ info: "Info",
16
+ warn: "Alerte",
17
+ error: "Erreur",
18
+ };
19
+ const LEVELS = ["debug", "info", "warn", "error"];
20
+ const LEVEL_INK = {
21
+ debug: "text-muted-foreground/70",
22
+ info: "text-muted-foreground",
23
+ warn: "text-status-degraded-ink",
24
+ error: "text-destructive",
25
+ };
26
+ /** Hauteur fixe d'une ligne, en pixels : c'est elle qui permet de n'afficher que la partie visible. */
27
+ const ROW = 24;
28
+ /** Lignes rendues au-delà de la partie visible, en haut et en bas, pour un défilement sans trou. */
29
+ const OVERSCAN = 20;
30
+ const clock = new Intl.DateTimeFormat("fr-CH", {
31
+ hour: "2-digit",
32
+ minute: "2-digit",
33
+ second: "2-digit",
34
+ fractionalSecondDigits: 3,
35
+ });
36
+ /** Les lignes qui passent les niveaux choisis (aucun : tous) et contiennent la recherche. */
37
+ export function filterLogLines(lines, { levels = [], query = "" }) {
38
+ const needle = query.trim().toLocaleLowerCase("fr-CH");
39
+ if (levels.length === 0 && needle === "")
40
+ return lines;
41
+ return lines.filter((line) => (levels.length === 0 || (line.level !== undefined && levels.includes(line.level))) &&
42
+ (needle === "" || line.message.toLocaleLowerCase("fr-CH").includes(needle)));
43
+ }
44
+ /** Le texte copié : une ligne par entrée, heure, niveau, message. */
45
+ export function formatLogLines(lines, formatTime = (t) => clock.format(t)) {
46
+ return lines
47
+ .map((line) => [formatTime(line.time), line.level?.toUpperCase(), line.message].filter(Boolean).join(" "))
48
+ .join("\n");
49
+ }
50
+ /** Les lignes à rendre pour une position de défilement : `[début, fin)`. */
51
+ export function visibleRange(scrollTop, viewport, count) {
52
+ const start = Math.max(0, Math.floor(scrollTop / ROW) - OVERSCAN);
53
+ const end = Math.min(count, Math.ceil((scrollTop + viewport) / ROW) + OVERSCAN);
54
+ return [start, end];
55
+ }
56
+ function Highlight({ text, query }) {
57
+ const needle = query.trim().toLocaleLowerCase("fr-CH");
58
+ if (needle === "")
59
+ return text;
60
+ const parts = [];
61
+ const lower = text.toLocaleLowerCase("fr-CH");
62
+ let from = 0;
63
+ let at = lower.indexOf(needle);
64
+ while (at !== -1) {
65
+ if (at > from)
66
+ parts.push(text.slice(from, at));
67
+ parts.push(_jsx("mark", { className: "rounded-sm bg-status-degraded/30 text-foreground", children: text.slice(at, at + needle.length) }, at));
68
+ from = at + needle.length;
69
+ at = lower.indexOf(needle, from);
70
+ }
71
+ parts.push(text.slice(from));
72
+ return parts;
73
+ }
74
+ const CHIP = "inline-flex h-8 items-center rounded-full px-3 text-xs font-medium text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-out select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-pressed:bg-foreground data-pressed:text-background";
75
+ /**
76
+ * Journaux d'un déploiement, d'une fonction, d'un envoi : flux en chasse fixe, heure et niveau
77
+ * en tête de ligne. Filtre par niveau, recherche (surlignée), copie de ce qui est affiché.
78
+ *
79
+ * `live` : le flux grossit encore. « Suivre » garde le bas en vue ; remonter dans le journal
80
+ * l'arrête, « Suivre » ou le bouton du bas le reprend.
81
+ *
82
+ * Seules les lignes visibles sont dans la page (hauteur de ligne fixe, lignes longues défilant
83
+ * de côté) : dix mille lignes coûtent autant que cinquante.
84
+ */
85
+ /* oxlint-disable jsx-a11y/no-noninteractive-tabindex -- zone de défilement focalisable, voulu */
86
+ export function LogViewer({ lines, label = "Journaux", live = false, height = 420, formatTime = (t) => clock.format(t), emptyLabel = "Aucune ligne pour l'instant.", className, }) {
87
+ const [query, setQuery] = useState("");
88
+ const [levels, setLevels] = useState([]);
89
+ const [follow, setFollow] = useState(true);
90
+ const [scrollTop, setScrollTop] = useState(0);
91
+ const viewport = useRef(null);
92
+ const { copied, copy } = useCopy();
93
+ const deferredQuery = useDeferredValue(query);
94
+ const shown = useMemo(() => filterLogLines(lines, { levels, query: deferredQuery }), [lines, levels, deferredQuery]);
95
+ const hasLevels = useMemo(() => lines.some((l) => l.level !== undefined), [lines]);
96
+ const following = live && follow;
97
+ const [start, end] = visibleRange(scrollTop, height, shown.length);
98
+ useLayoutEffect(() => {
99
+ const el = viewport.current;
100
+ if (!following || !el)
101
+ return;
102
+ el.scrollTop = el.scrollHeight;
103
+ }, [following, shown]);
104
+ function onScroll() {
105
+ const el = viewport.current;
106
+ if (!el)
107
+ return;
108
+ setScrollTop(el.scrollTop);
109
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= ROW;
110
+ if (live && follow && !atBottom)
111
+ setFollow(false);
112
+ }
113
+ return (_jsxs("div", { "data-slot": "log-viewer", className: cn("surface flex min-w-0 flex-col overflow-hidden rounded-3xl", className), children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 p-3", children: [_jsx(Input, { type: "search", "aria-label": "Rechercher dans les journaux", placeholder: "Rechercher\u2026", autoComplete: "off", spellCheck: false, value: query, onChange: (event) => setQuery(event.target.value), className: "h-9 min-w-0 flex-1 basis-full sm:max-w-xs sm:basis-48 lg:h-9" }), hasLevels ? (_jsx(ToggleGroup, { multiple: true, "aria-label": "Niveaux", value: levels, onValueChange: (value) => setLevels(LEVELS.filter((l) => value.includes(l))), className: "flex items-center gap-1", children: LEVELS.map((level) => (_jsx(Toggle, { value: level, className: CHIP, children: LOG_LEVEL_LABEL[level] }, level))) })) : null, _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [live ? (_jsx(Toggle, { pressed: follow, onPressedChange: setFollow, className: CHIP, children: "Suivre" })) : null, _jsx("button", { type: "button", "aria-label": copied ? "Copié" : "Copier les lignes affichées", title: copied ? "Copié" : "Copier les lignes affichées", disabled: shown.length === 0, onClick: () => void copy(formatLogLines(shown, formatTime)), className: cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground"), children: _jsx(HugeiconsIcon, { icon: copied ? Tick02Icon : Copy01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4 pop-in" }, copied ? "ok" : "copy") })] })] }), _jsxs("div", { className: "relative", children: [_jsx("div", { ref: viewport, role: "log", "aria-label": label, "aria-live": "off", tabIndex: 0, onScroll: onScroll, style: { height }, className: "overflow-auto border-t border-foreground/5 py-2 font-mono text-[13px] leading-6 outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset", children: shown.length === 0 ? (_jsx("p", { className: "px-4 font-sans text-sm text-muted-foreground", children: lines.length === 0 ? emptyLabel : "Aucune ligne ne correspond." })) : (_jsx("div", { style: { height: shown.length * ROW }, className: "relative w-max min-w-full", children: _jsx("div", { style: { transform: `translateY(${start * ROW}px)` }, children: shown.slice(start, end).map((line) => (_jsxs("div", { "data-slot": "log-line", "data-level": line.level, style: { height: ROW }, className: cn("flex gap-3 px-4 whitespace-pre hover:bg-foreground/[0.03]", line.level === "error" && "bg-destructive/[0.05]"), children: [_jsx("time", { className: "shrink-0 text-muted-foreground tabular-nums select-none", children: formatTime(line.time) }), hasLevels ? (_jsx("span", { className: cn("w-14 shrink-0 uppercase", line.level && LEVEL_INK[line.level]), children: line.level ? LOG_LEVEL_LABEL[line.level] : "" })) : null, _jsx("span", { children: _jsx(Highlight, { text: line.message, query: deferredQuery }) })] }, line.id))) }) })) }), live && !follow ? (_jsxs("button", { type: "button", onClick: () => setFollow(true), className: cn(buttonVariants({ variant: "secondary", size: "xs" }), "absolute bottom-3 left-1/2 -translate-x-1/2 shadow-lg shadow-black/10"), children: [_jsx(HugeiconsIcon, { icon: ArrowDownDoubleIcon, strokeWidth: 2, "aria-hidden": true }), "Revenir en bas"] })) : null] }), _jsx("p", { className: "sr-only", "aria-live": "polite", children: deferredQuery.trim() !== "" || levels.length > 0 ? `${shown.length} lignes sur ${lines.length}` : "" })] }));
114
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Où couper : la fin gardée entière. Un chemin garde son dernier segment (`…/src/app.tsx`), un
3
+ * identifiant ses `tail` derniers caractères (`dpl_8f3a…c91e`). La fin d'un identifiant est celle
4
+ * qu'on compare d'un coup d'œil, comme dans un hash git.
5
+ */
6
+ export declare function splitForTruncation(text: string, tail?: number): [start: string, end: string];
7
+ /**
8
+ * Un texte long coupé au milieu quand la place manque : le début s'efface en « … », la fin reste
9
+ * lisible. Le texte entier reste dans la page (lecteurs d'écran, sélection, copie, recherche du
10
+ * navigateur) et dans l'infobulle native. Pur CSS : aucune mesure, la coupe suit la largeur.
11
+ */
12
+ export declare function MiddleTruncate({ text, tail, className, }: {
13
+ text: string;
14
+ /** Caractères gardés à la fin quand le texte n'est pas un chemin. */
15
+ tail?: number;
16
+ className?: string;
17
+ }): React.JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "./cn.js";
3
+ /**
4
+ * Où couper : la fin gardée entière. Un chemin garde son dernier segment (`…/src/app.tsx`), un
5
+ * identifiant ses `tail` derniers caractères (`dpl_8f3a…c91e`). La fin d'un identifiant est celle
6
+ * qu'on compare d'un coup d'œil, comme dans un hash git.
7
+ */
8
+ export function splitForTruncation(text, tail = 8) {
9
+ if (text.length <= tail * 2)
10
+ return [text, ""];
11
+ const slash = text.lastIndexOf("/", text.length - 2);
12
+ const segment = slash > 0 ? text.length - slash : 0;
13
+ // Un dernier segment trop long ou trop court ne sert pas de fin : on revient aux caractères.
14
+ const keep = segment > 2 && segment <= tail * 3 ? segment : tail;
15
+ return [text.slice(0, text.length - keep), text.slice(text.length - keep)];
16
+ }
17
+ /**
18
+ * Un texte long coupé au milieu quand la place manque : le début s'efface en « … », la fin reste
19
+ * lisible. Le texte entier reste dans la page (lecteurs d'écran, sélection, copie, recherche du
20
+ * navigateur) et dans l'infobulle native. Pur CSS : aucune mesure, la coupe suit la largeur.
21
+ */
22
+ export function MiddleTruncate({ text, tail = 8, className, }) {
23
+ const [start, end] = splitForTruncation(text, tail);
24
+ return (_jsxs("span", { "data-slot": "middle-truncate", title: text, className: cn("inline-flex max-w-full min-w-0", className), children: [_jsx("span", { className: "min-w-0 truncate", children: start }), end ? _jsx("span", { className: "shrink-0 whitespace-pre", children: end }) : null] }));
25
+ }
@@ -0,0 +1,31 @@
1
+ /** Des centimes en montant suisse : `125050` → « 1’250.50 », `-500` → « -5.00 ». */
2
+ export declare function formatMoney(centimes: number): string;
3
+ /** Comme `formatMoney`, précédé de la devise : « CHF 1’250.50 ». */
4
+ export declare function formatChf(centimes: number): string;
5
+ export type MoneyParse = {
6
+ ok: true;
7
+ centimes: number | null;
8
+ } | {
9
+ ok: false;
10
+ };
11
+ /**
12
+ * Lit un montant saisi, en centimes, sans passer par les flottants. Accepte les écritures d'ici :
13
+ * « 1’250.50 », « 1'250,50 », « 1 250.5 », « CHF 12.– », « 12.- ». Vide : `centimes: null`.
14
+ * Plus de deux décimales, deux virgules ou des lettres : `ok: false`.
15
+ */
16
+ export declare function parseMoney(text: string): MoneyParse;
17
+ /**
18
+ * Un montant en francs suisses, stocké en centimes (entier) : prix, budget, seuil d'alerte. Le
19
+ * champ accepte les écritures courantes (apostrophe, virgule, « 12.– ») et se remet au format
20
+ * suisse en quittant le champ. Clavier numérique sur téléphone.
21
+ *
22
+ * Une saisie invalide ou hors bornes n'appelle pas `onChange` et s'explique sous le champ.
23
+ */
24
+ export declare function MoneyField({ value, onChange, min, max, className, id: idProp, ...props }: Omit<React.ComponentProps<"input">, "value" | "onChange" | "min" | "max" | "type" | "defaultValue"> & {
25
+ /** En centimes ; `null` : vide. */
26
+ value: number | null;
27
+ onChange: (centimes: number | null) => void;
28
+ /** En centimes, `0` par défaut (pas de montant négatif) ; `null` : sans borne. */
29
+ min?: number | null;
30
+ max?: number;
31
+ }): React.JSX.Element;
@@ -0,0 +1,98 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useState } from "react";
3
+ import { cn } from "./cn.js";
4
+ import { useFieldDescribedBy, useFieldId } from "./field.js";
5
+ import { Input } from "./input.js";
6
+ /** Apostrophe typographique, séparateur des milliers en Suisse : « 1’250.50 ». */
7
+ const GROUP = "’";
8
+ /** Des centimes en montant suisse : `125050` → « 1’250.50 », `-500` → « -5.00 ». */
9
+ export function formatMoney(centimes) {
10
+ const sign = centimes < 0 ? "-" : "";
11
+ const abs = Math.abs(Math.round(centimes));
12
+ const francs = String(Math.floor(abs / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, GROUP);
13
+ return `${sign}${francs}.${String(abs % 100).padStart(2, "0")}`;
14
+ }
15
+ /** Comme `formatMoney`, précédé de la devise : « CHF 1’250.50 ». */
16
+ export function formatChf(centimes) {
17
+ return `CHF ${formatMoney(centimes)}`;
18
+ }
19
+ /**
20
+ * Lit un montant saisi, en centimes, sans passer par les flottants. Accepte les écritures d'ici :
21
+ * « 1’250.50 », « 1'250,50 », « 1 250.5 », « CHF 12.– », « 12.- ». Vide : `centimes: null`.
22
+ * Plus de deux décimales, deux virgules ou des lettres : `ok: false`.
23
+ */
24
+ export function parseMoney(text) {
25
+ const compact = text
26
+ .replace(/^\s*(CHF|Fr\.)\s*/i, "")
27
+ // `\s` couvre aussi les espaces insécables qu'Intl met entre les milliers.
28
+ .replace(/[\s'’]/g, "")
29
+ .replace(/\.[-–—]$/, "")
30
+ .replace(",", ".");
31
+ if (compact === "")
32
+ return { ok: true, centimes: null };
33
+ const match = /^(-?)(\d*)(?:\.(\d{0,2}))?$/.exec(compact);
34
+ if (!match || (match[2] === "" && !match[3]))
35
+ return { ok: false };
36
+ const francs = Number(match[2] || "0");
37
+ const cents = Number((match[3] ?? "").padEnd(2, "0"));
38
+ const centimes = francs * 100 + cents;
39
+ if (!Number.isSafeInteger(centimes))
40
+ return { ok: false };
41
+ return { ok: true, centimes: match[1] && centimes !== 0 ? -centimes : centimes };
42
+ }
43
+ /**
44
+ * Un montant en francs suisses, stocké en centimes (entier) : prix, budget, seuil d'alerte. Le
45
+ * champ accepte les écritures courantes (apostrophe, virgule, « 12.– ») et se remet au format
46
+ * suisse en quittant le champ. Clavier numérique sur téléphone.
47
+ *
48
+ * Une saisie invalide ou hors bornes n'appelle pas `onChange` et s'explique sous le champ.
49
+ */
50
+ export function MoneyField({ value, onChange, min = 0, max, className, id: idProp, ...props }) {
51
+ const fieldId = useFieldId();
52
+ const fieldDescribedBy = useFieldDescribedBy();
53
+ const ownId = useId();
54
+ const id = idProp ?? fieldId ?? ownId;
55
+ const errorId = `${id}-money-error`;
56
+ const display = (centimes) => (centimes === null ? "" : formatMoney(centimes));
57
+ const [text, setText] = useState(display(value));
58
+ const [shown, setShown] = useState(value);
59
+ const [error, setError] = useState(null);
60
+ // Un montant changé par le produit (réinitialisation) remplace la saisie.
61
+ if (shown !== value) {
62
+ setShown(value);
63
+ const parsed = parseMoney(text);
64
+ if (!parsed.ok || parsed.centimes !== value)
65
+ setText(display(value));
66
+ }
67
+ const check = (next) => {
68
+ const parsed = parseMoney(next);
69
+ if (!parsed.ok)
70
+ return { error: "Montant invalide. Écris par exemple 1’250.50." };
71
+ const { centimes } = parsed;
72
+ if (centimes !== null && min !== null && centimes < min)
73
+ return { error: `Au moins ${formatChf(min)}.` };
74
+ if (centimes !== null && max !== undefined && centimes > max)
75
+ return { error: `Au plus ${formatChf(max)}.` };
76
+ return { centimes };
77
+ };
78
+ const describedBy = [props["aria-describedby"] ?? fieldDescribedBy, error ? errorId : null].filter(Boolean).join(" ");
79
+ return (_jsxs("div", { "data-slot": "money-field", className: cn("grid gap-1.5", className), children: [_jsxs("div", { className: "relative", children: [_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-y-0 left-3.5 flex items-center text-sm text-muted-foreground", children: "CHF" }), _jsx(Input, { ...props, id: id, type: "text", inputMode: "decimal", autoComplete: "off", value: text, "aria-invalid": error ? true : undefined, "aria-describedby": describedBy || undefined, onChange: (event) => {
80
+ const next = event.target.value;
81
+ setText(next);
82
+ const result = check(next);
83
+ if ("error" in result)
84
+ return;
85
+ setError(null);
86
+ if (result.centimes !== value) {
87
+ setShown(result.centimes);
88
+ onChange(result.centimes);
89
+ }
90
+ }, onBlur: (event) => {
91
+ props.onBlur?.(event);
92
+ const result = check(text);
93
+ if ("error" in result)
94
+ return setError(result.error);
95
+ setError(null);
96
+ setText(display(result.centimes));
97
+ }, className: "pl-12 text-right tabular-nums" })] }), error ? (_jsx("p", { id: errorId, role: "alert", className: "soft-in text-sm text-destructive", children: error })) : null] }));
98
+ }
@@ -0,0 +1,44 @@
1
+ import type { IconSvgElement } from "@hugeicons/react";
2
+ /** Où en est un produit : seul ce qui répond s'ouvre depuis les menus. */
3
+ export type ProductStage = "live" | "preview" | "soon";
4
+ export declare const PRODUCT_STAGE_LABEL: Readonly<Record<ProductStage, string>>;
5
+ /** Un produit de la famille, tel que la vision le décrit. */
6
+ export type HgdcCatalogProduct = {
7
+ /** Le mot de « HGDC <mot> » : « Host ». */
8
+ word: string;
9
+ /** « HGDC Host ». */
10
+ name: string;
11
+ /** « host.hgdc.ch ». */
12
+ subdomain: string;
13
+ href: string;
14
+ /** Une phrase, au tutoiement. */
15
+ pitch: string;
16
+ icon: IconSvgElement;
17
+ stage: ProductStage;
18
+ };
19
+ /** HGDC Account : le compte commun (profil, sécurité, organisations), hors du catalogue des produits. */
20
+ export declare const HGDC_ACCOUNT: HgdcCatalogProduct;
21
+ /** Les produits de la famille, dans leur ordre d'affichage. Account n'en fait pas partie (`HGDC_ACCOUNT`). */
22
+ export declare const HGDC_PRODUCTS: ReadonlyArray<HgdcCatalogProduct>;
23
+ /** Vrai si le produit répond déjà (en service ou en préversion). */
24
+ export declare function isProductOpen(item: Pick<HgdcCatalogProduct, "stage">): boolean;
25
+ /** Une entrée du menu « Produits HGDC » d'`AccountMenu`. */
26
+ export type ProductMenuEntry = {
27
+ name: string;
28
+ href: string;
29
+ /** Le produit ouvert : coché, sans lien. */
30
+ current?: boolean;
31
+ icon?: IconSvgElement;
32
+ /** Texte discret à droite : « Préversion ». */
33
+ hint?: string;
34
+ };
35
+ /**
36
+ * Les entrées du menu du compte : Account d'abord, puis les produits ouverts. `current` est le
37
+ * mot du produit affiché (« Mail ») : il est coché. `accountHref` remplace l'adresse d'Account
38
+ * (un Account local en développement).
39
+ */
40
+ export declare function productMenuEntries(options?: {
41
+ current?: string | undefined;
42
+ accountHref?: string | undefined;
43
+ catalog?: ReadonlyArray<HgdcCatalogProduct> | undefined;
44
+ }): Array<ProductMenuEntry>;
@@ -0,0 +1,47 @@
1
+ import Activity01Icon from "@hugeicons/core-free-icons/Activity01Icon";
2
+ import Analytics01Icon from "@hugeicons/core-free-icons/Analytics01Icon";
3
+ import BookOpen01Icon from "@hugeicons/core-free-icons/BookOpen01Icon";
4
+ import Briefcase01Icon from "@hugeicons/core-free-icons/Briefcase01Icon";
5
+ import Mail01Icon from "@hugeicons/core-free-icons/Mail01Icon";
6
+ import Rocket01Icon from "@hugeicons/core-free-icons/Rocket01Icon";
7
+ import UserCircleIcon from "@hugeicons/core-free-icons/UserCircleIcon";
8
+ export const PRODUCT_STAGE_LABEL = {
9
+ live: "En service",
10
+ preview: "Préversion",
11
+ soon: "Bientôt",
12
+ };
13
+ function product(word, pitch, icon, stage) {
14
+ const subdomain = `${word.toLowerCase()}.hgdc.ch`;
15
+ return { word, name: `HGDC ${word}`, subdomain, href: `https://${subdomain}`, pitch, icon, stage };
16
+ }
17
+ /** HGDC Account : le compte commun (profil, sécurité, organisations), hors du catalogue des produits. */
18
+ export const HGDC_ACCOUNT = product("Account", "Ton compte HGDC : profil, sécurité et organisations, pour tous les produits.", UserCircleIcon, "live");
19
+ /** Les produits de la famille, dans leur ordre d'affichage. Account n'en fait pas partie (`HGDC_ACCOUNT`). */
20
+ export const HGDC_PRODUCTS = [
21
+ product("Host", "Ton site en ligne, déployé à chaque modification.", Rocket01Icon, "preview"),
22
+ product("Mail", "Tes e-mails, newsletters et formulaires, envoyés depuis ton adresse.", Mail01Icon, "live"),
23
+ product("Portal", "Un espace partagé où ton client suit son projet, signe et paie.", Briefcase01Icon, "soon"),
24
+ product("Status", "La surveillance de tes sites et ta page de statut.", Activity01Icon, "soon"),
25
+ product("Analytics", "Tes visiteurs mesurés, sans cookies ni bannière.", Analytics01Icon, "soon"),
26
+ product("Docs", "Ta documentation en ligne, avec un assistant qui y répond.", BookOpen01Icon, "soon"),
27
+ ];
28
+ /** Vrai si le produit répond déjà (en service ou en préversion). */
29
+ export function isProductOpen(item) {
30
+ return item.stage !== "soon";
31
+ }
32
+ /**
33
+ * Les entrées du menu du compte : Account d'abord, puis les produits ouverts. `current` est le
34
+ * mot du produit affiché (« Mail ») : il est coché. `accountHref` remplace l'adresse d'Account
35
+ * (un Account local en développement).
36
+ */
37
+ export function productMenuEntries(options = {}) {
38
+ const current = options.current?.toLowerCase();
39
+ const account = { ...HGDC_ACCOUNT, href: options.accountHref ?? HGDC_ACCOUNT.href };
40
+ return [account, ...(options.catalog ?? HGDC_PRODUCTS).filter(isProductOpen)].map((item) => ({
41
+ name: item.name,
42
+ href: item.href,
43
+ icon: item.icon,
44
+ ...(item.word.toLowerCase() === current ? { current: true } : {}),
45
+ ...(item.stage === "preview" ? { hint: PRODUCT_STAGE_LABEL.preview } : {}),
46
+ }));
47
+ }
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from "react";
2
+ /**
3
+ * L'avancement d'une tâche qui finit : import, build, envoi d'une campagne. `value={null}` :
4
+ * durée inconnue, une barre qui glisse (immobile si le mouvement est réduit). Base UI pose
5
+ * `role="progressbar"` et ses valeurs `aria`.
6
+ *
7
+ * Pour une quantité dans une plage (quota, stockage), `Meter`.
8
+ */
9
+ export declare function Progress({ label, value, max, valueText, showValue, className, }: {
10
+ /** Ce qui avance, visible et nom accessible : « Envoi de la campagne ». */
11
+ label: ReactNode;
12
+ value: number | null;
13
+ max?: number;
14
+ /** Texte de la valeur, lu aussi par les lecteurs d'écran : « 120 sur 400 e-mails ». Pourcentage par défaut. */
15
+ valueText?: string;
16
+ showValue?: boolean;
17
+ className?: string;
18
+ }): React.JSX.Element;
19
+ /** Seuils d'une jauge : au-delà de `warning`, orange ; au-delà de `danger`, rouge. En fraction de `max`. */
20
+ export type MeterThresholds = {
21
+ warning?: number;
22
+ danger?: number;
23
+ };
24
+ /**
25
+ * Une quantité dans une plage connue : quota d'envoi du mois, stockage, sièges. La couleur passe
26
+ * à l'orange puis au rouge près de la limite, et le texte dit toujours la valeur : jamais la
27
+ * couleur seule. Base UI pose `role="meter"`.
28
+ */
29
+ export declare function Meter({ label, value, max, valueText, thresholds, className, }: {
30
+ /** « E-mails envoyés ce mois ». */
31
+ label: ReactNode;
32
+ value: number;
33
+ max?: number;
34
+ /** « 8’200 sur 10’000 ». Pourcentage par défaut. */
35
+ valueText?: string;
36
+ thresholds?: MeterThresholds;
37
+ className?: string;
38
+ }): React.JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Meter as MeterPrimitive } from "@base-ui/react/meter";
3
+ import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
4
+ import { cn } from "./cn.js";
5
+ const TRACK = "relative h-1.5 w-full overflow-hidden rounded-full bg-foreground/[0.08]";
6
+ const LABEL = "text-sm font-medium";
7
+ const VALUE = "text-sm text-muted-foreground tabular-nums";
8
+ /**
9
+ * L'avancement d'une tâche qui finit : import, build, envoi d'une campagne. `value={null}` :
10
+ * durée inconnue, une barre qui glisse (immobile si le mouvement est réduit). Base UI pose
11
+ * `role="progressbar"` et ses valeurs `aria`.
12
+ *
13
+ * Pour une quantité dans une plage (quota, stockage), `Meter`.
14
+ */
15
+ export function Progress({ label, value, max = 100, valueText, showValue = true, className, }) {
16
+ return (_jsxs(ProgressPrimitive.Root, { "data-slot": "progress", value: value, max: max, locale: "fr-CH", ...(valueText !== undefined ? { getAriaValueText: () => valueText } : {}), className: cn("grid grid-cols-[1fr_auto] items-baseline gap-x-3 gap-y-2", className), children: [_jsx(ProgressPrimitive.Label, { className: LABEL, children: label }), showValue && value !== null ? (_jsx(ProgressPrimitive.Value, { className: VALUE, children: (formatted) => valueText ?? formatted })) : (_jsx("span", {})), _jsx(ProgressPrimitive.Track, { className: cn(TRACK, "col-span-2"), children: _jsx(ProgressPrimitive.Indicator, { className: cn("block h-full rounded-full bg-primary transition-[width] duration-(--duration-base) ease-out", "data-indeterminate:absolute data-indeterminate:inset-y-0 data-indeterminate:w-1/3 data-indeterminate:progress-slide") }) })] }));
17
+ }
18
+ /**
19
+ * Une quantité dans une plage connue : quota d'envoi du mois, stockage, sièges. La couleur passe
20
+ * à l'orange puis au rouge près de la limite, et le texte dit toujours la valeur : jamais la
21
+ * couleur seule. Base UI pose `role="meter"`.
22
+ */
23
+ export function Meter({ label, value, max = 100, valueText, thresholds = { warning: 0.8, danger: 0.95 }, className, }) {
24
+ const ratio = max > 0 ? value / max : 0;
25
+ const level = thresholds.danger !== undefined && ratio >= thresholds.danger
26
+ ? "danger"
27
+ : thresholds.warning !== undefined && ratio >= thresholds.warning
28
+ ? "warning"
29
+ : "normal";
30
+ return (_jsxs(MeterPrimitive.Root, { "data-slot": "meter", "data-level": level, value: Math.min(Math.max(value, 0), max), max: max, locale: "fr-CH", ...(valueText !== undefined ? { getAriaValueText: () => valueText } : {}), className: cn("grid grid-cols-[1fr_auto] items-baseline gap-x-3 gap-y-2", className), children: [_jsx(MeterPrimitive.Label, { className: LABEL, children: label }), _jsx(MeterPrimitive.Value, { className: VALUE, children: (formatted) => valueText ?? formatted }), _jsx(MeterPrimitive.Track, { className: cn(TRACK, "col-span-2"), children: _jsx(MeterPrimitive.Indicator, { className: cn("block h-full rounded-full transition-[width] duration-(--duration-base) ease-out", level === "danger" ? "bg-destructive" : level === "warning" ? "bg-status-degraded" : "bg-primary") }) })] }));
31
+ }
@@ -0,0 +1,28 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ export type RankItem = {
3
+ /** Identifiant stable de la ligne. */
4
+ key: string;
5
+ label: ReactNode;
6
+ value: number;
7
+ /** Icône ou pastille avant le libellé (drapeau, favicon), décorative. */
8
+ icon?: ReactNode;
9
+ /** La ligne mène au détail : `<Link to="…" />` ou `<a href="…" />`. */
10
+ render?: ReactElement;
11
+ };
12
+ /**
13
+ * Classement d'un tableau de bord : pages les plus vues, domaines qui envoient le plus, pays.
14
+ * Une ligne par élément, triée du plus grand au plus petit, sa barre proportionnelle au premier.
15
+ * Une liste ordonnée : le rang se lit sans la barre, et chaque valeur s'écrit.
16
+ */
17
+ export declare function RankBar({ label, items, limit, formatValue, valueLabel, emptyLabel, className, }: {
18
+ /** Nom de la liste pour les lecteurs d'écran : « Pages les plus vues ». */
19
+ label: string;
20
+ items: ReadonlyArray<RankItem>;
21
+ /** Nombre de lignes affichées ; le reste n'est pas rendu (le produit offre « Tout voir »). */
22
+ limit?: number;
23
+ formatValue?: (value: number) => string;
24
+ /** En-tête de la colonne des valeurs, visible : « Visites ». */
25
+ valueLabel?: ReactNode;
26
+ emptyLabel?: ReactNode;
27
+ className?: string;
28
+ }): React.JSX.Element;