@hgdc/ui 0.3.0 → 0.4.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/README.md +16 -0
- package/dist/chart-path.d.ts +9 -0
- package/dist/chart-path.js +46 -0
- package/dist/data-table.d.ts +70 -0
- package/dist/data-table.js +46 -0
- package/dist/destructive-dialog.d.ts +28 -0
- package/dist/destructive-dialog.js +74 -0
- package/dist/dns-record-table.d.ts +30 -0
- package/dist/dns-record-table.js +33 -0
- package/dist/error-card.d.ts +23 -0
- package/dist/error-card.js +35 -0
- package/dist/filter-bar.d.ts +50 -0
- package/dist/filter-bar.js +88 -0
- package/dist/log-viewer.d.ts +39 -0
- package/dist/log-viewer.js +114 -0
- package/dist/middle-truncate.d.ts +17 -0
- package/dist/middle-truncate.js +25 -0
- package/dist/sonner.d.ts +20 -1
- package/dist/sonner.js +43 -2
- package/dist/sparkline.d.ts +21 -0
- package/dist/sparkline.js +28 -0
- package/dist/time-chart.js +1 -38
- package/dist/uptime-bar.d.ts +5 -0
- package/dist/uptime-bar.js +33 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,6 +69,22 @@ La barre se réduit en rail (`Ctrl+B`, mémorisé) : poser `sidebarInitScript` d
|
|
|
69
69
|
| `secret-reveal` | `<SecretReveal label="Clé d'API" value={key} onDismiss={forget} />` : affichée une fois, puis retirée de la page |
|
|
70
70
|
| `skeleton` | `<Pending pending={isLoading} fallback={<TableSkeleton columns={3} />}>…</Pending>` : après 200 ms, tenu 400 ms au moins |
|
|
71
71
|
|
|
72
|
+
## Listes, journaux et retours
|
|
73
|
+
|
|
74
|
+
| Import | Exemple |
|
|
75
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
76
|
+
| `filter-bar` | `<FilterBar search={{ value: q, onChange }} filters={[{ key: "status", label: "Statut", options }]} values={{ status }} onFilterChange={…} onReset={…} />` |
|
|
77
|
+
| `data-table` | `<DataTable label="Déploiements" rows={rows} columns={columns} rowKey={(d) => d.id} sort={sort} onSortChange={…} filters={<FilterBar …/>} loadMore={…} />` |
|
|
78
|
+
| `dns-record-table` | `<DnsRecordTable records={[{ type: "MX", name: "send", value, priority: 10, status: "pending" }]} />` (Vérifié, En attente, Échec, À ajouter) |
|
|
79
|
+
| `log-viewer` | `<LogViewer lines={[{ id, time, level: "error", message }]} live />` : niveaux, recherche, « Suivre », copie ; seules les lignes visibles sont rendues |
|
|
80
|
+
| `sparkline` | `<Sparkline label="Envois sur 7 jours" values={[3, 8, 5]} color={2} />` |
|
|
81
|
+
| `middle-truncate` | `<MiddleTruncate text={deployment.url} />` : « …/routes/index.tsx », texte entier dans la page |
|
|
82
|
+
| `error-card` | `<ErrorCard title="Impossible de charger les journaux" detail={message} reference={requestId} onRetry={refetch} />` |
|
|
83
|
+
| `destructive-dialog` | `<DestructiveDialog open={open} onOpenChange={setOpen} title="Supprimer le domaine" question="…" name="acme.ch" onConfirm={remove} />` |
|
|
84
|
+
| `sonner` | `<Toaster />` une fois, puis `toastUndo("Domaine retiré", { onUndo: restore })` ; `errorMessage(error)` pour un toast d'erreur |
|
|
85
|
+
|
|
86
|
+
Filtres, tri et page vivent dans l'URL du produit : les composants sont contrôlés et ne connaissent pas le routeur.
|
|
87
|
+
|
|
72
88
|
## Contrôles et surfaces flottantes
|
|
73
89
|
|
|
74
90
|
| Import | Exemple |
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracés SVG partagés par `TimeChart` et `Sparkline`. Repère de `values.length` unités de large
|
|
3
|
+
* sur 100 de haut : le point `i` est au centre de sa colonne (`i + 0.5`), `min` en bas, `max` en
|
|
4
|
+
* haut. Le SVG s'étire ensuite (`preserveAspectRatio="none"`).
|
|
5
|
+
*/
|
|
6
|
+
/** Une ligne, interrompue là où une valeur manque (`null`). */
|
|
7
|
+
export declare function linePath(values: ReadonlyArray<number | null>, max: number, min?: number): string;
|
|
8
|
+
/** L'aire sous la ligne : une forme par segment continu, fermée sur le bas du repère. */
|
|
9
|
+
export declare function areaPath(values: ReadonlyArray<number | null>, max: number, min?: number): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracés SVG partagés par `TimeChart` et `Sparkline`. Repère de `values.length` unités de large
|
|
3
|
+
* sur 100 de haut : le point `i` est au centre de sa colonne (`i + 0.5`), `min` en bas, `max` en
|
|
4
|
+
* haut. Le SVG s'étire ensuite (`preserveAspectRatio="none"`).
|
|
5
|
+
*/
|
|
6
|
+
function y(value, min, max) {
|
|
7
|
+
const range = max - min;
|
|
8
|
+
return range > 0 ? 100 - ((value - min) / range) * 100 : 50;
|
|
9
|
+
}
|
|
10
|
+
/** Une ligne, interrompue là où une valeur manque (`null`). */
|
|
11
|
+
export function linePath(values, max, min = 0) {
|
|
12
|
+
let d = "";
|
|
13
|
+
let open = false;
|
|
14
|
+
values.forEach((v, i) => {
|
|
15
|
+
if (v === null) {
|
|
16
|
+
open = false;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
d += `${open ? "L" : "M"}${i + 0.5} ${y(v, min, max)}`;
|
|
20
|
+
open = true;
|
|
21
|
+
});
|
|
22
|
+
return d;
|
|
23
|
+
}
|
|
24
|
+
/** L'aire sous la ligne : une forme par segment continu, fermée sur le bas du repère. */
|
|
25
|
+
export function areaPath(values, max, min = 0) {
|
|
26
|
+
const parts = [];
|
|
27
|
+
let start = -1;
|
|
28
|
+
const close = (end) => {
|
|
29
|
+
if (start < 0)
|
|
30
|
+
return;
|
|
31
|
+
const top = values
|
|
32
|
+
.slice(start, end)
|
|
33
|
+
.map((v, j) => `${j === 0 ? "M" : "L"}${start + j + 0.5} ${y(v ?? min, min, max)}`)
|
|
34
|
+
.join("");
|
|
35
|
+
parts.push(`${top}L${end - 1 + 0.5} 100L${start + 0.5} 100Z`);
|
|
36
|
+
start = -1;
|
|
37
|
+
};
|
|
38
|
+
values.forEach((v, i) => {
|
|
39
|
+
if (v === null)
|
|
40
|
+
close(i);
|
|
41
|
+
else if (start < 0)
|
|
42
|
+
start = i;
|
|
43
|
+
});
|
|
44
|
+
close(values.length);
|
|
45
|
+
return parts.join("");
|
|
46
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type SortDirection } from "./table.tsx";
|
|
3
|
+
export type DataTableSort = {
|
|
4
|
+
key: string;
|
|
5
|
+
direction: Exclude<SortDirection, "none">;
|
|
6
|
+
};
|
|
7
|
+
export type DataTableColumn<Row> = {
|
|
8
|
+
/** Identifiant de la colonne, et clé du tri dans l'URL. */
|
|
9
|
+
key: string;
|
|
10
|
+
header: ReactNode;
|
|
11
|
+
cell: (row: Row) => ReactNode;
|
|
12
|
+
/** Chiffres alignés à droite, en `tabular-nums`. */
|
|
13
|
+
numeric?: boolean;
|
|
14
|
+
/** Triable : l'en-tête devient un bouton. */
|
|
15
|
+
sortable?: boolean;
|
|
16
|
+
/** Sens du premier clic : `descending` pour une date ou un volume, `ascending` pour un nom. */
|
|
17
|
+
firstDirection?: DataTableSort["direction"];
|
|
18
|
+
className?: string;
|
|
19
|
+
headClassName?: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Le tri après un clic sur une colonne : une nouvelle colonne part dans son sens premier, la
|
|
23
|
+
* colonne déjà triée change de sens.
|
|
24
|
+
*/
|
|
25
|
+
export declare function nextSort(current: DataTableSort | null | undefined, key: string, firstDirection?: DataTableSort["direction"]): DataTableSort;
|
|
26
|
+
/**
|
|
27
|
+
* Une liste de ressources en tableau, avec ses filtres, son tri et sa pagination, tous contrôlés :
|
|
28
|
+
* l'état vit dans l'URL du produit (tri, page, filtres), le tableau ne fait que l'afficher et
|
|
29
|
+
* rappeler. Construit sur `table` ; pour une mise en page à part, les pièces de `table` restent
|
|
30
|
+
* disponibles.
|
|
31
|
+
*
|
|
32
|
+
* `rows` à `undefined` : chargement, des lignes squelettes à la forme des vraies. `error` remplace
|
|
33
|
+
* le tableau (un `ErrorCard`). Aucune ligne : `empty`, ou `filteredEmpty` quand des filtres sont
|
|
34
|
+
* posés, pour dire que c'est la recherche qui ne trouve rien.
|
|
35
|
+
*
|
|
36
|
+
* Deux paginations : `pagination` (pages numérotées) ou `loadMore` (« Charger plus », comme
|
|
37
|
+
* `usePaginatedQuery` de Convex).
|
|
38
|
+
*/
|
|
39
|
+
export declare function DataTable<Row>({ rows, columns, rowKey, label, sort, onSortChange, filters, filtered, empty, filteredEmpty, error, pagination, loadMore, skeletonRows, surface, className, }: {
|
|
40
|
+
/** `undefined` pendant le chargement. */
|
|
41
|
+
rows: ReadonlyArray<Row> | undefined;
|
|
42
|
+
columns: ReadonlyArray<DataTableColumn<Row>>;
|
|
43
|
+
rowKey: (row: Row) => string;
|
|
44
|
+
/** Nom du tableau pour les lecteurs d'écran : « Déploiements ». */
|
|
45
|
+
label: string;
|
|
46
|
+
sort?: DataTableSort | null;
|
|
47
|
+
onSortChange?: (sort: DataTableSort) => void;
|
|
48
|
+
/** Au-dessus du tableau : un `FilterBar`. */
|
|
49
|
+
filters?: ReactNode;
|
|
50
|
+
/** Des filtres sont posés : l'état vide le dit. */
|
|
51
|
+
filtered?: boolean;
|
|
52
|
+
empty?: ReactNode;
|
|
53
|
+
filteredEmpty?: ReactNode;
|
|
54
|
+
/** À la place du tableau quand le chargement a échoué. */
|
|
55
|
+
error?: ReactNode;
|
|
56
|
+
pagination?: {
|
|
57
|
+
page: number;
|
|
58
|
+
pageCount: number;
|
|
59
|
+
onPageChange: (page: number) => void;
|
|
60
|
+
};
|
|
61
|
+
loadMore?: {
|
|
62
|
+
hasMore: boolean;
|
|
63
|
+
onLoadMore: () => void;
|
|
64
|
+
loading?: boolean;
|
|
65
|
+
label?: string;
|
|
66
|
+
};
|
|
67
|
+
skeletonRows?: number;
|
|
68
|
+
surface?: boolean;
|
|
69
|
+
className?: string;
|
|
70
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import ArrowLeft01Icon from "@hugeicons/core-free-icons/ArrowLeft01Icon";
|
|
3
|
+
import ArrowRight01Icon from "@hugeicons/core-free-icons/ArrowRight01Icon";
|
|
4
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
5
|
+
import { Button } from "./button.js";
|
|
6
|
+
import { cn } from "./cn.js";
|
|
7
|
+
import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow, TableSkeleton, } from "./table.js";
|
|
8
|
+
/**
|
|
9
|
+
* Le tri après un clic sur une colonne : une nouvelle colonne part dans son sens premier, la
|
|
10
|
+
* colonne déjà triée change de sens.
|
|
11
|
+
*/
|
|
12
|
+
export function nextSort(current, key, firstDirection = "ascending") {
|
|
13
|
+
if (current?.key !== key)
|
|
14
|
+
return { key, direction: firstDirection };
|
|
15
|
+
return { key, direction: current.direction === "ascending" ? "descending" : "ascending" };
|
|
16
|
+
}
|
|
17
|
+
function Pagination({ page, pageCount, onPageChange, }) {
|
|
18
|
+
return (_jsxs("nav", { "aria-label": "Pagination", className: "flex items-center justify-between gap-3 px-1 text-sm", children: [_jsxs("p", { className: "text-muted-foreground tabular-nums", "aria-live": "polite", children: ["Page ", page, " sur ", Math.max(pageCount, 1)] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Button, { variant: "outline", size: "sm", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: [_jsx(HugeiconsIcon, { icon: ArrowLeft01Icon, strokeWidth: 2, "aria-hidden": true, "data-icon": "inline-start" }), "Pr\u00E9c\u00E9dente"] }), _jsxs(Button, { variant: "outline", size: "sm", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: ["Suivante", _jsx(HugeiconsIcon, { icon: ArrowRight01Icon, strokeWidth: 2, "aria-hidden": true, "data-icon": "inline-end" })] })] })] }));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Une liste de ressources en tableau, avec ses filtres, son tri et sa pagination, tous contrôlés :
|
|
22
|
+
* l'état vit dans l'URL du produit (tri, page, filtres), le tableau ne fait que l'afficher et
|
|
23
|
+
* rappeler. Construit sur `table` ; pour une mise en page à part, les pièces de `table` restent
|
|
24
|
+
* disponibles.
|
|
25
|
+
*
|
|
26
|
+
* `rows` à `undefined` : chargement, des lignes squelettes à la forme des vraies. `error` remplace
|
|
27
|
+
* le tableau (un `ErrorCard`). Aucune ligne : `empty`, ou `filteredEmpty` quand des filtres sont
|
|
28
|
+
* posés, pour dire que c'est la recherche qui ne trouve rien.
|
|
29
|
+
*
|
|
30
|
+
* Deux paginations : `pagination` (pages numérotées) ou `loadMore` (« Charger plus », comme
|
|
31
|
+
* `usePaginatedQuery` de Convex).
|
|
32
|
+
*/
|
|
33
|
+
export function DataTable({ rows, columns, rowKey, label, sort, onSortChange, filters, filtered = false, empty = "Rien pour l'instant.", filteredEmpty = "Aucun résultat pour ces filtres.", error, pagination, loadMore, skeletonRows = 5, surface = true, className, }) {
|
|
34
|
+
const failed = error !== undefined && error !== null && error !== false;
|
|
35
|
+
const showPagination = pagination !== undefined && rows !== undefined && pagination.pageCount > 1;
|
|
36
|
+
const showLoadMore = loadMore !== undefined && rows !== undefined && (loadMore.hasMore || loadMore.loading === true);
|
|
37
|
+
return (_jsxs("div", { "data-slot": "data-table", className: cn("grid gap-3", className), children: [filters, failed ? (error) : (_jsxs(Table, { "aria-label": label, "aria-busy": rows === undefined || undefined, surface: surface, children: [_jsx(TableHeader, { children: _jsx(TableRow, { children: columns.map((column) => {
|
|
38
|
+
const direction = sort?.key === column.key ? sort.direction : "none";
|
|
39
|
+
return (_jsx(TableHead, { numeric: column.numeric ?? false, className: column.headClassName, ...(column.sortable && onSortChange
|
|
40
|
+
? {
|
|
41
|
+
sort: direction,
|
|
42
|
+
onSort: () => onSortChange(nextSort(sort, column.key, column.firstDirection)),
|
|
43
|
+
}
|
|
44
|
+
: {}), children: column.header }, column.key));
|
|
45
|
+
}) }) }), _jsx(TableBody, { children: rows === undefined ? (_jsx(TableSkeleton, { rows: skeletonRows, columns: columns.length })) : rows.length === 0 ? (_jsx(TableEmpty, { colSpan: columns.length, children: filtered ? filteredEmpty : empty })) : (rows.map((row) => (_jsx(TableRow, { children: columns.map((column) => (_jsx(TableCell, { numeric: column.numeric ?? false, className: column.className, children: column.cell(row) }, column.key))) }, rowKey(row))))) })] })), !failed && showPagination ? _jsx(Pagination, { ...pagination }) : null, !failed && showLoadMore ? (_jsx("div", { className: "flex justify-center", children: _jsx(Button, { variant: "outline", size: "sm", isLoading: loadMore.loading === true, disabled: !loadMore.hasMore, onClick: loadMore.onLoadMore, children: loadMore.label ?? "Charger plus" }) })) : null] }));
|
|
46
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/** Recopié, aux espaces près en début et en fin. */
|
|
3
|
+
export declare function matchesConfirmation(typed: string, name: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Confirmation d'une action qui ne se rattrape pas (supprimer un domaine, un projet, une clé).
|
|
6
|
+
* Avec `name`, il faut le recopier avant que le bouton s'active : on ne supprime pas par
|
|
7
|
+
* réflexe. `Ctrl+Entrée` (`⌘+Entrée` sur Mac) confirme, `Échap` annule.
|
|
8
|
+
*
|
|
9
|
+
* `onConfirm` rend une promesse : le bouton tourne, la boîte ne se ferme qu'une fois l'action
|
|
10
|
+
* faite. Si elle échoue, la boîte reste ouverte et l'erreur s'affiche en toast
|
|
11
|
+
* (`formatError` pour traduire une erreur du serveur). Pour ce qui se défait, `toastUndo`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function DestructiveDialog({ open, onOpenChange, title, question, consequence, name, confirmLabel, onConfirm, formatError, }: {
|
|
14
|
+
open: boolean;
|
|
15
|
+
onOpenChange: (open: boolean) => void;
|
|
16
|
+
/** « Supprimer le domaine » */
|
|
17
|
+
title: string;
|
|
18
|
+
/** « Tu veux vraiment supprimer acme.ch ? » */
|
|
19
|
+
question: ReactNode;
|
|
20
|
+
/** Ce qui disparaît avec, en une phrase. */
|
|
21
|
+
consequence?: ReactNode;
|
|
22
|
+
/** À recopier pour confirmer. Sans lui, un simple bouton suffit. */
|
|
23
|
+
name?: string;
|
|
24
|
+
/** Libellé du bouton, par défaut le titre. */
|
|
25
|
+
confirmLabel?: string;
|
|
26
|
+
onConfirm: () => Promise<unknown>;
|
|
27
|
+
formatError?: (error: unknown) => string;
|
|
28
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Copy01Icon from "@hugeicons/core-free-icons/Copy01Icon";
|
|
3
|
+
import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
|
|
4
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
5
|
+
import { useId, useRef, useState } from "react";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import { Button } from "./button.js";
|
|
8
|
+
import { useCopy } from "./copy-button.js";
|
|
9
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./dialog.js";
|
|
10
|
+
import { Input } from "./input.js";
|
|
11
|
+
import { Kbd, isApplePlatform } from "./kbd.js";
|
|
12
|
+
import { errorMessage } from "./sonner.js";
|
|
13
|
+
/** Le nom à recopier, copiable d'un clic pour qui préfère coller. */
|
|
14
|
+
function NameChip({ name }) {
|
|
15
|
+
const { copied, copy } = useCopy();
|
|
16
|
+
return (_jsxs("button", { type: "button", "aria-label": copied ? "Copié" : `Copier ${name}`, title: copied ? "Copié" : "Copier", onClick: () => void copy(name), className: "inline-flex max-w-full items-center gap-1.5 rounded-lg bg-muted px-2 py-0.5 align-middle font-medium text-foreground outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-foreground/10 focus-visible:ring-2 focus-visible:ring-ring", children: [_jsx("span", { className: "truncate", children: name }), _jsx(HugeiconsIcon, { icon: copied ? Tick02Icon : Copy01Icon, "aria-hidden": true, className: "size-3.5 shrink-0 pop-in" }, copied ? "ok" : "copy")] }));
|
|
17
|
+
}
|
|
18
|
+
/** Recopié, aux espaces près en début et en fin. */
|
|
19
|
+
export function matchesConfirmation(typed, name) {
|
|
20
|
+
return typed.trim() === name.trim();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Confirmation d'une action qui ne se rattrape pas (supprimer un domaine, un projet, une clé).
|
|
24
|
+
* Avec `name`, il faut le recopier avant que le bouton s'active : on ne supprime pas par
|
|
25
|
+
* réflexe. `Ctrl+Entrée` (`⌘+Entrée` sur Mac) confirme, `Échap` annule.
|
|
26
|
+
*
|
|
27
|
+
* `onConfirm` rend une promesse : le bouton tourne, la boîte ne se ferme qu'une fois l'action
|
|
28
|
+
* faite. Si elle échoue, la boîte reste ouverte et l'erreur s'affiche en toast
|
|
29
|
+
* (`formatError` pour traduire une erreur du serveur). Pour ce qui se défait, `toastUndo`.
|
|
30
|
+
*/
|
|
31
|
+
/* oxlint-disable react/refs -- `view` vient d'un ref lu au rendu, voulu (voir plus bas). */
|
|
32
|
+
export function DestructiveDialog({ open, onOpenChange, title, question, consequence, name, confirmLabel, onConfirm, formatError = (error) => errorMessage(error), }) {
|
|
33
|
+
// Le parent vide souvent son état à la fermeture : le dernier contenu ouvert reste affiché le
|
|
34
|
+
// temps de l'animation de sortie. Seul un ref le garde sans rendu supplémentaire.
|
|
35
|
+
const shown = useRef({ title, question, consequence, name, confirmLabel });
|
|
36
|
+
if (open)
|
|
37
|
+
shown.current = { title, question, consequence, name, confirmLabel };
|
|
38
|
+
const view = shown.current;
|
|
39
|
+
const [typed, setTyped] = useState("");
|
|
40
|
+
const [pending, setPending] = useState(false);
|
|
41
|
+
const inputId = useId();
|
|
42
|
+
const ready = !pending && (view.name === undefined || matchesConfirmation(typed, view.name));
|
|
43
|
+
const [wasOpen, setWasOpen] = useState(open);
|
|
44
|
+
if (open !== wasOpen) {
|
|
45
|
+
setWasOpen(open);
|
|
46
|
+
if (!open)
|
|
47
|
+
setTyped("");
|
|
48
|
+
}
|
|
49
|
+
async function confirm() {
|
|
50
|
+
if (!ready)
|
|
51
|
+
return;
|
|
52
|
+
setPending(true);
|
|
53
|
+
try {
|
|
54
|
+
await onConfirm();
|
|
55
|
+
onOpenChange(false);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
toast.error(formatError(error));
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
setPending(false);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const mod = isApplePlatform() ? "⌘" : "Ctrl";
|
|
65
|
+
return (_jsx(Dialog, { open: open, onOpenChange: (next) => !pending && onOpenChange(next), children: _jsxs(DialogContent, { "data-slot": "destructive-dialog", onKeyDown: (event) => {
|
|
66
|
+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
void confirm();
|
|
69
|
+
}
|
|
70
|
+
}, children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: view.title }), _jsxs(DialogDescription, { render: _jsx("div", {}), className: "grid gap-1 text-[15px]", children: [_jsx("p", { children: view.question }), view.consequence ? _jsx("p", { children: view.consequence }) : null, _jsx("p", { className: "font-semibold text-destructive", children: "Impossible de revenir en arri\u00E8re." })] })] }), view.name !== undefined ? (_jsxs("form", { className: "grid gap-3", onSubmit: (event) => {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
void confirm();
|
|
73
|
+
}, children: [_jsxs("label", { htmlFor: inputId, className: "text-[15px] leading-7 text-muted-foreground", children: ["Tape ", _jsx(NameChip, { name: view.name }), " pour confirmer."] }), _jsx(Input, { id: inputId, autoFocus: true, autoComplete: "off", autoCapitalize: "off", spellCheck: false, value: typed, onChange: (event) => setTyped(event.target.value), placeholder: view.name, "aria-invalid": typed.length > 0 && !view.name.startsWith(typed) ? true : undefined })] })) : null, _jsxs(DialogFooter, { className: "sm:justify-start", children: [_jsxs(Button, { variant: "destructive", isLoading: pending, disabled: !ready, onClick: () => void confirm(), children: [view.confirmLabel ?? view.title, _jsx(Kbd, { "aria-hidden": true, className: "ml-1 hidden bg-current/10 text-current lg:inline-flex", children: mod }), _jsx(Kbd, { "aria-hidden": true, className: "hidden bg-current/10 text-current lg:inline-flex", children: "\u21B5" })] }), _jsxs(Button, { variant: "outline", disabled: pending, onClick: () => onOpenChange(false), children: ["Annuler", _jsx(Kbd, { "aria-hidden": true, className: "ml-1 hidden lg:inline-flex", children: "\u00C9chap" })] })] })] }) }));
|
|
74
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Où en est un enregistrement : vu chez le fournisseur DNS ou pas encore. */
|
|
2
|
+
export type DnsRecordStatus = "verified" | "pending" | "failed" | "not_started";
|
|
3
|
+
export declare const DNS_RECORD_STATUS_LABEL: Record<DnsRecordStatus, string>;
|
|
4
|
+
export type DnsRecord = {
|
|
5
|
+
/** Clé de ligne ; à défaut, type, nom et valeur. */
|
|
6
|
+
id?: string;
|
|
7
|
+
/** `TXT`, `MX`, `CNAME`… */
|
|
8
|
+
type: string;
|
|
9
|
+
/** Tel qu'il se saisit chez le fournisseur : `send`, `resend._domainkey`, `@`. */
|
|
10
|
+
name: string;
|
|
11
|
+
value: string;
|
|
12
|
+
priority?: number;
|
|
13
|
+
/** « Auto », « 3600 ». */
|
|
14
|
+
ttl?: string;
|
|
15
|
+
status?: DnsRecordStatus;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Les enregistrements à poser chez le fournisseur DNS : type, nom et valeur à copier, et le
|
|
19
|
+
* statut de chacun (point et libellé). Une valeur longue (DKIM) est coupée au milieu, mais le
|
|
20
|
+
* bouton copie toujours la valeur entière. Priorité et TTL n'ont leur colonne que si une ligne
|
|
21
|
+
* en a. Sur téléphone, le tableau défile de côté dans sa carte.
|
|
22
|
+
*/
|
|
23
|
+
export declare function DnsRecordTable({ records, label, surface, className, }: {
|
|
24
|
+
records: Array<DnsRecord>;
|
|
25
|
+
/** Nom du tableau pour les lecteurs d'écran. */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** `false` dans une carte qui a déjà sa teinte. */
|
|
28
|
+
surface?: boolean;
|
|
29
|
+
className?: string;
|
|
30
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "./cn.js";
|
|
3
|
+
import { CopyButton } from "./copy-button.js";
|
|
4
|
+
import { MiddleTruncate } from "./middle-truncate.js";
|
|
5
|
+
import { StatusBadge } from "./status.js";
|
|
6
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table.js";
|
|
7
|
+
export const DNS_RECORD_STATUS_LABEL = {
|
|
8
|
+
verified: "Vérifié",
|
|
9
|
+
pending: "En attente",
|
|
10
|
+
failed: "Échec",
|
|
11
|
+
not_started: "À ajouter",
|
|
12
|
+
};
|
|
13
|
+
const STATUS_TONE = {
|
|
14
|
+
verified: "operational",
|
|
15
|
+
pending: "degraded",
|
|
16
|
+
failed: "outage",
|
|
17
|
+
not_started: "unknown",
|
|
18
|
+
};
|
|
19
|
+
function CopyableValue({ value, label }) {
|
|
20
|
+
return (_jsxs("span", { className: "flex min-w-0 items-center gap-1", children: [_jsx(MiddleTruncate, { text: value, tail: 10, className: "font-mono text-[13px]" }), _jsx(CopyButton, { value: value, label: label, className: "size-7" })] }));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Les enregistrements à poser chez le fournisseur DNS : type, nom et valeur à copier, et le
|
|
24
|
+
* statut de chacun (point et libellé). Une valeur longue (DKIM) est coupée au milieu, mais le
|
|
25
|
+
* bouton copie toujours la valeur entière. Priorité et TTL n'ont leur colonne que si une ligne
|
|
26
|
+
* en a. Sur téléphone, le tableau défile de côté dans sa carte.
|
|
27
|
+
*/
|
|
28
|
+
export function DnsRecordTable({ records, label = "Enregistrements DNS", surface = true, className, }) {
|
|
29
|
+
const hasPriority = records.some((r) => r.priority !== undefined);
|
|
30
|
+
const hasTtl = records.some((r) => r.ttl !== undefined);
|
|
31
|
+
const hasStatus = records.some((r) => r.status !== undefined);
|
|
32
|
+
return (_jsxs(Table, { "aria-label": label, surface: surface, "data-slot": "dns-record-table", className: cn("min-w-[44rem] table-fixed", className), children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { className: "w-20", children: "Type" }), _jsx(TableHead, { className: "w-[26%]", children: "Nom" }), _jsx(TableHead, { children: "Valeur" }), hasPriority ? (_jsx(TableHead, { numeric: true, className: "w-24", children: "Priorit\u00E9" })) : null, hasTtl ? _jsx(TableHead, { className: "w-20", children: "TTL" }) : null, hasStatus ? _jsx(TableHead, { className: "w-36", children: "Statut" }) : null] }) }), _jsx(TableBody, { children: records.map((record) => (_jsxs(TableRow, { children: [_jsx(TableCell, { className: "font-mono text-[13px] font-medium", children: record.type }), _jsx(TableCell, { children: _jsx(CopyableValue, { value: record.name, label: `Copier le nom ${record.name}` }) }), _jsx(TableCell, { children: _jsx(CopyableValue, { value: record.value, label: `Copier la valeur ${record.type}` }) }), hasPriority ? _jsx(TableCell, { numeric: true, children: record.priority ?? "" }) : null, hasTtl ? _jsx(TableCell, { className: "text-muted-foreground tabular-nums", children: record.ttl ?? "" }) : null, hasStatus ? (_jsx(TableCell, { children: record.status ? (_jsx(StatusBadge, { status: STATUS_TONE[record.status], label: DNS_RECORD_STATUS_LABEL[record.status], live: record.status === "pending" })) : null })) : null] }, record.id ?? `${record.type} ${record.name} ${record.value}`))) })] }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Ce qui a échoué, quoi faire, et de quoi le signaler. À la place du contenu qui n'a pas pu se
|
|
4
|
+
* charger (une liste, un graphique), pas en plus : l'écran ne reste jamais blanc ni muet.
|
|
5
|
+
*
|
|
6
|
+
* Le détail technique (message d'origine, pile) est replié : utile au support, illisible pour
|
|
7
|
+
* le reste. L'identifiant se copie d'un clic, pour l'e-mail au support. `onRetry` peut rendre une
|
|
8
|
+
* promesse : le bouton tourne jusqu'à ce qu'elle se règle.
|
|
9
|
+
*/
|
|
10
|
+
export declare function ErrorCard({ title, children, detail, reference, onRetry, retryLabel, action, className, }: {
|
|
11
|
+
title?: ReactNode;
|
|
12
|
+
/** Ce que la personne peut faire, en une ou deux phrases. */
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
/** Le message technique, montré sous « Détails » en chasse fixe. */
|
|
15
|
+
detail?: string;
|
|
16
|
+
/** Identifiant de la requête ou de l'erreur, à transmettre au support. */
|
|
17
|
+
reference?: string;
|
|
18
|
+
onRetry?: () => unknown;
|
|
19
|
+
retryLabel?: string;
|
|
20
|
+
/** Une autre issue, à côté de « Réessayer » : un lien vers la page de statut. */
|
|
21
|
+
action?: ReactNode;
|
|
22
|
+
className?: string;
|
|
23
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Collapsible } from "@base-ui/react/collapsible";
|
|
3
|
+
import AlertCircleIcon from "@hugeicons/core-free-icons/AlertCircleIcon";
|
|
4
|
+
import ArrowRight01Icon from "@hugeicons/core-free-icons/ArrowRight01Icon";
|
|
5
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { Button } from "./button.js";
|
|
8
|
+
import { cn } from "./cn.js";
|
|
9
|
+
import { CopyButton } from "./copy-button.js";
|
|
10
|
+
/**
|
|
11
|
+
* Ce qui a échoué, quoi faire, et de quoi le signaler. À la place du contenu qui n'a pas pu se
|
|
12
|
+
* charger (une liste, un graphique), pas en plus : l'écran ne reste jamais blanc ni muet.
|
|
13
|
+
*
|
|
14
|
+
* Le détail technique (message d'origine, pile) est replié : utile au support, illisible pour
|
|
15
|
+
* le reste. L'identifiant se copie d'un clic, pour l'e-mail au support. `onRetry` peut rendre une
|
|
16
|
+
* promesse : le bouton tourne jusqu'à ce qu'elle se règle.
|
|
17
|
+
*/
|
|
18
|
+
export function ErrorCard({ title = "Le chargement a échoué", children, detail, reference, onRetry, retryLabel = "Réessayer", action, className, }) {
|
|
19
|
+
const [retrying, setRetrying] = useState(false);
|
|
20
|
+
async function retry() {
|
|
21
|
+
if (!onRetry)
|
|
22
|
+
return;
|
|
23
|
+
setRetrying(true);
|
|
24
|
+
try {
|
|
25
|
+
await onRetry();
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
setRetrying(false);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return (_jsxs("div", { role: "alert", "data-slot": "error-card", className: cn("surface flex flex-col gap-4 rounded-3xl p-5 text-sm sm:p-6", className), children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsx(HugeiconsIcon, { icon: AlertCircleIcon, strokeWidth: 2, "aria-hidden": true, className: "mt-0.5 size-5 shrink-0 text-destructive" }), _jsxs("div", { className: "grid min-w-0 gap-1", children: [_jsx("p", { className: "font-heading text-base font-medium text-foreground", children: title }), _jsx("div", { className: "text-pretty text-muted-foreground", children: children ??
|
|
32
|
+
(reference
|
|
33
|
+
? "Réessaie dans un instant. Si ça continue, écris-nous avec l'identifiant ci-dessous."
|
|
34
|
+
: "Réessaie dans un instant.") })] })] }), reference ? (_jsxs("div", { className: "flex min-w-0 items-center gap-2 pl-8 text-muted-foreground", children: [_jsx("span", { className: "shrink-0", children: "Identifiant" }), _jsx("code", { className: "min-w-0 truncate rounded-md bg-foreground/[0.05] px-1.5 py-0.5 font-mono text-xs text-foreground", children: reference }), _jsx(CopyButton, { value: reference, label: "Copier l'identifiant" })] })) : null, detail ? (_jsxs(Collapsible.Root, { className: "pl-8", children: [_jsxs(Collapsible.Trigger, { className: "group -mx-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-1 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: ArrowRight01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-3.5 transition-transform duration-(--duration-fast) ease-out group-data-panel-open:rotate-90" }), "D\u00E9tails"] }), _jsx(Collapsible.Panel, { className: "h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-(--duration-base) ease-out data-ending-style:h-0 data-starting-style:h-0 [&[hidden]:not([hidden='until-found'])]:hidden", children: _jsx("pre", { className: "mt-2 max-h-60 overflow-auto rounded-2xl bg-foreground/[0.05] p-3 font-mono text-xs whitespace-pre-wrap break-all text-foreground", children: detail }) })] })) : null, onRetry || action ? (_jsxs("div", { className: "flex flex-wrap items-center gap-2 pl-8", children: [onRetry ? (_jsx(Button, { variant: "outline", size: "sm", isLoading: retrying, onClick: () => void retry(), children: retryLabel })) : null, action] })) : null] }));
|
|
35
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type FilterOption = {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
};
|
|
6
|
+
export type FilterDefinition = {
|
|
7
|
+
/** Nom du paramètre d'URL : `status`, `environment`. */
|
|
8
|
+
key: string;
|
|
9
|
+
/** « Statut » */
|
|
10
|
+
label: string;
|
|
11
|
+
options: Array<FilterOption>;
|
|
12
|
+
/** Une seule valeur à la fois (période, environnement). Par défaut, plusieurs. */
|
|
13
|
+
single?: boolean;
|
|
14
|
+
};
|
|
15
|
+
/** Valeurs choisies par filtre, telles qu'elles vivent dans l'URL : `{ status: ["error"] }`. */
|
|
16
|
+
export type FilterValues = Readonly<Record<string, ReadonlyArray<string> | undefined>>;
|
|
17
|
+
/** Le texte de la pastille : « Statut », « Statut : Erreur », « Statut : Erreur +2 ». */
|
|
18
|
+
export declare function filterSummary(filter: FilterDefinition, selected?: ReadonlyArray<string>): string;
|
|
19
|
+
/** Nombre de critères posés, recherche comprise : sert à afficher « Effacer » et l'état vide. */
|
|
20
|
+
export declare function activeFilterCount(values?: FilterValues, search?: string): number;
|
|
21
|
+
/** Ajoute ou retire une valeur d'un filtre à choix multiple, dans l'ordre des options. */
|
|
22
|
+
export declare function toggleFilterValue(filter: FilterDefinition, selected: ReadonlyArray<string> | undefined, value: string, checked: boolean): Array<string>;
|
|
23
|
+
/**
|
|
24
|
+
* Recherche et filtres en pastilles au-dessus d'une liste. Entièrement contrôlée, sans routeur :
|
|
25
|
+
* le produit lit les valeurs dans les paramètres d'URL et les y réécrit dans les callbacks, pour
|
|
26
|
+
* que le partage, le rafraîchissement et le retour arrière gardent la vue.
|
|
27
|
+
*
|
|
28
|
+
* La recherche n'écrit qu'après 250 ms de calme (Entrée pour tout de suite, Échap pour vider).
|
|
29
|
+
* Un filtre à choix multiple reste ouvert pendant qu'on coche ; `single` en fait un choix unique
|
|
30
|
+
* avec « Tous ». « Effacer » apparaît dès qu'un critère est posé.
|
|
31
|
+
*/
|
|
32
|
+
export declare function FilterBar({ search, filters, values, onFilterChange, onReset, actions, className, }: {
|
|
33
|
+
search?: {
|
|
34
|
+
value: string;
|
|
35
|
+
onChange: (value: string) => void;
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/** Nom du champ pour les lecteurs d'écran, par défaut « Rechercher ». */
|
|
38
|
+
label?: string;
|
|
39
|
+
/** Délai avant d'écrire la recherche, en millisecondes. */
|
|
40
|
+
delay?: number;
|
|
41
|
+
};
|
|
42
|
+
filters?: Array<FilterDefinition>;
|
|
43
|
+
values?: FilterValues;
|
|
44
|
+
onFilterChange?: (key: string, values: Array<string>) => void;
|
|
45
|
+
/** Retire tous les critères, recherche comprise. */
|
|
46
|
+
onReset?: () => void;
|
|
47
|
+
/** À droite : la période, un bouton d'export. */
|
|
48
|
+
actions?: ReactNode;
|
|
49
|
+
className?: string;
|
|
50
|
+
}): React.JSX.Element;
|
|
@@ -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
|
+
}
|
package/dist/sonner.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
1
|
import { type ToasterProps } from "sonner";
|
|
2
2
|
declare function Toaster(props: ToasterProps): React.JSX.Element;
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Retour d'une action réversible, avec « Annuler » pendant quelques secondes : on agit tout de
|
|
5
|
+
* suite (mise à jour optimiste), sans boîte de confirmation, et on laisse revenir en arrière.
|
|
6
|
+
* Une action qui ne se rattrape pas passe par `DestructiveDialog`.
|
|
7
|
+
*
|
|
8
|
+
* `onUndo` défait l'action. `onCommit`, s'il est donné, part quand le toast se ferme sans
|
|
9
|
+
* annulation (délai écoulé, fermé à la main) : pour une action différée, envoyée seulement
|
|
10
|
+
* une fois le délai passé. Rend l'identifiant du toast (`toast.dismiss(id)`).
|
|
11
|
+
*/
|
|
12
|
+
declare function toastUndo(message: React.ReactNode, { onUndo, onCommit, description, duration, undoLabel, }: {
|
|
13
|
+
onUndo: () => unknown;
|
|
14
|
+
onCommit?: () => unknown;
|
|
15
|
+
description?: React.ReactNode;
|
|
16
|
+
/** En millisecondes ; Sonner met le délai en pause au survol et quand l'onglet est caché. */
|
|
17
|
+
duration?: number;
|
|
18
|
+
undoLabel?: string;
|
|
19
|
+
}): string | number;
|
|
20
|
+
/** Le message d'une erreur, lisible : celui de l'`Error`, sinon une phrase générique. */
|
|
21
|
+
declare function errorMessage(error: unknown, fallback?: string): string;
|
|
22
|
+
export { Toaster, toastUndo, errorMessage };
|
package/dist/sonner.js
CHANGED
|
@@ -4,7 +4,7 @@ import CheckmarkCircle02Icon from "@hugeicons/core-free-icons/CheckmarkCircle02I
|
|
|
4
4
|
import InformationCircleIcon from "@hugeicons/core-free-icons/InformationCircleIcon";
|
|
5
5
|
import MultiplicationSignCircleIcon from "@hugeicons/core-free-icons/MultiplicationSignCircleIcon";
|
|
6
6
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
7
|
-
import { Toaster as Sonner } from "sonner";
|
|
7
|
+
import { Toaster as Sonner, toast } from "sonner";
|
|
8
8
|
import { Spinner } from "./spinner.js";
|
|
9
9
|
import { useTheme } from "./theme.js";
|
|
10
10
|
function Toaster(props) {
|
|
@@ -30,4 +30,45 @@ function Toaster(props) {
|
|
|
30
30
|
},
|
|
31
31
|
}, ...props }));
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Retour d'une action réversible, avec « Annuler » pendant quelques secondes : on agit tout de
|
|
35
|
+
* suite (mise à jour optimiste), sans boîte de confirmation, et on laisse revenir en arrière.
|
|
36
|
+
* Une action qui ne se rattrape pas passe par `DestructiveDialog`.
|
|
37
|
+
*
|
|
38
|
+
* `onUndo` défait l'action. `onCommit`, s'il est donné, part quand le toast se ferme sans
|
|
39
|
+
* annulation (délai écoulé, fermé à la main) : pour une action différée, envoyée seulement
|
|
40
|
+
* une fois le délai passé. Rend l'identifiant du toast (`toast.dismiss(id)`).
|
|
41
|
+
*/
|
|
42
|
+
function toastUndo(message, { onUndo, onCommit, description, duration = 6000, undoLabel = "Annuler", }) {
|
|
43
|
+
let settled = false;
|
|
44
|
+
const commit = () => {
|
|
45
|
+
if (settled)
|
|
46
|
+
return;
|
|
47
|
+
settled = true;
|
|
48
|
+
void onCommit?.();
|
|
49
|
+
};
|
|
50
|
+
return toast(message, {
|
|
51
|
+
description,
|
|
52
|
+
duration,
|
|
53
|
+
action: {
|
|
54
|
+
label: undoLabel,
|
|
55
|
+
onClick: () => {
|
|
56
|
+
if (settled)
|
|
57
|
+
return;
|
|
58
|
+
settled = true;
|
|
59
|
+
void onUndo();
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
onAutoClose: commit,
|
|
63
|
+
onDismiss: commit,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** Le message d'une erreur, lisible : celui de l'`Error`, sinon une phrase générique. */
|
|
67
|
+
function errorMessage(error, fallback = "Quelque chose n'a pas marché. Réessaie.") {
|
|
68
|
+
if (error instanceof Error && error.message.trim() !== "")
|
|
69
|
+
return error.message;
|
|
70
|
+
if (typeof error === "string" && error.trim() !== "")
|
|
71
|
+
return error;
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
export { Toaster, toastUndo, errorMessage };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Ce que la courbe dit, en une phrase, pour qui ne la voit pas. */
|
|
2
|
+
export declare function describeSparkline(label: string, values: ReadonlyArray<number | null>, format?: (value: number) => string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Tendance en un trait, sans axe ni info-bulle, à côté d'un chiffre (ligne de tableau, `StatTile`).
|
|
5
|
+
* L'échelle va du minimum au maximum de la série : c'est la forme qui compte, pas le zéro. Pour lire
|
|
6
|
+
* des valeurs, c'est `TimeChart`. Les lecteurs d'écran entendent l'étendue et la dernière valeur.
|
|
7
|
+
*/
|
|
8
|
+
export declare function Sparkline({ values, label, color, area, height, formatValue, className, }: {
|
|
9
|
+
/** Du plus ancien au plus récent ; `null` : pas de mesure, le trait s'interrompt. */
|
|
10
|
+
values: ReadonlyArray<number | null>;
|
|
11
|
+
/** Ce que mesure la courbe : « Envois sur 7 jours ». */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Teinte `--chart-1` à `--chart-6`. */
|
|
14
|
+
color?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
15
|
+
/** Lavis sous la courbe. */
|
|
16
|
+
area?: boolean;
|
|
17
|
+
/** En pixels ; la largeur suit le conteneur. */
|
|
18
|
+
height?: number;
|
|
19
|
+
formatValue?: (value: number) => string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { areaPath, linePath } from "./chart-path.js";
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { CHART_COLORS } from "./time-chart.js";
|
|
5
|
+
const number = new Intl.NumberFormat("fr-CH");
|
|
6
|
+
/** Ce que la courbe dit, en une phrase, pour qui ne la voit pas. */
|
|
7
|
+
export function describeSparkline(label, values, format = (v) => number.format(v)) {
|
|
8
|
+
const known = values.filter((v) => v !== null);
|
|
9
|
+
const last = known.at(-1);
|
|
10
|
+
if (last === undefined)
|
|
11
|
+
return `${label} : aucune donnée`;
|
|
12
|
+
return `${label} : de ${format(Math.min(...known))} à ${format(Math.max(...known))}, dernière valeur ${format(last)}`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Tendance en un trait, sans axe ni info-bulle, à côté d'un chiffre (ligne de tableau, `StatTile`).
|
|
16
|
+
* L'échelle va du minimum au maximum de la série : c'est la forme qui compte, pas le zéro. Pour lire
|
|
17
|
+
* des valeurs, c'est `TimeChart`. Les lecteurs d'écran entendent l'étendue et la dernière valeur.
|
|
18
|
+
*/
|
|
19
|
+
export function Sparkline({ values, label, color = 1, area = true, height = 32, formatValue, className, }) {
|
|
20
|
+
const known = values.filter((v) => v !== null);
|
|
21
|
+
const min = Math.min(...known);
|
|
22
|
+
const max = Math.max(...known);
|
|
23
|
+
const stroke = CHART_COLORS[color - 1] ?? "currentColor";
|
|
24
|
+
const description = describeSparkline(label, values, formatValue);
|
|
25
|
+
// Une série plate se dessine au milieu ; une marge en haut et en bas garde le trait entier.
|
|
26
|
+
const [lo, hi] = max > min ? [min, max] : [min - 1, max + 1];
|
|
27
|
+
return (_jsx("span", { "data-slot": "sparkline", role: "img", "aria-label": description, style: { height }, className: cn("inline-block w-24 min-w-0 align-middle", className), children: known.length > 0 ? (_jsxs("svg", { "aria-hidden": true, viewBox: `0 -6 ${values.length} 112`, preserveAspectRatio: "none", className: "block size-full overflow-visible", children: [area ? _jsx("path", { d: areaPath(values, hi, lo), fill: stroke, opacity: 0.12 }) : null, _jsx("path", { d: linePath(values, hi, lo), fill: "none", stroke: stroke, strokeWidth: 1.5, strokeLinejoin: "round", strokeLinecap: "round", vectorEffect: "non-scaling-stroke" })] })) : (_jsx("span", { "aria-hidden": true, className: "flex size-full items-center", children: _jsx("span", { className: "h-px w-full bg-foreground/10" }) })) }));
|
|
28
|
+
}
|
package/dist/time-chart.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
+
import { areaPath, linePath } from "./chart-path.js";
|
|
3
4
|
import { cn } from "./cn.js";
|
|
4
5
|
/** Six teintes de la DA (`--chart-1` à `--chart-6`), dans l'ordre, jamais recyclées. */
|
|
5
6
|
export const CHART_COLORS = [
|
|
@@ -26,44 +27,6 @@ export function niceScale(max, ticks = 4) {
|
|
|
26
27
|
const step = nice * magnitude;
|
|
27
28
|
return { max: Math.ceil(max / step) * step, step };
|
|
28
29
|
}
|
|
29
|
-
function linePath(values, max) {
|
|
30
|
-
let d = "";
|
|
31
|
-
let open = false;
|
|
32
|
-
values.forEach((v, i) => {
|
|
33
|
-
if (v === null) {
|
|
34
|
-
open = false;
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
const x = i + 0.5;
|
|
38
|
-
const y = 100 - (v / max) * 100;
|
|
39
|
-
d += `${open ? "L" : "M"}${x} ${y}`;
|
|
40
|
-
open = true;
|
|
41
|
-
});
|
|
42
|
-
return d;
|
|
43
|
-
}
|
|
44
|
-
function areaPath(values, max) {
|
|
45
|
-
// Une aire par segment continu, fermée sur l'axe.
|
|
46
|
-
const parts = [];
|
|
47
|
-
let start = -1;
|
|
48
|
-
const close = (end) => {
|
|
49
|
-
if (start < 0)
|
|
50
|
-
return;
|
|
51
|
-
const segment = values.slice(start, end);
|
|
52
|
-
const top = segment
|
|
53
|
-
.map((v, j) => `${j === 0 ? "M" : "L"}${start + j + 0.5} ${100 - ((v ?? 0) / max) * 100}`)
|
|
54
|
-
.join("");
|
|
55
|
-
parts.push(`${top}L${end - 1 + 0.5} 100L${start + 0.5} 100Z`);
|
|
56
|
-
start = -1;
|
|
57
|
-
};
|
|
58
|
-
values.forEach((v, i) => {
|
|
59
|
-
if (v === null)
|
|
60
|
-
close(i);
|
|
61
|
-
else if (start < 0)
|
|
62
|
-
start = i;
|
|
63
|
-
});
|
|
64
|
-
close(values.length);
|
|
65
|
-
return parts.join("");
|
|
66
|
-
}
|
|
67
30
|
/**
|
|
68
31
|
* Série temporelle en SVG, sans bibliothèque : lignes (aire sous la courbe pour une série seule)
|
|
69
32
|
* ou barres empilées. Le SVG s'étire à la largeur de son conteneur ; les traits gardent 2 px
|
package/dist/uptime-bar.d.ts
CHANGED
|
@@ -9,10 +9,15 @@ export type UptimeDay = {
|
|
|
9
9
|
/** Ce qui s'est passé : « Panne de 12 min, base de données ». */
|
|
10
10
|
note?: string;
|
|
11
11
|
};
|
|
12
|
+
/** Le jour à atteindre pour une touche, ou `null` si la touche ne déplace pas. */
|
|
13
|
+
export declare function nextDay(key: string, current: number, count: number): number | null;
|
|
12
14
|
/**
|
|
13
15
|
* Une barre par jour, du plus ancien au plus récent, comme les pages de statut de référence.
|
|
14
16
|
* Chaque barre a sa couleur et, au survol, une infobulle en texte (jour, statut, disponibilité,
|
|
15
17
|
* note). Les lecteurs d'écran lisent la liste des jours, dont chacun dit son statut en mots.
|
|
18
|
+
*
|
|
19
|
+
* Au clavier, la barre est un seul arrêt de tabulation (le dernier jour visité, aujourd'hui au
|
|
20
|
+
* départ) ; les flèches, Début et Fin passent d'un jour à l'autre et ouvrent son infobulle.
|
|
16
21
|
*/
|
|
17
22
|
export declare function UptimeBar({ days, label, summary, className, }: {
|
|
18
23
|
days: Array<UptimeDay>;
|
package/dist/uptime-bar.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState } from "react";
|
|
2
3
|
import { SERVICE_STATUS_LABEL } from "./status.js";
|
|
3
4
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
|
|
4
5
|
import { cn } from "./cn.js";
|
|
@@ -15,11 +16,42 @@ function describe(entry) {
|
|
|
15
16
|
.filter(Boolean)
|
|
16
17
|
.join(" · ");
|
|
17
18
|
}
|
|
19
|
+
/** Le jour à atteindre pour une touche, ou `null` si la touche ne déplace pas. */
|
|
20
|
+
export function nextDay(key, current, count) {
|
|
21
|
+
if (count === 0)
|
|
22
|
+
return null;
|
|
23
|
+
switch (key) {
|
|
24
|
+
case "ArrowLeft":
|
|
25
|
+
return Math.max(0, current - 1);
|
|
26
|
+
case "ArrowRight":
|
|
27
|
+
return Math.min(count - 1, current + 1);
|
|
28
|
+
case "Home":
|
|
29
|
+
return 0;
|
|
30
|
+
case "End":
|
|
31
|
+
return count - 1;
|
|
32
|
+
default:
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
18
36
|
/**
|
|
19
37
|
* Une barre par jour, du plus ancien au plus récent, comme les pages de statut de référence.
|
|
20
38
|
* Chaque barre a sa couleur et, au survol, une infobulle en texte (jour, statut, disponibilité,
|
|
21
39
|
* note). Les lecteurs d'écran lisent la liste des jours, dont chacun dit son statut en mots.
|
|
40
|
+
*
|
|
41
|
+
* Au clavier, la barre est un seul arrêt de tabulation (le dernier jour visité, aujourd'hui au
|
|
42
|
+
* départ) ; les flèches, Début et Fin passent d'un jour à l'autre et ouvrent son infobulle.
|
|
22
43
|
*/
|
|
23
44
|
export function UptimeBar({ days, label = "Disponibilité", summary, className, }) {
|
|
24
|
-
|
|
45
|
+
const [focused, setFocused] = useState(null);
|
|
46
|
+
const list = useRef(null);
|
|
47
|
+
const current = Math.min(focused ?? days.length - 1, days.length - 1);
|
|
48
|
+
function onKeyDown(event) {
|
|
49
|
+
const target = nextDay(event.key, current, days.length);
|
|
50
|
+
if (target === null)
|
|
51
|
+
return;
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
setFocused(target);
|
|
54
|
+
list.current?.querySelectorAll("[data-slot=uptime-day]")[target]?.focus();
|
|
55
|
+
}
|
|
56
|
+
return (_jsxs("div", { "data-slot": "uptime-bar", className: cn("grid gap-2", className), children: [_jsx("ol", { ref: list, "aria-label": label, className: "flex h-8 items-stretch gap-[2px]", children: days.map((entry, index) => (_jsx("li", { className: "flex min-w-0 flex-1", children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("button", { type: "button" }), "data-slot": "uptime-day", tabIndex: index === current ? 0 : -1, onFocus: () => setFocused(index), onKeyDown: onKeyDown, className: cn("block w-full rounded-[3px] outline-none transition-opacity duration-(--duration-fast) ease-out hover:opacity-70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-popup-open:opacity-70", BAR[entry.status]), children: _jsx("span", { className: "sr-only", children: describe(entry) }) }), _jsx(TooltipContent, { children: describe(entry) })] }) }, new Date(entry.date).toISOString()))) }), _jsxs("div", { "aria-hidden": true, className: "flex items-center justify-between gap-2 text-xs text-muted-foreground tabular-nums", children: [_jsx("span", { children: days.length > 0 ? `il y a ${days.length} jours` : null }), summary ? _jsx("span", { className: "text-foreground", children: summary }) : null, _jsx("span", { children: "Aujourd'hui" })] })] }));
|
|
25
57
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Interface de la H family : jetons de la DA, thèmes, polices et composants.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",
|