@hgdc/ui 0.2.1 → 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 +78 -21
- package/dist/account-menu.d.ts +32 -0
- package/dist/account-menu.js +39 -0
- package/dist/alert.d.ts +19 -0
- package/dist/alert.js +25 -0
- package/dist/app-shell.d.ts +56 -0
- package/dist/app-shell.js +107 -0
- package/dist/card.d.ts +24 -0
- package/dist/card.js +42 -0
- package/dist/chart-path.d.ts +9 -0
- package/dist/chart-path.js +46 -0
- package/dist/checkbox.d.ts +12 -0
- package/dist/checkbox.js +19 -0
- package/dist/command-menu.d.ts +56 -0
- package/dist/command-menu.js +79 -0
- package/dist/copy-button.d.ts +26 -0
- package/dist/copy-button.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/kbd.d.ts +9 -0
- package/dist/kbd.js +19 -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/org-switcher.d.ts +32 -0
- package/dist/org-switcher.js +32 -0
- package/dist/page-header.d.ts +27 -0
- package/dist/page-header.js +31 -0
- package/dist/popover.d.ts +13 -0
- package/dist/popover.js +22 -0
- package/dist/radio-group.d.ts +16 -0
- package/dist/radio-group.js +22 -0
- package/dist/relative-time.d.ts +16 -0
- package/dist/relative-time.js +54 -0
- package/dist/resource-list.d.ts +31 -0
- package/dist/resource-list.js +35 -0
- package/dist/secret-reveal.d.ts +13 -0
- package/dist/secret-reveal.js +26 -0
- package/dist/skeleton.d.ts +28 -0
- package/dist/skeleton.js +47 -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/stat.d.ts +30 -0
- package/dist/stat.js +26 -0
- package/dist/status.d.ts +22 -0
- package/dist/status.js +37 -0
- package/dist/table.d.ts +50 -0
- package/dist/table.js +68 -0
- package/dist/tabs.d.ts +16 -0
- package/dist/tabs.js +26 -0
- package/dist/time-chart.d.ts +40 -0
- package/dist/time-chart.js +90 -0
- package/dist/timeline.d.ts +21 -0
- package/dist/timeline.js +11 -0
- package/dist/tooltip.d.ts +15 -0
- package/dist/tooltip.js +25 -0
- package/dist/uptime-bar.d.ts +29 -0
- package/dist/uptime-bar.js +57 -0
- package/package.json +1 -1
- package/styles.css +45 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type IconSvgElement } from "@hugeicons/react";
|
|
2
|
+
export type Command = {
|
|
3
|
+
/** Unique dans toute la palette. */
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
icon?: IconSvgElement;
|
|
7
|
+
/** Autres mots qui trouvent la commande : « dns » pour « Ajouter un domaine ». */
|
|
8
|
+
keywords?: Array<string>;
|
|
9
|
+
/** Texte discret à droite : l'organisation d'un projet, « Page ». */
|
|
10
|
+
hint?: string;
|
|
11
|
+
/** Touches affichées à droite, par exemple `["G", "P"]`. */
|
|
12
|
+
shortcut?: Array<string>;
|
|
13
|
+
onSelect: () => void;
|
|
14
|
+
};
|
|
15
|
+
/** Les groupes de la famille, dans cet ordre : « Aller à », « Créer », « Changer ». */
|
|
16
|
+
export type CommandGroup = {
|
|
17
|
+
label: string;
|
|
18
|
+
items: Array<Command>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Une commande répond à la recherche si chaque mot tapé figure dans son libellé ou ses mots-clés,
|
|
22
|
+
* sans tenir compte des accents ni de la casse : « creer dom » trouve « Créer un domaine ».
|
|
23
|
+
*/
|
|
24
|
+
export declare function matchCommand(command: Pick<Command, "label" | "keywords">, query: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* `Ctrl+K` (`⌘K` sur Mac) partout, même dans un champ : c'est le geste qui ouvre tout. Le produit
|
|
27
|
+
* le pose une fois, autour de sa coque.
|
|
28
|
+
*/
|
|
29
|
+
export declare function useCommandMenuShortcut(onToggle: () => void, key?: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* Palette de commandes : chercher une page, créer une ressource, changer d'organisation ou de
|
|
32
|
+
* thème, sans la souris. Bâtie sur l'`Autocomplete` de Base UI rendu dans un `Dialog`, comme
|
|
33
|
+
* l'exemple « Command palette » de sa documentation : flèches pour choisir, Entrée pour lancer,
|
|
34
|
+
* Échap pour fermer, focus rendu à l'élément d'origine.
|
|
35
|
+
*
|
|
36
|
+
* Sans animation, ni à l'ouverture ni à la fermeture : elle s'ouvre des dizaines de fois par
|
|
37
|
+
* jour, au clavier, et doit être là à l'instant où la touche est pressée.
|
|
38
|
+
*/
|
|
39
|
+
export declare function CommandMenu({ groups, open: openProp, onOpenChange, placeholder, emptyLabel, shortcut, }: {
|
|
40
|
+
groups: Array<CommandGroup>;
|
|
41
|
+
open?: boolean;
|
|
42
|
+
onOpenChange?: (open: boolean) => void;
|
|
43
|
+
placeholder?: string;
|
|
44
|
+
emptyLabel?: string;
|
|
45
|
+
/** Ouvrir et fermer par `Ctrl+K`. `false` si le produit gère le raccourci lui-même. */
|
|
46
|
+
shortcut?: boolean;
|
|
47
|
+
}): React.JSX.Element;
|
|
48
|
+
/**
|
|
49
|
+
* Le bouton qui ouvre la palette, pour la souris et le doigt : « Rechercher » et le raccourci.
|
|
50
|
+
* Dans la barre latérale ou l'en-tête.
|
|
51
|
+
*/
|
|
52
|
+
export declare function CommandMenuButton({ onClick, label, className, }: {
|
|
53
|
+
onClick: () => void;
|
|
54
|
+
label?: string;
|
|
55
|
+
className?: string;
|
|
56
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Autocomplete } from "@base-ui/react/autocomplete";
|
|
3
|
+
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
|
4
|
+
import Search01Icon from "@hugeicons/core-free-icons/Search01Icon";
|
|
5
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
6
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
7
|
+
import { FLOATING_SURFACE } from "./floating.js";
|
|
8
|
+
import { Kbd, KbdGroup, isApplePlatform } from "./kbd.js";
|
|
9
|
+
import { cn } from "./cn.js";
|
|
10
|
+
function normalize(text) {
|
|
11
|
+
return text
|
|
12
|
+
.normalize("NFD")
|
|
13
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
14
|
+
.toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Une commande répond à la recherche si chaque mot tapé figure dans son libellé ou ses mots-clés,
|
|
18
|
+
* sans tenir compte des accents ni de la casse : « creer dom » trouve « Créer un domaine ».
|
|
19
|
+
*/
|
|
20
|
+
export function matchCommand(command, query) {
|
|
21
|
+
const words = normalize(query).split(/\s+/).filter(Boolean);
|
|
22
|
+
if (words.length === 0)
|
|
23
|
+
return true;
|
|
24
|
+
const haystack = normalize([command.label, ...(command.keywords ?? [])].join(" "));
|
|
25
|
+
return words.every((word) => haystack.includes(word));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `Ctrl+K` (`⌘K` sur Mac) partout, même dans un champ : c'est le geste qui ouvre tout. Le produit
|
|
29
|
+
* le pose une fois, autour de sa coque.
|
|
30
|
+
*/
|
|
31
|
+
export function useCommandMenuShortcut(onToggle, key = "k") {
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const onKey = (event) => {
|
|
34
|
+
if (event.key.toLowerCase() !== key || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey)
|
|
35
|
+
return;
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
onToggle();
|
|
38
|
+
};
|
|
39
|
+
document.addEventListener("keydown", onKey);
|
|
40
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
41
|
+
}, [onToggle, key]);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Palette de commandes : chercher une page, créer une ressource, changer d'organisation ou de
|
|
45
|
+
* thème, sans la souris. Bâtie sur l'`Autocomplete` de Base UI rendu dans un `Dialog`, comme
|
|
46
|
+
* l'exemple « Command palette » de sa documentation : flèches pour choisir, Entrée pour lancer,
|
|
47
|
+
* Échap pour fermer, focus rendu à l'élément d'origine.
|
|
48
|
+
*
|
|
49
|
+
* Sans animation, ni à l'ouverture ni à la fermeture : elle s'ouvre des dizaines de fois par
|
|
50
|
+
* jour, au clavier, et doit être là à l'instant où la touche est pressée.
|
|
51
|
+
*/
|
|
52
|
+
export function CommandMenu({ groups, open: openProp, onOpenChange, placeholder = "Rechercher une page, une action…", emptyLabel = "Aucune commande ne correspond.", shortcut = true, }) {
|
|
53
|
+
const [openState, setOpenState] = useState(false);
|
|
54
|
+
const open = openProp ?? openState;
|
|
55
|
+
const setOpen = useCallback((next) => {
|
|
56
|
+
if (openProp === undefined)
|
|
57
|
+
setOpenState(next);
|
|
58
|
+
onOpenChange?.(next);
|
|
59
|
+
}, [openProp, onOpenChange]);
|
|
60
|
+
const toggle = useCallback(() => setOpen(!open), [open, setOpen]);
|
|
61
|
+
useCommandMenuShortcut(shortcut ? toggle : noop);
|
|
62
|
+
const visibleGroups = groups.filter((group) => group.items.length > 0);
|
|
63
|
+
return (_jsx(DialogPrimitive.Root, { open: open, onOpenChange: setOpen, children: _jsxs(DialogPrimitive.Portal, { children: [_jsx(DialogPrimitive.Backdrop, { className: "fixed inset-0 z-50 bg-foreground/30 dark:bg-black/60" }), _jsx(DialogPrimitive.Viewport, { className: "fixed inset-0 z-50 flex items-start justify-center px-2 pt-[min(12dvh,6rem)] pb-2", children: _jsx(DialogPrimitive.Popup, { "data-slot": "command-menu", "aria-label": "Palette de commandes", className: cn(FLOATING_SURFACE, "flex max-h-[min(36rem,calc(100dvh-2rem))] w-full max-w-xl flex-col overflow-hidden rounded-3xl shadow-2xl shadow-black/10 outline-none"), children: _jsxs(Autocomplete.Root, { open: true, inline: true, items: visibleGroups, itemToStringValue: (command) => command.label, filter: (command, query) => matchCommand(command, query), autoHighlight: "always", keepHighlight: true, children: [_jsxs(Autocomplete.InputGroup, { className: "flex items-center gap-3 border-b border-foreground/5 px-4", children: [_jsx(HugeiconsIcon, { icon: Search01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" }), _jsx(Autocomplete.Input, { "aria-label": "Rechercher une commande", placeholder: placeholder, className: "h-14 w-full min-w-0 bg-transparent text-base outline-none placeholder:text-muted-foreground" }), _jsx(DialogPrimitive.Close, { className: "sr-only", children: "Fermer la palette" })] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5 [scroll-padding-block:0.375rem]", children: [_jsx(Autocomplete.Empty, { className: "px-3 py-10 text-center text-sm text-muted-foreground empty:hidden", children: emptyLabel }), _jsx(Autocomplete.List, { children: (group) => (_jsxs(Autocomplete.Group, { items: group.items, className: "not-last:mb-1.5", children: [_jsx(Autocomplete.GroupLabel, { className: "px-3 pt-2 pb-1.5 text-xs font-medium text-muted-foreground", children: group.label }), _jsx(Autocomplete.Collection, { children: (command) => (_jsxs(Autocomplete.Item, { value: command, onClick: () => {
|
|
64
|
+
setOpen(false);
|
|
65
|
+
command.onSelect();
|
|
66
|
+
}, className: "flex min-h-11 cursor-default items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium outline-none select-none data-highlighted:bg-muted lg:min-h-10", children: [command.icon ? (_jsx(HugeiconsIcon, { icon: command.icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" })) : null, _jsx("span", { className: "min-w-0 flex-1 truncate", children: command.label }), command.hint ? (_jsx("span", { className: "shrink-0 truncate text-xs font-normal text-muted-foreground", children: command.hint })) : null, command.shortcut ? (_jsx(KbdGroup, { className: "hidden shrink-0 sm:inline-flex", children: command.shortcut.map((key) => (_jsx(Kbd, { children: key }, key))) })) : null] }, command.id)) })] }, group.label)) })] }), _jsxs("div", { className: "hidden items-center gap-4 border-t border-foreground/5 px-4 py-2.5 text-xs text-muted-foreground sm:flex", children: [_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsxs(KbdGroup, { children: [_jsx(Kbd, { children: "\u2191" }), _jsx(Kbd, { children: "\u2193" })] }), "Choisir"] }), _jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx(Kbd, { children: "Entr\u00E9e" }), "Ouvrir"] }), _jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx(Kbd, { children: "\u00C9chap" }), "Fermer"] })] })] }) }) })] }) }));
|
|
67
|
+
}
|
|
68
|
+
function noop() { }
|
|
69
|
+
function subscribeNever() {
|
|
70
|
+
return noop;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Le bouton qui ouvre la palette, pour la souris et le doigt : « Rechercher » et le raccourci.
|
|
74
|
+
* Dans la barre latérale ou l'en-tête.
|
|
75
|
+
*/
|
|
76
|
+
export function CommandMenuButton({ onClick, label = "Rechercher", className, }) {
|
|
77
|
+
const apple = useSyncExternalStore(subscribeNever, isApplePlatform, () => false);
|
|
78
|
+
return (_jsxs("button", { type: "button", onClick: onClick, "aria-keyshortcuts": "Control+K Meta+K", className: cn("touch-target flex h-9 w-full max-w-sm min-w-0 items-center gap-2 rounded-xl bg-foreground/[0.05] px-3 text-sm text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-foreground/[0.08] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", className), children: [_jsx(HugeiconsIcon, { icon: Search01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4 shrink-0" }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: label }), _jsxs(KbdGroup, { "aria-hidden": true, className: "max-lg:hidden", children: [_jsx(Kbd, { children: apple ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }));
|
|
79
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Copie dans le presse-papiers, et dit si c'est fait pendant 1,5 s. */
|
|
2
|
+
export declare function useCopy(): {
|
|
3
|
+
copied: boolean;
|
|
4
|
+
copy: (value: string) => Promise<boolean>;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Bouton-icône qui copie une valeur ; l'icône devient une coche, et « Copié » est annoncé aux
|
|
8
|
+
* lecteurs d'écran.
|
|
9
|
+
*/
|
|
10
|
+
export declare function CopyButton({ value, label, className, }: {
|
|
11
|
+
value: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}): React.JSX.Element;
|
|
15
|
+
/**
|
|
16
|
+
* Une commande, une valeur DNS, une URL : en police à chasse fixe, sur une ligne qui défile si
|
|
17
|
+
* elle est longue, avec son bouton de copie. `prompt` affiche « $ » devant une commande, sans
|
|
18
|
+
* qu'il soit copié.
|
|
19
|
+
*/
|
|
20
|
+
export declare function Snippet({ value, prompt, label, className, }: {
|
|
21
|
+
value: string;
|
|
22
|
+
prompt?: boolean;
|
|
23
|
+
/** Nom accessible du bouton : « Copier la commande ». */
|
|
24
|
+
label?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,46 @@
|
|
|
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 { useEffect, useState } from "react";
|
|
6
|
+
import { cn } from "./cn.js";
|
|
7
|
+
/** Copie dans le presse-papiers, et dit si c'est fait pendant 1,5 s. */
|
|
8
|
+
export function useCopy() {
|
|
9
|
+
const [copied, setCopied] = useState(false);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!copied)
|
|
12
|
+
return;
|
|
13
|
+
const timer = setTimeout(() => setCopied(false), 1500);
|
|
14
|
+
return () => clearTimeout(timer);
|
|
15
|
+
}, [copied]);
|
|
16
|
+
return {
|
|
17
|
+
copied,
|
|
18
|
+
copy: async (value) => {
|
|
19
|
+
try {
|
|
20
|
+
await navigator.clipboard.writeText(value);
|
|
21
|
+
setCopied(true);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Presse-papiers refusé (page non sécurisée, permission) : rien n'est annoncé comme copié.
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Bouton-icône qui copie une valeur ; l'icône devient une coche, et « Copié » est annoncé aux
|
|
33
|
+
* lecteurs d'écran.
|
|
34
|
+
*/
|
|
35
|
+
export function CopyButton({ value, label = "Copier", className, }) {
|
|
36
|
+
const { copied, copy } = useCopy();
|
|
37
|
+
return (_jsxs("button", { type: "button", "aria-label": copied ? "Copié" : label, title: copied ? "Copié" : label, onClick: () => void copy(value), className: cn("touch-target grid size-8 shrink-0 pressable place-items-center rounded-lg text-muted-foreground outline-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50", copied && "text-status-active-ink hover:text-status-active-ink", className), children: [_jsx(HugeiconsIcon, { icon: copied ? Tick02Icon : Copy01Icon, "aria-hidden": true, className: "size-4 pop-in" }, copied ? "ok" : "copy"), _jsx("span", { className: "sr-only", "aria-live": "polite", children: copied ? "Copié" : "" })] }));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Une commande, une valeur DNS, une URL : en police à chasse fixe, sur une ligne qui défile si
|
|
41
|
+
* elle est longue, avec son bouton de copie. `prompt` affiche « $ » devant une commande, sans
|
|
42
|
+
* qu'il soit copié.
|
|
43
|
+
*/
|
|
44
|
+
export function Snippet({ value, prompt = false, label, className, }) {
|
|
45
|
+
return (_jsxs("div", { "data-slot": "snippet", className: cn("flex min-w-0 items-center gap-2 rounded-2xl bg-foreground/[0.05] py-1.5 pr-1.5 pl-4", className), children: [_jsxs("pre", { className: "min-w-0 flex-1 overflow-x-auto py-1 font-mono text-[13px] [scrollbar-width:thin]", children: [prompt ? (_jsx("span", { "aria-hidden": true, className: "mr-2 text-muted-foreground select-none", children: "$" })) : null, _jsx("code", { children: value })] }), _jsx(CopyButton, { value: value, ...(label !== undefined ? { label } : {}) })] }));
|
|
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;
|