@hgdc/ui 0.2.0 → 0.3.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 +63 -18
- 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/badge.js +3 -3
- package/dist/button.js +6 -5
- package/dist/card.d.ts +24 -0
- package/dist/card.js +42 -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/kbd.d.ts +9 -0
- package/dist/kbd.js +19 -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/segmented-control.js +1 -3
- package/dist/skeleton.d.ts +28 -0
- package/dist/skeleton.js +47 -0
- package/dist/sonner.js +2 -2
- package/dist/spinner.d.ts +15 -4
- package/dist/spinner.js +7 -6
- 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 +127 -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 +24 -0
- package/dist/uptime-bar.js +25 -0
- package/package.json +1 -1
- package/styles.css +74 -25
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Un bloc de chargement : il prend la forme exacte de ce qu'il remplace (hauteur d'une ligne,
|
|
4
|
+
* largeur d'un titre), pour que rien ne saute à l'arrivée des données. `bg-foreground/6` plutôt
|
|
5
|
+
* que `bg-muted` : posé sur une carte `surface`, qui est déjà `muted` en clair, il restait invisible.
|
|
6
|
+
*/
|
|
7
|
+
export declare function Skeleton({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
8
|
+
export type PendingTiming = {
|
|
9
|
+
/** Attente avant d'afficher le squelette : une réponse rapide n'en montre aucun (ms). */
|
|
10
|
+
delay?: number;
|
|
11
|
+
/** Durée minimale d'affichage d'un squelette apparu, pour qu'il ne clignote pas (ms). */
|
|
12
|
+
minDuration?: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Faut-il montrer le squelette ? Il n'apparaît qu'après `delay` (200 ms) et, une fois là, reste
|
|
16
|
+
* au moins `minDuration` (400 ms) : une donnée qui arrive en 250 ms ne fait pas clignoter
|
|
17
|
+
* l'écran deux fois.
|
|
18
|
+
*/
|
|
19
|
+
export declare function usePendingVisibility(pending: boolean, { delay, minDuration }?: PendingTiming): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Le contenu, ou son squelette quand il se fait attendre. Pendant le délai, rien n'est rendu :
|
|
22
|
+
* une page qui répond vite passe directement du vide au contenu.
|
|
23
|
+
*/
|
|
24
|
+
export declare function Pending({ pending, fallback, children, delay, minDuration, }: PendingTiming & {
|
|
25
|
+
pending: boolean;
|
|
26
|
+
fallback: ReactNode;
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
}): React.JSX.Element | null;
|
package/dist/skeleton.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
/**
|
|
5
|
+
* Un bloc de chargement : il prend la forme exacte de ce qu'il remplace (hauteur d'une ligne,
|
|
6
|
+
* largeur d'un titre), pour que rien ne saute à l'arrivée des données. `bg-foreground/6` plutôt
|
|
7
|
+
* que `bg-muted` : posé sur une carte `surface`, qui est déjà `muted` en clair, il restait invisible.
|
|
8
|
+
*/
|
|
9
|
+
export function Skeleton({ className, ...props }) {
|
|
10
|
+
return (_jsx("div", { "aria-hidden": true, "data-slot": "skeleton", className: cn("skeleton-pulse rounded-xl bg-foreground/6", className), ...props }));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Faut-il montrer le squelette ? Il n'apparaît qu'après `delay` (200 ms) et, une fois là, reste
|
|
14
|
+
* au moins `minDuration` (400 ms) : une donnée qui arrive en 250 ms ne fait pas clignoter
|
|
15
|
+
* l'écran deux fois.
|
|
16
|
+
*/
|
|
17
|
+
export function usePendingVisibility(pending, { delay = 200, minDuration = 400 } = {}) {
|
|
18
|
+
const [visible, setVisible] = useState(false);
|
|
19
|
+
const shownAt = useRef(0);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (pending === visible)
|
|
22
|
+
return;
|
|
23
|
+
const wait = pending ? delay : Math.max(0, shownAt.current + minDuration - Date.now());
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
if (pending)
|
|
26
|
+
shownAt.current = Date.now();
|
|
27
|
+
setVisible(pending);
|
|
28
|
+
}, wait);
|
|
29
|
+
return () => clearTimeout(timer);
|
|
30
|
+
}, [pending, visible, delay, minDuration]);
|
|
31
|
+
return visible;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Le contenu, ou son squelette quand il se fait attendre. Pendant le délai, rien n'est rendu :
|
|
35
|
+
* une page qui répond vite passe directement du vide au contenu.
|
|
36
|
+
*/
|
|
37
|
+
export function Pending({ pending, fallback, children, delay, minDuration, }) {
|
|
38
|
+
const showFallback = usePendingVisibility(pending, {
|
|
39
|
+
...(delay !== undefined ? { delay } : {}),
|
|
40
|
+
...(minDuration !== undefined ? { minDuration } : {}),
|
|
41
|
+
});
|
|
42
|
+
if (showFallback)
|
|
43
|
+
return (_jsxs("div", { "aria-busy": "true", "data-slot": "pending", children: [_jsx("span", { className: "sr-only", role: "status", children: "Chargement" }), fallback] }));
|
|
44
|
+
if (pending)
|
|
45
|
+
return null;
|
|
46
|
+
return _jsx(_Fragment, { children: children });
|
|
47
|
+
}
|
package/dist/sonner.js
CHANGED
|
@@ -2,10 +2,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import Alert02Icon from "@hugeicons/core-free-icons/Alert02Icon";
|
|
3
3
|
import CheckmarkCircle02Icon from "@hugeicons/core-free-icons/CheckmarkCircle02Icon";
|
|
4
4
|
import InformationCircleIcon from "@hugeicons/core-free-icons/InformationCircleIcon";
|
|
5
|
-
import Loading03Icon from "@hugeicons/core-free-icons/Loading03Icon";
|
|
6
5
|
import MultiplicationSignCircleIcon from "@hugeicons/core-free-icons/MultiplicationSignCircleIcon";
|
|
7
6
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
8
7
|
import { Toaster as Sonner } from "sonner";
|
|
8
|
+
import { Spinner } from "./spinner.js";
|
|
9
9
|
import { useTheme } from "./theme.js";
|
|
10
10
|
function Toaster(props) {
|
|
11
11
|
const { theme } = useTheme();
|
|
@@ -18,7 +18,7 @@ function Toaster(props) {
|
|
|
18
18
|
info: _jsx(HugeiconsIcon, { icon: InformationCircleIcon, strokeWidth: 2, className: "size-4" }),
|
|
19
19
|
warning: _jsx(HugeiconsIcon, { icon: Alert02Icon, strokeWidth: 2, className: "size-4" }),
|
|
20
20
|
error: _jsx(HugeiconsIcon, { icon: MultiplicationSignCircleIcon, strokeWidth: 2, className: "size-4" }),
|
|
21
|
-
loading: _jsx(
|
|
21
|
+
loading: (_jsx("span", { "aria-hidden": true, children: _jsx(Spinner, {}) })),
|
|
22
22
|
}, style: {
|
|
23
23
|
"--normal-bg": "var(--popover)",
|
|
24
24
|
"--normal-text": "var(--popover-foreground)",
|
package/dist/spinner.d.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
declare const SIZES: {
|
|
2
|
+
readonly 3: "size-3";
|
|
3
|
+
readonly 4: "size-4";
|
|
4
|
+
readonly 5: "size-5";
|
|
5
|
+
readonly 6: "size-6";
|
|
6
|
+
readonly 8: "size-8";
|
|
7
|
+
};
|
|
1
8
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
9
|
+
* L'anneau d'attente de la DA : un arc arrondi qui tourne sur une piste à peine visible, le
|
|
10
|
+
* même que la page d'attente du portail. Dans un bouton ou une ligne, il prend la couleur du
|
|
11
|
+
* texte (`tone="current"`) ; seul sur une page, il prend l'accent (`tone="accent"`).
|
|
12
|
+
* Mouvement réduit : l'arc ne tourne plus, il respire.
|
|
4
13
|
*/
|
|
5
|
-
export declare function Spinner({ className, label, size, }: {
|
|
14
|
+
export declare function Spinner({ className, label, size, tone, }: {
|
|
6
15
|
className?: string;
|
|
7
16
|
label?: string;
|
|
8
|
-
size?:
|
|
17
|
+
size?: keyof typeof SIZES;
|
|
18
|
+
tone?: "current" | "accent";
|
|
9
19
|
}): React.JSX.Element;
|
|
20
|
+
export {};
|
package/dist/spinner.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import Loading03Icon from "@hugeicons/core-free-icons/Loading03Icon";
|
|
3
|
-
import { HugeiconsIcon } from "@hugeicons/react";
|
|
4
2
|
import { cn } from "./cn.js";
|
|
3
|
+
const SIZES = { 3: "size-3", 4: "size-4", 5: "size-5", 6: "size-6", 8: "size-8" };
|
|
5
4
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* L'anneau d'attente de la DA : un arc arrondi qui tourne sur une piste à peine visible, le
|
|
6
|
+
* même que la page d'attente du portail. Dans un bouton ou une ligne, il prend la couleur du
|
|
7
|
+
* texte (`tone="current"`) ; seul sur une page, il prend l'accent (`tone="accent"`).
|
|
8
|
+
* Mouvement réduit : l'arc ne tourne plus, il respire.
|
|
8
9
|
*/
|
|
9
|
-
export function Spinner({ className, label, size = 4, }) {
|
|
10
|
-
return (_jsxs("span", { className: cn("inline-flex items-center gap-2", className), children: [
|
|
10
|
+
export function Spinner({ className, label, size = 4, tone = "current", }) {
|
|
11
|
+
return (_jsxs("span", { role: "status", className: cn("inline-flex items-center gap-2", className), children: [_jsxs("svg", { viewBox: "0 0 32 32", fill: "none", "aria-hidden": true, className: cn("shrink-0", SIZES[size]), children: [_jsx("circle", { cx: "16", cy: "16", r: "13", strokeWidth: "4", className: "stroke-current opacity-15" }), _jsx("circle", { cx: "16", cy: "16", r: "13", strokeWidth: "4", strokeLinecap: "round", strokeDasharray: "22 60", className: cn("origin-center animate-spin motion-reduce:animate-pulse", tone === "accent" ? "stroke-accent" : "stroke-current") })] }), label ? _jsx("span", { children: label }) : _jsx("span", { className: "sr-only", children: "Chargement" })] }));
|
|
11
12
|
}
|
package/dist/stat.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export type StatDelta = {
|
|
3
|
+
/** Déjà formatée : « 12 % », « +340 ». */
|
|
4
|
+
value: string;
|
|
5
|
+
direction: "up" | "down" | "flat";
|
|
6
|
+
/**
|
|
7
|
+
* Ce qui est bon pour ce chiffre. Une hausse des erreurs est mauvaise : `good: "down"`.
|
|
8
|
+
* Par défaut, la hausse est bonne.
|
|
9
|
+
*/
|
|
10
|
+
good?: "up" | "down";
|
|
11
|
+
/** Période de comparaison : « vs 7 jours précédents ». */
|
|
12
|
+
label?: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Une métrique : libellé, valeur en chiffres de largeur fixe, variation. La variation dit son
|
|
16
|
+
* sens par la flèche et par un texte lu aux lecteurs d'écran, pas par la couleur seule.
|
|
17
|
+
*/
|
|
18
|
+
export declare function StatTile({ label, value, delta, hint, icon, className, children, }: {
|
|
19
|
+
label: ReactNode;
|
|
20
|
+
value: ReactNode;
|
|
21
|
+
delta?: StatDelta;
|
|
22
|
+
/** Une précision sous la valeur (« sur 30 jours »). */
|
|
23
|
+
hint?: ReactNode;
|
|
24
|
+
icon?: ReactNode;
|
|
25
|
+
className?: string;
|
|
26
|
+
/** Un petit graphique (`TimeChart` compact) sous la valeur. */
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
}): React.JSX.Element;
|
|
29
|
+
/** Rangée de métriques : une colonne sur téléphone, deux puis quatre en s'élargissant. */
|
|
30
|
+
export declare function StatGrid({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
package/dist/stat.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import ArrowDownRight01Icon from "@hugeicons/core-free-icons/ArrowDownRight01Icon";
|
|
3
|
+
import ArrowUpRight01Icon from "@hugeicons/core-free-icons/ArrowUpRight01Icon";
|
|
4
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
5
|
+
import { cn } from "./cn.js";
|
|
6
|
+
const DIRECTION_TEXT = { up: "en hausse de", down: "en baisse de" };
|
|
7
|
+
/**
|
|
8
|
+
* Une métrique : libellé, valeur en chiffres de largeur fixe, variation. La variation dit son
|
|
9
|
+
* sens par la flèche et par un texte lu aux lecteurs d'écran, pas par la couleur seule.
|
|
10
|
+
*/
|
|
11
|
+
export function StatTile({ label, value, delta, hint, icon, className, children, }) {
|
|
12
|
+
return (_jsxs("div", { "data-slot": "stat-tile", className: cn("surface flex min-w-0 flex-col gap-2 rounded-3xl p-5", className), children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("p", { className: "truncate text-sm text-muted-foreground", children: label }), icon ? (_jsx("span", { "aria-hidden": true, className: "text-muted-foreground [&_svg:not([class*='size-'])]:size-4", children: icon })) : null] }), _jsx("p", { className: "font-heading text-2xl font-semibold tracking-tight tabular-nums sm:text-[28px]", children: value }), delta || hint ? (_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 text-xs", children: [delta ? _jsx(StatDeltaText, { delta: delta }) : null, hint ? _jsx("span", { className: "text-muted-foreground", children: hint }) : null] })) : null, children] }));
|
|
13
|
+
}
|
|
14
|
+
function StatDeltaText({ delta }) {
|
|
15
|
+
const good = delta.good ?? "up";
|
|
16
|
+
const tone = delta.direction === "flat"
|
|
17
|
+
? "text-muted-foreground"
|
|
18
|
+
: delta.direction === good
|
|
19
|
+
? "text-status-active-ink"
|
|
20
|
+
: "text-destructive";
|
|
21
|
+
return (_jsxs("span", { "data-slot": "stat-delta", "data-direction": delta.direction, className: cn("inline-flex items-center gap-0.5 font-medium tabular-nums", tone), children: [delta.direction === "flat" ? null : (_jsx(HugeiconsIcon, { icon: delta.direction === "up" ? ArrowUpRight01Icon : ArrowDownRight01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-3.5" })), delta.direction === "flat" ? ("Stable") : (_jsxs(_Fragment, { children: [_jsxs("span", { className: "sr-only", children: [DIRECTION_TEXT[delta.direction], " "] }), delta.value] })), delta.label ? _jsx("span", { className: "ml-1 font-normal text-muted-foreground", children: delta.label }) : null] }));
|
|
22
|
+
}
|
|
23
|
+
/** Rangée de métriques : une colonne sur téléphone, deux puis quatre en s'élargissant. */
|
|
24
|
+
export function StatGrid({ className, ...props }) {
|
|
25
|
+
return _jsx("div", { "data-slot": "stat-grid", className: cn("grid gap-3 sm:grid-cols-2 lg:grid-cols-4", className), ...props });
|
|
26
|
+
}
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Disponibilité d'un service, vocabulaire fermé de la famille (Status, Host, Mail). */
|
|
2
|
+
export type ServiceStatus = "operational" | "degraded" | "outage" | "maintenance" | "unknown";
|
|
3
|
+
export declare const SERVICE_STATUS_LABEL: Record<ServiceStatus, string>;
|
|
4
|
+
/**
|
|
5
|
+
* Le point d'un statut, décoratif (`aria-hidden`) : il accompagne toujours un libellé.
|
|
6
|
+
* `live` ajoute l'anneau qui s'élargit, pour ce qui est en cours (incident, vérification DNS).
|
|
7
|
+
*/
|
|
8
|
+
export declare function StatusDot({ status, live, className, }: {
|
|
9
|
+
status: ServiceStatus;
|
|
10
|
+
live?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}): React.JSX.Element;
|
|
13
|
+
/**
|
|
14
|
+
* Un statut : point et libellé, jamais la couleur seule. Le libellé par défaut est celui de la
|
|
15
|
+
* famille ; un produit peut le préciser (« Panne partielle ») sans changer de couleur.
|
|
16
|
+
*/
|
|
17
|
+
export declare function StatusBadge({ status, label, live, className, }: {
|
|
18
|
+
status: ServiceStatus;
|
|
19
|
+
label?: string;
|
|
20
|
+
live?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
}): React.JSX.Element;
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "./cn.js";
|
|
3
|
+
export const SERVICE_STATUS_LABEL = {
|
|
4
|
+
operational: "Opérationnel",
|
|
5
|
+
degraded: "Dégradé",
|
|
6
|
+
outage: "Panne",
|
|
7
|
+
maintenance: "Maintenance",
|
|
8
|
+
unknown: "Inconnu",
|
|
9
|
+
};
|
|
10
|
+
const DOT = {
|
|
11
|
+
operational: "bg-status-operational",
|
|
12
|
+
degraded: "bg-status-degraded",
|
|
13
|
+
outage: "bg-status-outage",
|
|
14
|
+
maintenance: "bg-status-maintenance",
|
|
15
|
+
unknown: "bg-status-unknown",
|
|
16
|
+
};
|
|
17
|
+
const BADGE = {
|
|
18
|
+
operational: "bg-status-operational/12 text-status-operational-ink",
|
|
19
|
+
degraded: "bg-status-degraded/15 text-status-degraded-ink",
|
|
20
|
+
outage: "bg-status-outage/10 text-status-outage-ink",
|
|
21
|
+
maintenance: "bg-status-maintenance/12 text-status-maintenance-ink",
|
|
22
|
+
unknown: "bg-foreground/[0.06] text-status-unknown-ink",
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Le point d'un statut, décoratif (`aria-hidden`) : il accompagne toujours un libellé.
|
|
26
|
+
* `live` ajoute l'anneau qui s'élargit, pour ce qui est en cours (incident, vérification DNS).
|
|
27
|
+
*/
|
|
28
|
+
export function StatusDot({ status, live = false, className, }) {
|
|
29
|
+
return (_jsxs("span", { "aria-hidden": true, "data-slot": "status-dot", className: cn("relative inline-flex size-2 shrink-0", className), children: [live ? _jsx("span", { className: cn("absolute inset-0 live-ping rounded-full", DOT[status]) }) : null, _jsx("span", { className: cn("relative inline-flex size-full rounded-full", DOT[status]) })] }));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Un statut : point et libellé, jamais la couleur seule. Le libellé par défaut est celui de la
|
|
33
|
+
* famille ; un produit peut le préciser (« Panne partielle ») sans changer de couleur.
|
|
34
|
+
*/
|
|
35
|
+
export function StatusBadge({ status, label, live = false, className, }) {
|
|
36
|
+
return (_jsxs("span", { "data-slot": "status-badge", "data-status": status, className: cn("inline-flex w-fit items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap", BADGE[status], className), children: [_jsx(StatusDot, { status: status, live: live, className: "size-1.5" }), label ?? SERVICE_STATUS_LABEL[status]] }));
|
|
37
|
+
}
|
package/dist/table.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Tableau de données. Posé sur une carte `surface` par défaut (`surface={false}` dans une carte
|
|
5
|
+
* qui en a déjà une), il défile horizontalement dans son cadre sur téléphone : la page, elle,
|
|
6
|
+
* ne défile jamais de côté.
|
|
7
|
+
*/
|
|
8
|
+
declare function Table({ className, surface, containerClassName, ...props }: React.ComponentProps<"table"> & {
|
|
9
|
+
surface?: boolean;
|
|
10
|
+
containerClassName?: string;
|
|
11
|
+
}): React.JSX.Element;
|
|
12
|
+
declare function TableHeader({ className, ...props }: React.ComponentProps<"thead">): React.JSX.Element;
|
|
13
|
+
declare function TableBody({ className, ...props }: React.ComponentProps<"tbody">): React.JSX.Element;
|
|
14
|
+
/**
|
|
15
|
+
* Une ligne. Avec un `TableRowLink` dedans, toute la ligne devient cliquable : survol teinté,
|
|
16
|
+
* lien étiré sur la ligne. Le lien reste un vrai lien (Tab, Entrée, clic milieu, « ouvrir dans
|
|
17
|
+
* un nouvel onglet »), là où un `onClick` sur `<tr>` n'en offrirait aucun.
|
|
18
|
+
*/
|
|
19
|
+
declare function TableRow({ className, ...props }: React.ComponentProps<"tr">): React.JSX.Element;
|
|
20
|
+
export type SortDirection = "ascending" | "descending" | "none";
|
|
21
|
+
/**
|
|
22
|
+
* En-tête de colonne. Triable avec `onSort` : un bouton, l'état dans `aria-sort` (lu par les
|
|
23
|
+
* lecteurs d'écran) et une flèche. Le tri lui-même reste au produit, idéalement dans l'URL.
|
|
24
|
+
*/
|
|
25
|
+
declare function TableHead({ className, children, sort, onSort, numeric, ...props }: React.ComponentProps<"th"> & {
|
|
26
|
+
sort?: SortDirection;
|
|
27
|
+
onSort?: () => void;
|
|
28
|
+
/** Colonne de chiffres : alignée à droite, chiffres de largeur fixe. */
|
|
29
|
+
numeric?: boolean;
|
|
30
|
+
}): React.JSX.Element;
|
|
31
|
+
declare function TableCell({ className, numeric, ...props }: React.ComponentProps<"td"> & {
|
|
32
|
+
numeric?: boolean;
|
|
33
|
+
}): React.JSX.Element;
|
|
34
|
+
/**
|
|
35
|
+
* Le lien d'une ligne, dans sa première cellule : son libellé est le nom de la ressource, et son
|
|
36
|
+
* pseudo-élément couvre toute la ligne. `render={<Link to="…" />}` pour le routeur du produit.
|
|
37
|
+
*/
|
|
38
|
+
declare function TableRowLink({ className, render, ...props }: useRender.ComponentProps<"a">): React.JSX.Element;
|
|
39
|
+
/** Aucune ligne : une phrase, et de quoi en créer une. Occupe toute la largeur du tableau. */
|
|
40
|
+
declare function TableEmpty({ colSpan, children, className, }: {
|
|
41
|
+
colSpan: number;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
className?: string;
|
|
44
|
+
}): React.JSX.Element;
|
|
45
|
+
/** Lignes de chargement à la forme des vraies : même hauteur, même nombre de colonnes. */
|
|
46
|
+
declare function TableSkeleton({ rows, columns }: {
|
|
47
|
+
rows?: number;
|
|
48
|
+
columns: number;
|
|
49
|
+
}): React.JSX.Element;
|
|
50
|
+
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableRowLink, TableEmpty, TableSkeleton };
|
package/dist/table.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import ArrowDown01Icon from "@hugeicons/core-free-icons/ArrowDown01Icon";
|
|
3
|
+
import ArrowUp01Icon from "@hugeicons/core-free-icons/ArrowUp01Icon";
|
|
4
|
+
import ArrowUpDownIcon from "@hugeicons/core-free-icons/ArrowUpDownIcon";
|
|
5
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
6
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
7
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
8
|
+
import { cn } from "./cn.js";
|
|
9
|
+
import { Skeleton } from "./skeleton.js";
|
|
10
|
+
/**
|
|
11
|
+
* Tableau de données. Posé sur une carte `surface` par défaut (`surface={false}` dans une carte
|
|
12
|
+
* qui en a déjà une), il défile horizontalement dans son cadre sur téléphone : la page, elle,
|
|
13
|
+
* ne défile jamais de côté.
|
|
14
|
+
*/
|
|
15
|
+
function Table({ className, surface = true, containerClassName, ...props }) {
|
|
16
|
+
return (_jsx("div", { "data-slot": "table-container", className: cn("relative w-full overflow-x-auto", surface && "surface rounded-3xl", containerClassName), children: _jsx("table", { "data-slot": "table", className: cn("w-full caption-bottom border-collapse text-sm", className), ...props }) }));
|
|
17
|
+
}
|
|
18
|
+
function TableHeader({ className, ...props }) {
|
|
19
|
+
return _jsx("thead", { "data-slot": "table-header", className: cn(className), ...props });
|
|
20
|
+
}
|
|
21
|
+
function TableBody({ className, ...props }) {
|
|
22
|
+
return _jsx("tbody", { "data-slot": "table-body", className: cn(className), ...props });
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Une ligne. Avec un `TableRowLink` dedans, toute la ligne devient cliquable : survol teinté,
|
|
26
|
+
* lien étiré sur la ligne. Le lien reste un vrai lien (Tab, Entrée, clic milieu, « ouvrir dans
|
|
27
|
+
* un nouvel onglet »), là où un `onClick` sur `<tr>` n'en offrirait aucun.
|
|
28
|
+
*/
|
|
29
|
+
function TableRow({ className, ...props }) {
|
|
30
|
+
return (_jsx("tr", { "data-slot": "table-row", className: cn("relative border-t border-foreground/5 transition-colors duration-(--duration-fast) ease-out", "[[data-slot=table-header]_&]:border-t-0", "has-data-[slot=table-row-link]:hover:bg-foreground/[0.03] has-data-[slot=table-row-link]:has-focus-visible:bg-foreground/[0.03]", "data-[state=selected]:bg-foreground/[0.04]", className), ...props }));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* En-tête de colonne. Triable avec `onSort` : un bouton, l'état dans `aria-sort` (lu par les
|
|
34
|
+
* lecteurs d'écran) et une flèche. Le tri lui-même reste au produit, idéalement dans l'URL.
|
|
35
|
+
*/
|
|
36
|
+
function TableHead({ className, children, sort, onSort, numeric = false, ...props }) {
|
|
37
|
+
const sortable = onSort !== undefined;
|
|
38
|
+
const direction = sort ?? "none";
|
|
39
|
+
return (_jsx("th", { "data-slot": "table-head", scope: "col", "aria-sort": sortable ? direction : undefined, className: cn("h-11 px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground first:pl-5 last:pr-5", numeric && "text-right", className), ...props, children: sortable ? (_jsxs("button", { type: "button", onClick: onSort, className: cn("-mx-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-1 outline-none transition-colors duration-(--duration-fast) ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", direction !== "none" && "text-foreground", numeric && "flex-row-reverse"), children: [children, _jsx(HugeiconsIcon, { icon: direction === "ascending" ? ArrowUp01Icon : direction === "descending" ? ArrowDown01Icon : ArrowUpDownIcon, strokeWidth: 2, "aria-hidden": true, className: cn("size-3.5 shrink-0", direction === "none" && "opacity-50") })] })) : (children) }));
|
|
40
|
+
}
|
|
41
|
+
function TableCell({ className, numeric = false, ...props }) {
|
|
42
|
+
return (_jsx("td", { "data-slot": "table-cell", className: cn("px-4 py-3 align-middle first:pl-5 last:pr-5", numeric && "text-right tabular-nums",
|
|
43
|
+
// Un bouton ou un menu dans une ligne cliquable passe au-dessus du lien étiré.
|
|
44
|
+
"[&_a:not([data-slot=table-row-link])]:relative [&_a:not([data-slot=table-row-link])]:z-1 [&_button]:relative [&_button]:z-1", className), ...props }));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Le lien d'une ligne, dans sa première cellule : son libellé est le nom de la ressource, et son
|
|
48
|
+
* pseudo-élément couvre toute la ligne. `render={<Link to="…" />}` pour le routeur du produit.
|
|
49
|
+
*/
|
|
50
|
+
function TableRowLink({ className, render, ...props }) {
|
|
51
|
+
return useRender({
|
|
52
|
+
defaultTagName: "a",
|
|
53
|
+
render,
|
|
54
|
+
props: mergeProps({
|
|
55
|
+
"data-slot": "table-row-link",
|
|
56
|
+
className: cn("font-medium outline-none after:absolute after:inset-0 after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-ring/50 focus-visible:after:ring-inset", className),
|
|
57
|
+
}, props),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Aucune ligne : une phrase, et de quoi en créer une. Occupe toute la largeur du tableau. */
|
|
61
|
+
function TableEmpty({ colSpan, children, className, }) {
|
|
62
|
+
return (_jsx("tr", { "data-slot": "table-empty", children: _jsx("td", { colSpan: colSpan, className: cn("px-5 py-12 text-center text-sm text-muted-foreground", className), children: children }) }));
|
|
63
|
+
}
|
|
64
|
+
/** Lignes de chargement à la forme des vraies : même hauteur, même nombre de colonnes. */
|
|
65
|
+
function TableSkeleton({ rows = 5, columns }) {
|
|
66
|
+
return (_jsx(_Fragment, { children: Array.from({ length: rows }, (_, row) => (_jsx("tr", { "data-slot": "table-skeleton", "aria-hidden": true, className: "border-t border-foreground/5", children: Array.from({ length: columns }, (_, col) => (_jsx("td", { className: "px-4 py-3 first:pl-5 last:pr-5", children: _jsx(Skeleton, { className: cn("h-4 rounded-md", col === 0 ? "w-3/4" : "w-1/2") }) }, col))) }, row))) }));
|
|
67
|
+
}
|
|
68
|
+
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableRowLink, TableEmpty, TableSkeleton };
|
package/dist/tabs.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
|
|
2
|
+
/**
|
|
3
|
+
* Onglets d'une même page (Aperçu, Journal, Réglages). Flèches pour passer d'un onglet à
|
|
4
|
+
* l'autre, Tab pour entrer dans le panneau. L'onglet ouvert a sa place dans l'URL quand on
|
|
5
|
+
* partage la page : `value` et `onValueChange` contrôlés par le routeur, ou des liens
|
|
6
|
+
* (`render={<Link to="…" />}` et `nativeButton={false}` sur `TabsTab`).
|
|
7
|
+
*/
|
|
8
|
+
declare function Tabs({ className, ...props }: TabsPrimitive.Root.Props): React.JSX.Element;
|
|
9
|
+
/**
|
|
10
|
+
* La rangée d'onglets. L'onglet ouvert est marqué d'une pastille teintée qui glisse de l'un à
|
|
11
|
+
* l'autre, monochrome comme la barre latérale : l'accent reste au focus.
|
|
12
|
+
*/
|
|
13
|
+
declare function TabsList({ className, children, ...props }: TabsPrimitive.List.Props): React.JSX.Element;
|
|
14
|
+
declare function TabsTab({ className, ...props }: TabsPrimitive.Tab.Props): React.JSX.Element;
|
|
15
|
+
declare function TabsPanel({ className, ...props }: TabsPrimitive.Panel.Props): React.JSX.Element;
|
|
16
|
+
export { Tabs, TabsList, TabsTab, TabsPanel };
|
package/dist/tabs.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
/**
|
|
5
|
+
* Onglets d'une même page (Aperçu, Journal, Réglages). Flèches pour passer d'un onglet à
|
|
6
|
+
* l'autre, Tab pour entrer dans le panneau. L'onglet ouvert a sa place dans l'URL quand on
|
|
7
|
+
* partage la page : `value` et `onValueChange` contrôlés par le routeur, ou des liens
|
|
8
|
+
* (`render={<Link to="…" />}` et `nativeButton={false}` sur `TabsTab`).
|
|
9
|
+
*/
|
|
10
|
+
function Tabs({ className, ...props }) {
|
|
11
|
+
return _jsx(TabsPrimitive.Root, { "data-slot": "tabs", className: cn("grid gap-6", className), ...props });
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* La rangée d'onglets. L'onglet ouvert est marqué d'une pastille teintée qui glisse de l'un à
|
|
15
|
+
* l'autre, monochrome comme la barre latérale : l'accent reste au focus.
|
|
16
|
+
*/
|
|
17
|
+
function TabsList({ className, children, ...props }) {
|
|
18
|
+
return (_jsxs(TabsPrimitive.List, { "data-slot": "tabs-list", className: cn("relative z-0 flex w-fit max-w-full items-center gap-1 overflow-x-auto [scrollbar-width:none]", className), ...props, children: [children, _jsx(TabsPrimitive.Indicator, { "data-slot": "tabs-indicator", className: "absolute top-(--active-tab-top) left-0 -z-1 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) rounded-full bg-foreground/[0.07] transition-[translate,width] duration-(--duration-base) ease-out motion-reduce:transition-none" })] }));
|
|
19
|
+
}
|
|
20
|
+
function TabsTab({ className, ...props }) {
|
|
21
|
+
return (_jsx(TabsPrimitive.Tab, { "data-slot": "tabs-tab", className: cn("touch-target flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-full px-3.5 text-sm font-medium whitespace-nowrap text-muted-foreground", "transition-colors duration-(--duration-fast) ease-out outline-none select-none", "hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground", "data-disabled:pointer-events-none data-disabled:opacity-50", "[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props }));
|
|
22
|
+
}
|
|
23
|
+
function TabsPanel({ className, ...props }) {
|
|
24
|
+
return (_jsx(TabsPrimitive.Panel, { "data-slot": "tabs-panel", className: cn("rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ring/50", className), ...props }));
|
|
25
|
+
}
|
|
26
|
+
export { Tabs, TabsList, TabsTab, TabsPanel };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type TimeSeries = {
|
|
2
|
+
/** Identifiant stable : la couleur suit la série, jamais son rang dans un filtre. */
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** Une valeur par horodatage de `timestamps` ; `null` : pas de mesure (la ligne s'interrompt). */
|
|
6
|
+
values: Array<number | null>;
|
|
7
|
+
};
|
|
8
|
+
/** Six teintes de la DA (`--chart-1` à `--chart-6`), dans l'ordre, jamais recyclées. */
|
|
9
|
+
export declare const CHART_COLORS: ReadonlyArray<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Un maximum « rond » pour l'axe des valeurs, et ses graduations : 0, 25, 50, 75, 100 plutôt que
|
|
12
|
+
* 0, 23,7, 47,4. `ticks` est le nombre d'intervalles visé.
|
|
13
|
+
*/
|
|
14
|
+
export declare function niceScale(max: number, ticks?: number): {
|
|
15
|
+
max: number;
|
|
16
|
+
step: number;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Série temporelle en SVG, sans bibliothèque : lignes (aire sous la courbe pour une série seule)
|
|
20
|
+
* ou barres empilées. Le SVG s'étire à la largeur de son conteneur ; les traits gardent 2 px
|
|
21
|
+
* (`non-scaling-stroke`) et les textes restent en HTML, nets à toute taille.
|
|
22
|
+
*
|
|
23
|
+
* Survol ou flèches du clavier : un repère vertical et une infobulle avec chaque valeur. Les
|
|
24
|
+
* lecteurs d'écran ont un tableau des données. Légende dès deux séries.
|
|
25
|
+
*/
|
|
26
|
+
export declare function TimeChart({ timestamps, series, kind, height, compact, label, formatValue, formatTime, emptyLabel, className, }: {
|
|
27
|
+
timestamps: Array<number>;
|
|
28
|
+
series: Array<TimeSeries>;
|
|
29
|
+
kind?: "line" | "bar";
|
|
30
|
+
/** Hauteur de la zone de tracé, en pixels. */
|
|
31
|
+
height?: number;
|
|
32
|
+
/** Sans axes, grille ni légende : la courbe seule, sous un `StatTile`. */
|
|
33
|
+
compact?: boolean;
|
|
34
|
+
/** Ce que montre le graphique, pour les lecteurs d'écran : « Envois par jour ». */
|
|
35
|
+
label: string;
|
|
36
|
+
formatValue?: (value: number) => string;
|
|
37
|
+
formatTime?: (time: number) => string;
|
|
38
|
+
emptyLabel?: string;
|
|
39
|
+
className?: string;
|
|
40
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
/** Six teintes de la DA (`--chart-1` à `--chart-6`), dans l'ordre, jamais recyclées. */
|
|
5
|
+
export const CHART_COLORS = [
|
|
6
|
+
"var(--chart-1)",
|
|
7
|
+
"var(--chart-2)",
|
|
8
|
+
"var(--chart-3)",
|
|
9
|
+
"var(--chart-4)",
|
|
10
|
+
"var(--chart-5)",
|
|
11
|
+
"var(--chart-6)",
|
|
12
|
+
];
|
|
13
|
+
const number = new Intl.NumberFormat("fr-CH");
|
|
14
|
+
const dayMonth = new Intl.DateTimeFormat("fr-CH", { day: "numeric", month: "short" });
|
|
15
|
+
/**
|
|
16
|
+
* Un maximum « rond » pour l'axe des valeurs, et ses graduations : 0, 25, 50, 75, 100 plutôt que
|
|
17
|
+
* 0, 23,7, 47,4. `ticks` est le nombre d'intervalles visé.
|
|
18
|
+
*/
|
|
19
|
+
export function niceScale(max, ticks = 4) {
|
|
20
|
+
if (!(max > 0))
|
|
21
|
+
return { max: 1, step: 0.25 };
|
|
22
|
+
const raw = max / ticks;
|
|
23
|
+
const magnitude = 10 ** Math.floor(Math.log10(raw));
|
|
24
|
+
const residual = raw / magnitude;
|
|
25
|
+
const nice = residual <= 1 ? 1 : residual <= 2 ? 2 : residual <= 2.5 ? 2.5 : residual <= 5 ? 5 : 10;
|
|
26
|
+
const step = nice * magnitude;
|
|
27
|
+
return { max: Math.ceil(max / step) * step, step };
|
|
28
|
+
}
|
|
29
|
+
function linePath(values, max) {
|
|
30
|
+
let d = "";
|
|
31
|
+
let open = false;
|
|
32
|
+
values.forEach((v, i) => {
|
|
33
|
+
if (v === null) {
|
|
34
|
+
open = false;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const x = i + 0.5;
|
|
38
|
+
const y = 100 - (v / max) * 100;
|
|
39
|
+
d += `${open ? "L" : "M"}${x} ${y}`;
|
|
40
|
+
open = true;
|
|
41
|
+
});
|
|
42
|
+
return d;
|
|
43
|
+
}
|
|
44
|
+
function areaPath(values, max) {
|
|
45
|
+
// Une aire par segment continu, fermée sur l'axe.
|
|
46
|
+
const parts = [];
|
|
47
|
+
let start = -1;
|
|
48
|
+
const close = (end) => {
|
|
49
|
+
if (start < 0)
|
|
50
|
+
return;
|
|
51
|
+
const segment = values.slice(start, end);
|
|
52
|
+
const top = segment
|
|
53
|
+
.map((v, j) => `${j === 0 ? "M" : "L"}${start + j + 0.5} ${100 - ((v ?? 0) / max) * 100}`)
|
|
54
|
+
.join("");
|
|
55
|
+
parts.push(`${top}L${end - 1 + 0.5} 100L${start + 0.5} 100Z`);
|
|
56
|
+
start = -1;
|
|
57
|
+
};
|
|
58
|
+
values.forEach((v, i) => {
|
|
59
|
+
if (v === null)
|
|
60
|
+
close(i);
|
|
61
|
+
else if (start < 0)
|
|
62
|
+
start = i;
|
|
63
|
+
});
|
|
64
|
+
close(values.length);
|
|
65
|
+
return parts.join("");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Série temporelle en SVG, sans bibliothèque : lignes (aire sous la courbe pour une série seule)
|
|
69
|
+
* ou barres empilées. Le SVG s'étire à la largeur de son conteneur ; les traits gardent 2 px
|
|
70
|
+
* (`non-scaling-stroke`) et les textes restent en HTML, nets à toute taille.
|
|
71
|
+
*
|
|
72
|
+
* Survol ou flèches du clavier : un repère vertical et une infobulle avec chaque valeur. Les
|
|
73
|
+
* lecteurs d'écran ont un tableau des données. Légende dès deux séries.
|
|
74
|
+
*/
|
|
75
|
+
export function TimeChart({ timestamps, series, kind = "line", height = 200, compact = false, label, formatValue = (v) => number.format(v), formatTime = (t) => dayMonth.format(t), emptyLabel = "Aucune donnée sur cette période.", className, }) {
|
|
76
|
+
const [active, setActive] = useState(null);
|
|
77
|
+
const n = timestamps.length;
|
|
78
|
+
const hasData = n > 0 && series.some((s) => s.values.some((v) => v !== null));
|
|
79
|
+
if (!hasData)
|
|
80
|
+
return (_jsx("div", { "data-slot": "time-chart", "data-state": "empty", style: { height }, className: cn("grid place-items-center rounded-2xl bg-foreground/[0.03] text-sm text-muted-foreground", className), children: emptyLabel }));
|
|
81
|
+
const totals = timestamps.map((_, i) => kind === "bar"
|
|
82
|
+
? series.reduce((sum, s) => sum + (s.values[i] ?? 0), 0)
|
|
83
|
+
: Math.max(0, ...series.map((s) => s.values[i] ?? 0)));
|
|
84
|
+
const scale = niceScale(Math.max(0, ...totals));
|
|
85
|
+
const ticks = Array.from({ length: Math.round(scale.max / scale.step) + 1 }, (_, i) => i * scale.step);
|
|
86
|
+
const color = (index) => CHART_COLORS[index % CHART_COLORS.length] ?? "currentColor";
|
|
87
|
+
const indexFromPointer = (event) => {
|
|
88
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
89
|
+
const ratio = (event.clientX - rect.left) / rect.width;
|
|
90
|
+
return Math.min(n - 1, Math.max(0, Math.floor(ratio * n)));
|
|
91
|
+
};
|
|
92
|
+
const onKeyDown = (event) => {
|
|
93
|
+
const moves = { ArrowLeft: -1, ArrowRight: 1, Home: -n, End: n };
|
|
94
|
+
if (event.key === "Escape")
|
|
95
|
+
return setActive(null);
|
|
96
|
+
const move = moves[event.key];
|
|
97
|
+
if (move === undefined)
|
|
98
|
+
return;
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
setActive((current) => Math.min(n - 1, Math.max(0, (current ?? (move > 0 ? -1 : n)) + move)));
|
|
101
|
+
};
|
|
102
|
+
const activeTime = active !== null ? timestamps[active] : undefined;
|
|
103
|
+
const describe = (i) => {
|
|
104
|
+
const t = timestamps[i];
|
|
105
|
+
const values = series
|
|
106
|
+
.map((s) => `${s.label} ${s.values[i] == null ? "pas de mesure" : formatValue(s.values[i] ?? 0)}`)
|
|
107
|
+
.join(", ");
|
|
108
|
+
return t === undefined ? values : `${formatTime(t)} : ${values}`;
|
|
109
|
+
};
|
|
110
|
+
const xLabels = compact ? [] : [0, Math.floor((n - 1) / 2), n - 1].filter((v, i, a) => a.indexOf(v) === i);
|
|
111
|
+
return (_jsxs("figure", { "data-slot": "time-chart", className: cn("grid gap-3", className), children: [!compact && series.length > 1 ? (_jsx("figcaption", { className: "flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground", children: series.map((s, i) => (_jsxs("span", { className: "inline-flex items-center gap-1.5", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 rounded-[3px]", style: { background: color(i) } }), s.label] }, s.key))) })) : null, _jsxs("div", { className: cn("grid", !compact && "grid-cols-[auto_minmax(0,1fr)] gap-x-2"), children: [!compact ? (_jsx("div", { "aria-hidden": true, className: "relative w-10 text-right text-[11px] text-muted-foreground tabular-nums", style: { height }, children: ticks.map((t) => (_jsx("span", { className: "absolute right-0 -translate-y-1/2", style: { top: `${100 - (t / scale.max) * 100}%` }, children: formatValue(t) }, t))) })) : null, _jsxs("div", { className: "grid gap-1.5", children: [_jsxs("div", {
|
|
112
|
+
// Un curseur sur le temps : les flèches le déplacent d'un point, et chaque position se
|
|
113
|
+
// lit en entier (date et valeurs).
|
|
114
|
+
role: "slider", tabIndex: 0, "aria-label": label, "aria-orientation": "horizontal", "aria-valuemin": 0, "aria-valuemax": n - 1, "aria-valuenow": active ?? n - 1, "aria-valuetext": describe(active ?? n - 1), onPointerMove: (event) => setActive(indexFromPointer(event)), onPointerLeave: () => setActive(null), onKeyDown: onKeyDown, onBlur: () => setActive(null), className: "relative rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50", style: { height }, children: [_jsxs("svg", { viewBox: `0 0 ${n} 100`, preserveAspectRatio: "none", "aria-hidden": true, className: "absolute inset-0 size-full overflow-visible", children: [!compact
|
|
115
|
+
? ticks.map((t) => (_jsx("line", { x1: 0, x2: n, y1: 100 - (t / scale.max) * 100, y2: 100 - (t / scale.max) * 100, vectorEffect: "non-scaling-stroke", className: t === 0 ? "stroke-foreground/15" : "stroke-foreground/[0.06]", strokeWidth: 1 }, t)))
|
|
116
|
+
: null, kind === "bar"
|
|
117
|
+
? timestamps.map((_, i) => {
|
|
118
|
+
let base = 100;
|
|
119
|
+
return series.map((s, si) => {
|
|
120
|
+
const v = s.values[i] ?? 0;
|
|
121
|
+
const h = (v / scale.max) * 100;
|
|
122
|
+
base -= h;
|
|
123
|
+
return v > 0 ? (_jsx("rect", { x: i + 0.15, y: base, width: 0.7, height: h, fill: color(si), opacity: active === null || active === i ? 1 : 0.55 }, `${s.key}-${i}`)) : null;
|
|
124
|
+
});
|
|
125
|
+
})
|
|
126
|
+
: series.map((s, si) => (_jsxs("g", { children: [series.length === 1 ? (_jsx("path", { d: areaPath(s.values, scale.max), fill: color(si), opacity: 0.1 })) : null, _jsx("path", { d: linePath(s.values, scale.max), fill: "none", stroke: color(si), strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round", vectorEffect: "non-scaling-stroke" })] }, s.key))), active !== null ? (_jsx("line", { x1: active + 0.5, x2: active + 0.5, y1: 0, y2: 100, vectorEffect: "non-scaling-stroke", className: "stroke-foreground/25", strokeWidth: 1 })) : null] }), active !== null && activeTime !== undefined ? (_jsxs("div", { "data-slot": "time-chart-tooltip", className: cn("pointer-events-none absolute top-0 z-10 grid min-w-36 gap-1 rounded-xl bg-popover px-3 py-2 text-xs shadow-lg shadow-black/10 [--popover:var(--floating)]", (active + 0.5) / n > 0.6 ? "-translate-x-[calc(100%+0.75rem)]" : "translate-x-3"), style: { left: `${((active + 0.5) / n) * 100}%` }, children: [_jsx("p", { className: "font-medium", children: formatTime(activeTime) }), series.map((s, si) => (_jsxs("p", { className: "flex items-center gap-2", children: [_jsx("span", { "aria-hidden": true, className: "size-2 shrink-0 rounded-[2px]", style: { background: color(si) } }), _jsx("span", { className: "flex-1 text-muted-foreground", children: s.label }), _jsx("span", { className: "font-medium tabular-nums", children: s.values[active] === null ? "-" : formatValue(s.values[active] ?? 0) })] }, s.key)))] })) : null] }), xLabels.length > 0 ? (_jsx("div", { "aria-hidden": true, className: "relative h-4 text-[11px] text-muted-foreground tabular-nums", children: xLabels.map((i, k) => (_jsx("span", { className: cn("absolute", k === 0 ? "left-0" : k === xLabels.length - 1 ? "right-0" : "-translate-x-1/2"), style: k === 0 || k === xLabels.length - 1 ? undefined : { left: `${((i + 0.5) / n) * 100}%` }, children: timestamps[i] !== undefined ? formatTime(timestamps[i]) : null }, i))) })) : null] })] }), _jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Date" }), series.map((s) => (_jsx("th", { scope: "col", children: s.label }, s.key)))] }) }), _jsx("tbody", { children: timestamps.map((t, i) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatTime(t) }), series.map((s) => (_jsx("td", { children: s.values[i] === null ? "-" : formatValue(s.values[i] ?? 0) }, s.key)))] }, t))) })] })] }));
|
|
127
|
+
}
|