@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.
- package/README.md +56 -24
- package/dist/account-menu.d.ts +15 -2
- package/dist/account-menu.js +8 -2
- package/dist/address-fields.d.ts +50 -0
- package/dist/address-fields.js +79 -0
- package/dist/chart-path.d.ts +9 -0
- package/dist/chart-path.js +46 -0
- package/dist/color-field.d.ts +32 -0
- package/dist/color-field.js +80 -0
- package/dist/command-menu.d.ts +4 -2
- package/dist/command-menu.js +5 -3
- package/dist/copy-button.d.ts +5 -2
- package/dist/copy-button.js +5 -2
- package/dist/data-table.d.ts +70 -0
- package/dist/data-table.js +46 -0
- package/dist/date-range-picker.d.ts +50 -0
- package/dist/date-range-picker.js +118 -0
- package/dist/date-time-field.d.ts +18 -0
- package/dist/date-time-field.js +31 -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/money-field.d.ts +31 -0
- package/dist/money-field.js +98 -0
- package/dist/products.d.ts +44 -0
- package/dist/products.js +47 -0
- package/dist/progress.d.ts +38 -0
- package/dist/progress.js +31 -0
- package/dist/rank-bar.d.ts +28 -0
- package/dist/rank-bar.js +31 -0
- package/dist/settings-section.d.ts +40 -0
- package/dist/settings-section.js +48 -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/status.d.ts +31 -8
- package/dist/status.js +19 -3
- package/dist/time-chart.d.ts +7 -1
- package/dist/time-chart.js +7 -42
- package/dist/uptime-bar.d.ts +5 -0
- package/dist/uptime-bar.js +33 -1
- package/package.json +1 -1
- package/styles.css +19 -0
|
@@ -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,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Une période en dates civiles, bornes comprises : `{ from: "2026-10-01", to: "2026-10-15" }`.
|
|
3
|
+
* Des chaînes ISO plutôt que des `Date` : une journée n'a pas de fuseau, et la valeur passe telle
|
|
4
|
+
* quelle dans l'URL du produit.
|
|
5
|
+
*/
|
|
6
|
+
export type DateRange = {
|
|
7
|
+
from: string;
|
|
8
|
+
to: string;
|
|
9
|
+
};
|
|
10
|
+
export type DateRangePreset = {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
/** La période, calculée depuis la date du jour (ISO). */
|
|
14
|
+
range: (today: string) => DateRange;
|
|
15
|
+
};
|
|
16
|
+
/** Une date ISO (`2026-10-15`) en `Date` locale à minuit, ou `null` si elle n'existe pas (31 février). */
|
|
17
|
+
export declare function parseIsoDate(value: string): Date | null;
|
|
18
|
+
/** Une `Date` en date ISO, dans le fuseau du navigateur. */
|
|
19
|
+
export declare function toIsoDate(date: Date): string;
|
|
20
|
+
/** Périodes proposées par défaut : 7, 30 et 90 jours, ce mois-ci, le mois dernier. */
|
|
21
|
+
export declare const DEFAULT_DATE_RANGE_PRESETS: ReadonlyArray<DateRangePreset>;
|
|
22
|
+
/**
|
|
23
|
+
* « 1 au 15 oct. 2026 », « 28 sept. au 4 oct. 2026 » : le format de période d'`Intl`, son tiret
|
|
24
|
+
* remplacé par « au », plus lisible et sans tiret long dans l'interface.
|
|
25
|
+
*/
|
|
26
|
+
export declare function formatDateRange(range: DateRange): string;
|
|
27
|
+
/** Erreur d'une période saisie, ou `null` si elle tient. */
|
|
28
|
+
export declare function validateDateRange(range: DateRange, bounds?: {
|
|
29
|
+
min?: string;
|
|
30
|
+
max?: string;
|
|
31
|
+
}): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Choix d'une période pour un tableau de bord : des raccourcis (7, 30, 90 jours, mois) et une
|
|
34
|
+
* période libre en deux champs de date. Les champs sont ceux du navigateur, sans calendrier
|
|
35
|
+
* maison : clavier, lecteurs d'écran et sélecteur natif du téléphone fonctionnent d'office.
|
|
36
|
+
* Contrôlé : la période vit dans l'URL du produit.
|
|
37
|
+
*/
|
|
38
|
+
export declare function DateRangePicker({ value, onChange, presets, today: todayProp, min, max, label, className, }: {
|
|
39
|
+
value: DateRange;
|
|
40
|
+
onChange: (range: DateRange, preset: string | null) => void;
|
|
41
|
+
presets?: ReadonlyArray<DateRangePreset>;
|
|
42
|
+
/** Date du jour (ISO), pour les raccourcis ; celle du navigateur par défaut. */
|
|
43
|
+
today?: string;
|
|
44
|
+
/** Première date choisissable (ISO) : le début des données. */
|
|
45
|
+
min?: string;
|
|
46
|
+
/** Dernière date choisissable (ISO) ; la date du jour par défaut. */
|
|
47
|
+
max?: string;
|
|
48
|
+
label?: string;
|
|
49
|
+
className?: string;
|
|
50
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Calendar03Icon from "@hugeicons/core-free-icons/Calendar03Icon";
|
|
3
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
4
|
+
import { useId, useState } from "react";
|
|
5
|
+
import { Button } from "./button.js";
|
|
6
|
+
import { cn } from "./cn.js";
|
|
7
|
+
import { Input } from "./input.js";
|
|
8
|
+
import { Label } from "./label.js";
|
|
9
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
|
|
10
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
11
|
+
/** Une date ISO (`2026-10-15`) en `Date` locale à minuit, ou `null` si elle n'existe pas (31 février). */
|
|
12
|
+
export function parseIsoDate(value) {
|
|
13
|
+
const match = ISO_DATE.exec(value);
|
|
14
|
+
if (!match)
|
|
15
|
+
return null;
|
|
16
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
17
|
+
const date = new Date(year, month - 1, day);
|
|
18
|
+
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day ? date : null;
|
|
19
|
+
}
|
|
20
|
+
/** Une `Date` en date ISO, dans le fuseau du navigateur. */
|
|
21
|
+
export function toIsoDate(date) {
|
|
22
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
23
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
24
|
+
}
|
|
25
|
+
function shift(iso, days) {
|
|
26
|
+
const date = parseIsoDate(iso) ?? new Date();
|
|
27
|
+
date.setDate(date.getDate() + days);
|
|
28
|
+
return toIsoDate(date);
|
|
29
|
+
}
|
|
30
|
+
function lastDays(days) {
|
|
31
|
+
return (today) => ({ from: shift(today, -(days - 1)), to: today });
|
|
32
|
+
}
|
|
33
|
+
/** Périodes proposées par défaut : 7, 30 et 90 jours, ce mois-ci, le mois dernier. */
|
|
34
|
+
export const DEFAULT_DATE_RANGE_PRESETS = [
|
|
35
|
+
{ key: "7d", label: "7 derniers jours", range: lastDays(7) },
|
|
36
|
+
{ key: "30d", label: "30 derniers jours", range: lastDays(30) },
|
|
37
|
+
{ key: "90d", label: "90 derniers jours", range: lastDays(90) },
|
|
38
|
+
{ key: "month", label: "Ce mois-ci", range: (today) => ({ from: `${today.slice(0, 8)}01`, to: today }) },
|
|
39
|
+
{
|
|
40
|
+
key: "last-month",
|
|
41
|
+
label: "Le mois dernier",
|
|
42
|
+
range: (today) => {
|
|
43
|
+
const first = parseIsoDate(`${today.slice(0, 8)}01`) ?? new Date();
|
|
44
|
+
const end = new Date(first.getFullYear(), first.getMonth(), 0);
|
|
45
|
+
return { from: toIsoDate(new Date(end.getFullYear(), end.getMonth(), 1)), to: toIsoDate(end) };
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
const rangeFormat = new Intl.DateTimeFormat("fr-CH", { day: "numeric", month: "short", year: "numeric" });
|
|
50
|
+
/**
|
|
51
|
+
* « 1 au 15 oct. 2026 », « 28 sept. au 4 oct. 2026 » : le format de période d'`Intl`, son tiret
|
|
52
|
+
* remplacé par « au », plus lisible et sans tiret long dans l'interface.
|
|
53
|
+
*/
|
|
54
|
+
export function formatDateRange(range) {
|
|
55
|
+
const from = parseIsoDate(range.from);
|
|
56
|
+
const to = parseIsoDate(range.to);
|
|
57
|
+
if (!from || !to)
|
|
58
|
+
return "Période invalide";
|
|
59
|
+
if (range.from === range.to)
|
|
60
|
+
return rangeFormat.format(from);
|
|
61
|
+
return rangeFormat.formatRange(from, to).replace(/\s*[–—-]\s*/u, " au ");
|
|
62
|
+
}
|
|
63
|
+
/** Erreur d'une période saisie, ou `null` si elle tient. */
|
|
64
|
+
export function validateDateRange(range, bounds = {}) {
|
|
65
|
+
if (!parseIsoDate(range.from) || !parseIsoDate(range.to))
|
|
66
|
+
return "Choisis une date de début et une date de fin.";
|
|
67
|
+
if (range.from > range.to)
|
|
68
|
+
return "La date de début doit précéder la date de fin.";
|
|
69
|
+
if (bounds.min && range.from < bounds.min)
|
|
70
|
+
return `Les données commencent le ${formatDateRange({ from: bounds.min, to: bounds.min })}.`;
|
|
71
|
+
if (bounds.max && range.to > bounds.max)
|
|
72
|
+
return `Choisis une fin au plus tard le ${formatDateRange({ from: bounds.max, to: bounds.max })}.`;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Choix d'une période pour un tableau de bord : des raccourcis (7, 30, 90 jours, mois) et une
|
|
77
|
+
* période libre en deux champs de date. Les champs sont ceux du navigateur, sans calendrier
|
|
78
|
+
* maison : clavier, lecteurs d'écran et sélecteur natif du téléphone fonctionnent d'office.
|
|
79
|
+
* Contrôlé : la période vit dans l'URL du produit.
|
|
80
|
+
*/
|
|
81
|
+
export function DateRangePicker({ value, onChange, presets = DEFAULT_DATE_RANGE_PRESETS, today: todayProp, min, max, label = "Période", className, }) {
|
|
82
|
+
// La date du navigateur, lue une fois au montage : un rendu ne doit pas dépendre de l'horloge.
|
|
83
|
+
const [browserToday] = useState(() => toIsoDate(new Date()));
|
|
84
|
+
const today = todayProp ?? browserToday;
|
|
85
|
+
const upper = max ?? today;
|
|
86
|
+
const [open, setOpen] = useState(false);
|
|
87
|
+
const [draft, setDraft] = useState(value);
|
|
88
|
+
const [error, setError] = useState(null);
|
|
89
|
+
const id = useId();
|
|
90
|
+
const activePreset = presets.find((p) => {
|
|
91
|
+
const r = p.range(today);
|
|
92
|
+
return r.from === value.from && r.to === value.to;
|
|
93
|
+
});
|
|
94
|
+
const summary = activePreset?.label ?? formatDateRange(value);
|
|
95
|
+
const onOpenChange = (next) => {
|
|
96
|
+
if (next) {
|
|
97
|
+
setDraft(value);
|
|
98
|
+
setError(null);
|
|
99
|
+
}
|
|
100
|
+
setOpen(next);
|
|
101
|
+
};
|
|
102
|
+
const apply = (event) => {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
const problem = validateDateRange(draft, { ...(min ? { min } : {}), max: upper });
|
|
105
|
+
setError(problem);
|
|
106
|
+
if (problem)
|
|
107
|
+
return;
|
|
108
|
+
onChange(draft, null);
|
|
109
|
+
setOpen(false);
|
|
110
|
+
};
|
|
111
|
+
return (_jsxs(Popover, { open: open, onOpenChange: onOpenChange, children: [_jsxs(PopoverTrigger, { render: _jsx(Button, { variant: "outline", size: "sm", className: cn("justify-start", className) }), "aria-label": `${label} : ${summary}`, children: [_jsx(HugeiconsIcon, { icon: Calendar03Icon, "aria-hidden": true, "data-icon": "inline-start" }), _jsx("span", { className: "truncate", children: summary })] }), _jsx(PopoverContent, { align: "start", className: "w-[min(36rem,calc(100vw-1rem))] gap-0 p-0", "aria-label": label, children: _jsxs("div", { className: "grid sm:grid-cols-[12rem_1fr]", children: [_jsx("ul", { "aria-label": "Raccourcis", className: "grid content-start gap-0.5 border-b border-border p-1.5 sm:border-r sm:border-b-0", children: presets.map((preset) => {
|
|
112
|
+
const pressed = preset.key === activePreset?.key;
|
|
113
|
+
return (_jsx("li", { children: _jsx("button", { type: "button", "aria-pressed": pressed, onClick: () => {
|
|
114
|
+
onChange(preset.range(today), preset.key);
|
|
115
|
+
setOpen(false);
|
|
116
|
+
}, className: cn("flex min-h-10 w-full items-center rounded-xl px-3 text-left text-sm outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 lg:min-h-9", pressed && "bg-muted font-medium"), children: preset.label }) }, preset.key));
|
|
117
|
+
}) }), _jsxs("form", { onSubmit: apply, noValidate: true, className: "grid content-start gap-3 p-4", children: [_jsx("p", { className: "text-sm font-medium", children: "P\u00E9riode libre" }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-from`, children: "Du" }), _jsx(Input, { id: `${id}-from`, type: "date", value: draft.from, ...(min ? { min } : {}), max: upper, "aria-invalid": error ? true : undefined, "aria-describedby": error ? `${id}-error` : undefined, onChange: (event) => setDraft((d) => ({ ...d, from: event.target.value })) })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-to`, children: "Au" }), _jsx(Input, { id: `${id}-to`, type: "date", value: draft.to, ...(min ? { min } : {}), max: upper, "aria-invalid": error ? true : undefined, "aria-describedby": error ? `${id}-error` : undefined, onChange: (event) => setDraft((d) => ({ ...d, to: event.target.value })) })] })] }), error ? (_jsx("p", { id: `${id}-error`, role: "alert", className: "soft-in text-sm text-destructive", children: error })) : null, _jsx(Button, { type: "submit", size: "sm", className: "justify-self-end", children: "Appliquer" })] })] }) })] }));
|
|
118
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Un instant (millisecondes) en valeur de `<input type="datetime-local">`, à la minute, dans le fuseau du navigateur. */
|
|
2
|
+
export declare function toDateTimeLocal(time: number): string;
|
|
3
|
+
/** La valeur d'un champ `datetime-local` en instant (millisecondes), ou `null` si elle est vide ou invalide. */
|
|
4
|
+
export declare function fromDateTimeLocal(value: string): number | null;
|
|
5
|
+
/**
|
|
6
|
+
* Date et heure d'un envoi programmé, d'une fenêtre de maintenance : le champ du navigateur
|
|
7
|
+
* (sélecteur natif sur téléphone, clavier, lecteurs d'écran), avec la valeur en millisecondes
|
|
8
|
+
* comme Convex la stocke. L'heure est celle du navigateur, à la minute.
|
|
9
|
+
*
|
|
10
|
+
* Dans un `Field`, le libellé et la description se relient tout seuls.
|
|
11
|
+
*/
|
|
12
|
+
export declare function DateTimeField({ value, onChange, min, max, className, ...props }: Omit<React.ComponentProps<"input">, "type" | "value" | "onChange" | "min" | "max" | "defaultValue"> & {
|
|
13
|
+
value: number | null;
|
|
14
|
+
onChange: (value: number | null) => void;
|
|
15
|
+
/** Instant le plus tôt accepté (millisecondes) : `Date.now()` pour un envoi programmé. */
|
|
16
|
+
min?: number;
|
|
17
|
+
max?: number;
|
|
18
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Input } from "./input.js";
|
|
3
|
+
/** Un instant (millisecondes) en valeur de `<input type="datetime-local">`, à la minute, dans le fuseau du navigateur. */
|
|
4
|
+
export function toDateTimeLocal(time) {
|
|
5
|
+
const date = new Date(time);
|
|
6
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
7
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
8
|
+
}
|
|
9
|
+
const LOCAL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
|
|
10
|
+
/** La valeur d'un champ `datetime-local` en instant (millisecondes), ou `null` si elle est vide ou invalide. */
|
|
11
|
+
export function fromDateTimeLocal(value) {
|
|
12
|
+
const match = LOCAL.exec(value);
|
|
13
|
+
if (!match)
|
|
14
|
+
return null;
|
|
15
|
+
const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0));
|
|
16
|
+
const date = new Date(year, month - 1, day, hour, minute, second);
|
|
17
|
+
// `Date` corrige un 31 février en 3 mars : une date qui n'existe pas est refusée.
|
|
18
|
+
if (date.getMonth() !== month - 1 || date.getDate() !== day)
|
|
19
|
+
return null;
|
|
20
|
+
return date.getTime();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Date et heure d'un envoi programmé, d'une fenêtre de maintenance : le champ du navigateur
|
|
24
|
+
* (sélecteur natif sur téléphone, clavier, lecteurs d'écran), avec la valeur en millisecondes
|
|
25
|
+
* comme Convex la stocke. L'heure est celle du navigateur, à la minute.
|
|
26
|
+
*
|
|
27
|
+
* Dans un `Field`, le libellé et la description se relient tout seuls.
|
|
28
|
+
*/
|
|
29
|
+
export function DateTimeField({ value, onChange, min, max, className, ...props }) {
|
|
30
|
+
return (_jsx(Input, { ...props, type: "datetime-local", step: 60, value: value === null ? "" : toDateTimeLocal(value), ...(min !== undefined ? { min: toDateTimeLocal(min) } : {}), ...(max !== undefined ? { max: toDateTimeLocal(max) } : {}), onChange: (event) => onChange(fromDateTimeLocal(event.target.value)), ...(className !== undefined ? { className } : {}) }));
|
|
31
|
+
}
|
|
@@ -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;
|