@hgdc/ui 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -24
- package/dist/account-menu.d.ts +15 -2
- package/dist/account-menu.js +8 -2
- package/dist/address-fields.d.ts +50 -0
- package/dist/address-fields.js +79 -0
- package/dist/color-field.d.ts +32 -0
- package/dist/color-field.js +80 -0
- package/dist/command-menu.d.ts +4 -2
- package/dist/command-menu.js +5 -3
- package/dist/copy-button.d.ts +5 -2
- package/dist/copy-button.js +5 -2
- package/dist/date-range-picker.d.ts +50 -0
- package/dist/date-range-picker.js +118 -0
- package/dist/date-time-field.d.ts +18 -0
- package/dist/date-time-field.js +31 -0
- package/dist/money-field.d.ts +31 -0
- package/dist/money-field.js +98 -0
- package/dist/products.d.ts +44 -0
- package/dist/products.js +47 -0
- package/dist/progress.d.ts +38 -0
- package/dist/progress.js +31 -0
- package/dist/rank-bar.d.ts +28 -0
- package/dist/rank-bar.js +31 -0
- package/dist/settings-section.d.ts +40 -0
- package/dist/settings-section.js +48 -0
- package/dist/status.d.ts +31 -8
- package/dist/status.js +19 -3
- package/dist/time-chart.d.ts +7 -1
- package/dist/time-chart.js +6 -4
- package/package.json +1 -1
- package/styles.css +19 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Calendar03Icon from "@hugeicons/core-free-icons/Calendar03Icon";
|
|
3
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
4
|
+
import { useId, useState } from "react";
|
|
5
|
+
import { Button } from "./button.js";
|
|
6
|
+
import { cn } from "./cn.js";
|
|
7
|
+
import { Input } from "./input.js";
|
|
8
|
+
import { Label } from "./label.js";
|
|
9
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
|
|
10
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
11
|
+
/** Une date ISO (`2026-10-15`) en `Date` locale à minuit, ou `null` si elle n'existe pas (31 février). */
|
|
12
|
+
export function parseIsoDate(value) {
|
|
13
|
+
const match = ISO_DATE.exec(value);
|
|
14
|
+
if (!match)
|
|
15
|
+
return null;
|
|
16
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
17
|
+
const date = new Date(year, month - 1, day);
|
|
18
|
+
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day ? date : null;
|
|
19
|
+
}
|
|
20
|
+
/** Une `Date` en date ISO, dans le fuseau du navigateur. */
|
|
21
|
+
export function toIsoDate(date) {
|
|
22
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
23
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
24
|
+
}
|
|
25
|
+
function shift(iso, days) {
|
|
26
|
+
const date = parseIsoDate(iso) ?? new Date();
|
|
27
|
+
date.setDate(date.getDate() + days);
|
|
28
|
+
return toIsoDate(date);
|
|
29
|
+
}
|
|
30
|
+
function lastDays(days) {
|
|
31
|
+
return (today) => ({ from: shift(today, -(days - 1)), to: today });
|
|
32
|
+
}
|
|
33
|
+
/** Périodes proposées par défaut : 7, 30 et 90 jours, ce mois-ci, le mois dernier. */
|
|
34
|
+
export const DEFAULT_DATE_RANGE_PRESETS = [
|
|
35
|
+
{ key: "7d", label: "7 derniers jours", range: lastDays(7) },
|
|
36
|
+
{ key: "30d", label: "30 derniers jours", range: lastDays(30) },
|
|
37
|
+
{ key: "90d", label: "90 derniers jours", range: lastDays(90) },
|
|
38
|
+
{ key: "month", label: "Ce mois-ci", range: (today) => ({ from: `${today.slice(0, 8)}01`, to: today }) },
|
|
39
|
+
{
|
|
40
|
+
key: "last-month",
|
|
41
|
+
label: "Le mois dernier",
|
|
42
|
+
range: (today) => {
|
|
43
|
+
const first = parseIsoDate(`${today.slice(0, 8)}01`) ?? new Date();
|
|
44
|
+
const end = new Date(first.getFullYear(), first.getMonth(), 0);
|
|
45
|
+
return { from: toIsoDate(new Date(end.getFullYear(), end.getMonth(), 1)), to: toIsoDate(end) };
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
const rangeFormat = new Intl.DateTimeFormat("fr-CH", { day: "numeric", month: "short", year: "numeric" });
|
|
50
|
+
/**
|
|
51
|
+
* « 1 au 15 oct. 2026 », « 28 sept. au 4 oct. 2026 » : le format de période d'`Intl`, son tiret
|
|
52
|
+
* remplacé par « au », plus lisible et sans tiret long dans l'interface.
|
|
53
|
+
*/
|
|
54
|
+
export function formatDateRange(range) {
|
|
55
|
+
const from = parseIsoDate(range.from);
|
|
56
|
+
const to = parseIsoDate(range.to);
|
|
57
|
+
if (!from || !to)
|
|
58
|
+
return "Période invalide";
|
|
59
|
+
if (range.from === range.to)
|
|
60
|
+
return rangeFormat.format(from);
|
|
61
|
+
return rangeFormat.formatRange(from, to).replace(/\s*[–—-]\s*/u, " au ");
|
|
62
|
+
}
|
|
63
|
+
/** Erreur d'une période saisie, ou `null` si elle tient. */
|
|
64
|
+
export function validateDateRange(range, bounds = {}) {
|
|
65
|
+
if (!parseIsoDate(range.from) || !parseIsoDate(range.to))
|
|
66
|
+
return "Choisis une date de début et une date de fin.";
|
|
67
|
+
if (range.from > range.to)
|
|
68
|
+
return "La date de début doit précéder la date de fin.";
|
|
69
|
+
if (bounds.min && range.from < bounds.min)
|
|
70
|
+
return `Les données commencent le ${formatDateRange({ from: bounds.min, to: bounds.min })}.`;
|
|
71
|
+
if (bounds.max && range.to > bounds.max)
|
|
72
|
+
return `Choisis une fin au plus tard le ${formatDateRange({ from: bounds.max, to: bounds.max })}.`;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Choix d'une période pour un tableau de bord : des raccourcis (7, 30, 90 jours, mois) et une
|
|
77
|
+
* période libre en deux champs de date. Les champs sont ceux du navigateur, sans calendrier
|
|
78
|
+
* maison : clavier, lecteurs d'écran et sélecteur natif du téléphone fonctionnent d'office.
|
|
79
|
+
* Contrôlé : la période vit dans l'URL du produit.
|
|
80
|
+
*/
|
|
81
|
+
export function DateRangePicker({ value, onChange, presets = DEFAULT_DATE_RANGE_PRESETS, today: todayProp, min, max, label = "Période", className, }) {
|
|
82
|
+
// La date du navigateur, lue une fois au montage : un rendu ne doit pas dépendre de l'horloge.
|
|
83
|
+
const [browserToday] = useState(() => toIsoDate(new Date()));
|
|
84
|
+
const today = todayProp ?? browserToday;
|
|
85
|
+
const upper = max ?? today;
|
|
86
|
+
const [open, setOpen] = useState(false);
|
|
87
|
+
const [draft, setDraft] = useState(value);
|
|
88
|
+
const [error, setError] = useState(null);
|
|
89
|
+
const id = useId();
|
|
90
|
+
const activePreset = presets.find((p) => {
|
|
91
|
+
const r = p.range(today);
|
|
92
|
+
return r.from === value.from && r.to === value.to;
|
|
93
|
+
});
|
|
94
|
+
const summary = activePreset?.label ?? formatDateRange(value);
|
|
95
|
+
const onOpenChange = (next) => {
|
|
96
|
+
if (next) {
|
|
97
|
+
setDraft(value);
|
|
98
|
+
setError(null);
|
|
99
|
+
}
|
|
100
|
+
setOpen(next);
|
|
101
|
+
};
|
|
102
|
+
const apply = (event) => {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
const problem = validateDateRange(draft, { ...(min ? { min } : {}), max: upper });
|
|
105
|
+
setError(problem);
|
|
106
|
+
if (problem)
|
|
107
|
+
return;
|
|
108
|
+
onChange(draft, null);
|
|
109
|
+
setOpen(false);
|
|
110
|
+
};
|
|
111
|
+
return (_jsxs(Popover, { open: open, onOpenChange: onOpenChange, children: [_jsxs(PopoverTrigger, { render: _jsx(Button, { variant: "outline", size: "sm", className: cn("justify-start", className) }), "aria-label": `${label} : ${summary}`, children: [_jsx(HugeiconsIcon, { icon: Calendar03Icon, "aria-hidden": true, "data-icon": "inline-start" }), _jsx("span", { className: "truncate", children: summary })] }), _jsx(PopoverContent, { align: "start", className: "w-[min(36rem,calc(100vw-1rem))] gap-0 p-0", "aria-label": label, children: _jsxs("div", { className: "grid sm:grid-cols-[12rem_1fr]", children: [_jsx("ul", { "aria-label": "Raccourcis", className: "grid content-start gap-0.5 border-b border-border p-1.5 sm:border-r sm:border-b-0", children: presets.map((preset) => {
|
|
112
|
+
const pressed = preset.key === activePreset?.key;
|
|
113
|
+
return (_jsx("li", { children: _jsx("button", { type: "button", "aria-pressed": pressed, onClick: () => {
|
|
114
|
+
onChange(preset.range(today), preset.key);
|
|
115
|
+
setOpen(false);
|
|
116
|
+
}, className: cn("flex min-h-10 w-full items-center rounded-xl px-3 text-left text-sm outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 lg:min-h-9", pressed && "bg-muted font-medium"), children: preset.label }) }, preset.key));
|
|
117
|
+
}) }), _jsxs("form", { onSubmit: apply, noValidate: true, className: "grid content-start gap-3 p-4", children: [_jsx("p", { className: "text-sm font-medium", children: "P\u00E9riode libre" }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-from`, children: "Du" }), _jsx(Input, { id: `${id}-from`, type: "date", value: draft.from, ...(min ? { min } : {}), max: upper, "aria-invalid": error ? true : undefined, "aria-describedby": error ? `${id}-error` : undefined, onChange: (event) => setDraft((d) => ({ ...d, from: event.target.value })) })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-to`, children: "Au" }), _jsx(Input, { id: `${id}-to`, type: "date", value: draft.to, ...(min ? { min } : {}), max: upper, "aria-invalid": error ? true : undefined, "aria-describedby": error ? `${id}-error` : undefined, onChange: (event) => setDraft((d) => ({ ...d, to: event.target.value })) })] })] }), error ? (_jsx("p", { id: `${id}-error`, role: "alert", className: "soft-in text-sm text-destructive", children: error })) : null, _jsx(Button, { type: "submit", size: "sm", className: "justify-self-end", children: "Appliquer" })] })] }) })] }));
|
|
118
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Un instant (millisecondes) en valeur de `<input type="datetime-local">`, à la minute, dans le fuseau du navigateur. */
|
|
2
|
+
export declare function toDateTimeLocal(time: number): string;
|
|
3
|
+
/** La valeur d'un champ `datetime-local` en instant (millisecondes), ou `null` si elle est vide ou invalide. */
|
|
4
|
+
export declare function fromDateTimeLocal(value: string): number | null;
|
|
5
|
+
/**
|
|
6
|
+
* Date et heure d'un envoi programmé, d'une fenêtre de maintenance : le champ du navigateur
|
|
7
|
+
* (sélecteur natif sur téléphone, clavier, lecteurs d'écran), avec la valeur en millisecondes
|
|
8
|
+
* comme Convex la stocke. L'heure est celle du navigateur, à la minute.
|
|
9
|
+
*
|
|
10
|
+
* Dans un `Field`, le libellé et la description se relient tout seuls.
|
|
11
|
+
*/
|
|
12
|
+
export declare function DateTimeField({ value, onChange, min, max, className, ...props }: Omit<React.ComponentProps<"input">, "type" | "value" | "onChange" | "min" | "max" | "defaultValue"> & {
|
|
13
|
+
value: number | null;
|
|
14
|
+
onChange: (value: number | null) => void;
|
|
15
|
+
/** Instant le plus tôt accepté (millisecondes) : `Date.now()` pour un envoi programmé. */
|
|
16
|
+
min?: number;
|
|
17
|
+
max?: number;
|
|
18
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Input } from "./input.js";
|
|
3
|
+
/** Un instant (millisecondes) en valeur de `<input type="datetime-local">`, à la minute, dans le fuseau du navigateur. */
|
|
4
|
+
export function toDateTimeLocal(time) {
|
|
5
|
+
const date = new Date(time);
|
|
6
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
7
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
8
|
+
}
|
|
9
|
+
const LOCAL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
|
|
10
|
+
/** La valeur d'un champ `datetime-local` en instant (millisecondes), ou `null` si elle est vide ou invalide. */
|
|
11
|
+
export function fromDateTimeLocal(value) {
|
|
12
|
+
const match = LOCAL.exec(value);
|
|
13
|
+
if (!match)
|
|
14
|
+
return null;
|
|
15
|
+
const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0));
|
|
16
|
+
const date = new Date(year, month - 1, day, hour, minute, second);
|
|
17
|
+
// `Date` corrige un 31 février en 3 mars : une date qui n'existe pas est refusée.
|
|
18
|
+
if (date.getMonth() !== month - 1 || date.getDate() !== day)
|
|
19
|
+
return null;
|
|
20
|
+
return date.getTime();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Date et heure d'un envoi programmé, d'une fenêtre de maintenance : le champ du navigateur
|
|
24
|
+
* (sélecteur natif sur téléphone, clavier, lecteurs d'écran), avec la valeur en millisecondes
|
|
25
|
+
* comme Convex la stocke. L'heure est celle du navigateur, à la minute.
|
|
26
|
+
*
|
|
27
|
+
* Dans un `Field`, le libellé et la description se relient tout seuls.
|
|
28
|
+
*/
|
|
29
|
+
export function DateTimeField({ value, onChange, min, max, className, ...props }) {
|
|
30
|
+
return (_jsx(Input, { ...props, type: "datetime-local", step: 60, value: value === null ? "" : toDateTimeLocal(value), ...(min !== undefined ? { min: toDateTimeLocal(min) } : {}), ...(max !== undefined ? { max: toDateTimeLocal(max) } : {}), onChange: (event) => onChange(fromDateTimeLocal(event.target.value)), ...(className !== undefined ? { className } : {}) }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Des centimes en montant suisse : `125050` → « 1’250.50 », `-500` → « -5.00 ». */
|
|
2
|
+
export declare function formatMoney(centimes: number): string;
|
|
3
|
+
/** Comme `formatMoney`, précédé de la devise : « CHF 1’250.50 ». */
|
|
4
|
+
export declare function formatChf(centimes: number): string;
|
|
5
|
+
export type MoneyParse = {
|
|
6
|
+
ok: true;
|
|
7
|
+
centimes: number | null;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Lit un montant saisi, en centimes, sans passer par les flottants. Accepte les écritures d'ici :
|
|
13
|
+
* « 1’250.50 », « 1'250,50 », « 1 250.5 », « CHF 12.– », « 12.- ». Vide : `centimes: null`.
|
|
14
|
+
* Plus de deux décimales, deux virgules ou des lettres : `ok: false`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseMoney(text: string): MoneyParse;
|
|
17
|
+
/**
|
|
18
|
+
* Un montant en francs suisses, stocké en centimes (entier) : prix, budget, seuil d'alerte. Le
|
|
19
|
+
* champ accepte les écritures courantes (apostrophe, virgule, « 12.– ») et se remet au format
|
|
20
|
+
* suisse en quittant le champ. Clavier numérique sur téléphone.
|
|
21
|
+
*
|
|
22
|
+
* Une saisie invalide ou hors bornes n'appelle pas `onChange` et s'explique sous le champ.
|
|
23
|
+
*/
|
|
24
|
+
export declare function MoneyField({ value, onChange, min, max, className, id: idProp, ...props }: Omit<React.ComponentProps<"input">, "value" | "onChange" | "min" | "max" | "type" | "defaultValue"> & {
|
|
25
|
+
/** En centimes ; `null` : vide. */
|
|
26
|
+
value: number | null;
|
|
27
|
+
onChange: (centimes: number | null) => void;
|
|
28
|
+
/** En centimes, `0` par défaut (pas de montant négatif) ; `null` : sans borne. */
|
|
29
|
+
min?: number | null;
|
|
30
|
+
max?: number;
|
|
31
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId, useState } from "react";
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { useFieldDescribedBy, useFieldId } from "./field.js";
|
|
5
|
+
import { Input } from "./input.js";
|
|
6
|
+
/** Apostrophe typographique, séparateur des milliers en Suisse : « 1’250.50 ». */
|
|
7
|
+
const GROUP = "’";
|
|
8
|
+
/** Des centimes en montant suisse : `125050` → « 1’250.50 », `-500` → « -5.00 ». */
|
|
9
|
+
export function formatMoney(centimes) {
|
|
10
|
+
const sign = centimes < 0 ? "-" : "";
|
|
11
|
+
const abs = Math.abs(Math.round(centimes));
|
|
12
|
+
const francs = String(Math.floor(abs / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, GROUP);
|
|
13
|
+
return `${sign}${francs}.${String(abs % 100).padStart(2, "0")}`;
|
|
14
|
+
}
|
|
15
|
+
/** Comme `formatMoney`, précédé de la devise : « CHF 1’250.50 ». */
|
|
16
|
+
export function formatChf(centimes) {
|
|
17
|
+
return `CHF ${formatMoney(centimes)}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Lit un montant saisi, en centimes, sans passer par les flottants. Accepte les écritures d'ici :
|
|
21
|
+
* « 1’250.50 », « 1'250,50 », « 1 250.5 », « CHF 12.– », « 12.- ». Vide : `centimes: null`.
|
|
22
|
+
* Plus de deux décimales, deux virgules ou des lettres : `ok: false`.
|
|
23
|
+
*/
|
|
24
|
+
export function parseMoney(text) {
|
|
25
|
+
const compact = text
|
|
26
|
+
.replace(/^\s*(CHF|Fr\.)\s*/i, "")
|
|
27
|
+
// `\s` couvre aussi les espaces insécables qu'Intl met entre les milliers.
|
|
28
|
+
.replace(/[\s'’]/g, "")
|
|
29
|
+
.replace(/\.[-–—]$/, "")
|
|
30
|
+
.replace(",", ".");
|
|
31
|
+
if (compact === "")
|
|
32
|
+
return { ok: true, centimes: null };
|
|
33
|
+
const match = /^(-?)(\d*)(?:\.(\d{0,2}))?$/.exec(compact);
|
|
34
|
+
if (!match || (match[2] === "" && !match[3]))
|
|
35
|
+
return { ok: false };
|
|
36
|
+
const francs = Number(match[2] || "0");
|
|
37
|
+
const cents = Number((match[3] ?? "").padEnd(2, "0"));
|
|
38
|
+
const centimes = francs * 100 + cents;
|
|
39
|
+
if (!Number.isSafeInteger(centimes))
|
|
40
|
+
return { ok: false };
|
|
41
|
+
return { ok: true, centimes: match[1] && centimes !== 0 ? -centimes : centimes };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Un montant en francs suisses, stocké en centimes (entier) : prix, budget, seuil d'alerte. Le
|
|
45
|
+
* champ accepte les écritures courantes (apostrophe, virgule, « 12.– ») et se remet au format
|
|
46
|
+
* suisse en quittant le champ. Clavier numérique sur téléphone.
|
|
47
|
+
*
|
|
48
|
+
* Une saisie invalide ou hors bornes n'appelle pas `onChange` et s'explique sous le champ.
|
|
49
|
+
*/
|
|
50
|
+
export function MoneyField({ value, onChange, min = 0, max, className, id: idProp, ...props }) {
|
|
51
|
+
const fieldId = useFieldId();
|
|
52
|
+
const fieldDescribedBy = useFieldDescribedBy();
|
|
53
|
+
const ownId = useId();
|
|
54
|
+
const id = idProp ?? fieldId ?? ownId;
|
|
55
|
+
const errorId = `${id}-money-error`;
|
|
56
|
+
const display = (centimes) => (centimes === null ? "" : formatMoney(centimes));
|
|
57
|
+
const [text, setText] = useState(display(value));
|
|
58
|
+
const [shown, setShown] = useState(value);
|
|
59
|
+
const [error, setError] = useState(null);
|
|
60
|
+
// Un montant changé par le produit (réinitialisation) remplace la saisie.
|
|
61
|
+
if (shown !== value) {
|
|
62
|
+
setShown(value);
|
|
63
|
+
const parsed = parseMoney(text);
|
|
64
|
+
if (!parsed.ok || parsed.centimes !== value)
|
|
65
|
+
setText(display(value));
|
|
66
|
+
}
|
|
67
|
+
const check = (next) => {
|
|
68
|
+
const parsed = parseMoney(next);
|
|
69
|
+
if (!parsed.ok)
|
|
70
|
+
return { error: "Montant invalide. Écris par exemple 1’250.50." };
|
|
71
|
+
const { centimes } = parsed;
|
|
72
|
+
if (centimes !== null && min !== null && centimes < min)
|
|
73
|
+
return { error: `Au moins ${formatChf(min)}.` };
|
|
74
|
+
if (centimes !== null && max !== undefined && centimes > max)
|
|
75
|
+
return { error: `Au plus ${formatChf(max)}.` };
|
|
76
|
+
return { centimes };
|
|
77
|
+
};
|
|
78
|
+
const describedBy = [props["aria-describedby"] ?? fieldDescribedBy, error ? errorId : null].filter(Boolean).join(" ");
|
|
79
|
+
return (_jsxs("div", { "data-slot": "money-field", className: cn("grid gap-1.5", className), children: [_jsxs("div", { className: "relative", children: [_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-y-0 left-3.5 flex items-center text-sm text-muted-foreground", children: "CHF" }), _jsx(Input, { ...props, id: id, type: "text", inputMode: "decimal", autoComplete: "off", value: text, "aria-invalid": error ? true : undefined, "aria-describedby": describedBy || undefined, onChange: (event) => {
|
|
80
|
+
const next = event.target.value;
|
|
81
|
+
setText(next);
|
|
82
|
+
const result = check(next);
|
|
83
|
+
if ("error" in result)
|
|
84
|
+
return;
|
|
85
|
+
setError(null);
|
|
86
|
+
if (result.centimes !== value) {
|
|
87
|
+
setShown(result.centimes);
|
|
88
|
+
onChange(result.centimes);
|
|
89
|
+
}
|
|
90
|
+
}, onBlur: (event) => {
|
|
91
|
+
props.onBlur?.(event);
|
|
92
|
+
const result = check(text);
|
|
93
|
+
if ("error" in result)
|
|
94
|
+
return setError(result.error);
|
|
95
|
+
setError(null);
|
|
96
|
+
setText(display(result.centimes));
|
|
97
|
+
}, className: "pl-12 text-right tabular-nums" })] }), error ? (_jsx("p", { id: errorId, role: "alert", className: "soft-in text-sm text-destructive", children: error })) : null] }));
|
|
98
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { IconSvgElement } from "@hugeicons/react";
|
|
2
|
+
/** Où en est un produit : seul ce qui répond s'ouvre depuis les menus. */
|
|
3
|
+
export type ProductStage = "live" | "preview" | "soon";
|
|
4
|
+
export declare const PRODUCT_STAGE_LABEL: Readonly<Record<ProductStage, string>>;
|
|
5
|
+
/** Un produit de la famille, tel que la vision le décrit. */
|
|
6
|
+
export type HgdcCatalogProduct = {
|
|
7
|
+
/** Le mot de « HGDC <mot> » : « Host ». */
|
|
8
|
+
word: string;
|
|
9
|
+
/** « HGDC Host ». */
|
|
10
|
+
name: string;
|
|
11
|
+
/** « host.hgdc.ch ». */
|
|
12
|
+
subdomain: string;
|
|
13
|
+
href: string;
|
|
14
|
+
/** Une phrase, au tutoiement. */
|
|
15
|
+
pitch: string;
|
|
16
|
+
icon: IconSvgElement;
|
|
17
|
+
stage: ProductStage;
|
|
18
|
+
};
|
|
19
|
+
/** HGDC Account : le compte commun (profil, sécurité, organisations), hors du catalogue des produits. */
|
|
20
|
+
export declare const HGDC_ACCOUNT: HgdcCatalogProduct;
|
|
21
|
+
/** Les produits de la famille, dans leur ordre d'affichage. Account n'en fait pas partie (`HGDC_ACCOUNT`). */
|
|
22
|
+
export declare const HGDC_PRODUCTS: ReadonlyArray<HgdcCatalogProduct>;
|
|
23
|
+
/** Vrai si le produit répond déjà (en service ou en préversion). */
|
|
24
|
+
export declare function isProductOpen(item: Pick<HgdcCatalogProduct, "stage">): boolean;
|
|
25
|
+
/** Une entrée du menu « Produits HGDC » d'`AccountMenu`. */
|
|
26
|
+
export type ProductMenuEntry = {
|
|
27
|
+
name: string;
|
|
28
|
+
href: string;
|
|
29
|
+
/** Le produit ouvert : coché, sans lien. */
|
|
30
|
+
current?: boolean;
|
|
31
|
+
icon?: IconSvgElement;
|
|
32
|
+
/** Texte discret à droite : « Préversion ». */
|
|
33
|
+
hint?: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Les entrées du menu du compte : Account d'abord, puis les produits ouverts. `current` est le
|
|
37
|
+
* mot du produit affiché (« Mail ») : il est coché. `accountHref` remplace l'adresse d'Account
|
|
38
|
+
* (un Account local en développement).
|
|
39
|
+
*/
|
|
40
|
+
export declare function productMenuEntries(options?: {
|
|
41
|
+
current?: string | undefined;
|
|
42
|
+
accountHref?: string | undefined;
|
|
43
|
+
catalog?: ReadonlyArray<HgdcCatalogProduct> | undefined;
|
|
44
|
+
}): Array<ProductMenuEntry>;
|
package/dist/products.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Activity01Icon from "@hugeicons/core-free-icons/Activity01Icon";
|
|
2
|
+
import Analytics01Icon from "@hugeicons/core-free-icons/Analytics01Icon";
|
|
3
|
+
import BookOpen01Icon from "@hugeicons/core-free-icons/BookOpen01Icon";
|
|
4
|
+
import Briefcase01Icon from "@hugeicons/core-free-icons/Briefcase01Icon";
|
|
5
|
+
import Mail01Icon from "@hugeicons/core-free-icons/Mail01Icon";
|
|
6
|
+
import Rocket01Icon from "@hugeicons/core-free-icons/Rocket01Icon";
|
|
7
|
+
import UserCircleIcon from "@hugeicons/core-free-icons/UserCircleIcon";
|
|
8
|
+
export const PRODUCT_STAGE_LABEL = {
|
|
9
|
+
live: "En service",
|
|
10
|
+
preview: "Préversion",
|
|
11
|
+
soon: "Bientôt",
|
|
12
|
+
};
|
|
13
|
+
function product(word, pitch, icon, stage) {
|
|
14
|
+
const subdomain = `${word.toLowerCase()}.hgdc.ch`;
|
|
15
|
+
return { word, name: `HGDC ${word}`, subdomain, href: `https://${subdomain}`, pitch, icon, stage };
|
|
16
|
+
}
|
|
17
|
+
/** HGDC Account : le compte commun (profil, sécurité, organisations), hors du catalogue des produits. */
|
|
18
|
+
export const HGDC_ACCOUNT = product("Account", "Ton compte HGDC : profil, sécurité et organisations, pour tous les produits.", UserCircleIcon, "live");
|
|
19
|
+
/** Les produits de la famille, dans leur ordre d'affichage. Account n'en fait pas partie (`HGDC_ACCOUNT`). */
|
|
20
|
+
export const HGDC_PRODUCTS = [
|
|
21
|
+
product("Host", "Ton site en ligne, déployé à chaque modification.", Rocket01Icon, "preview"),
|
|
22
|
+
product("Mail", "Tes e-mails, newsletters et formulaires, envoyés depuis ton adresse.", Mail01Icon, "live"),
|
|
23
|
+
product("Portal", "Un espace partagé où ton client suit son projet, signe et paie.", Briefcase01Icon, "soon"),
|
|
24
|
+
product("Status", "La surveillance de tes sites et ta page de statut.", Activity01Icon, "soon"),
|
|
25
|
+
product("Analytics", "Tes visiteurs mesurés, sans cookies ni bannière.", Analytics01Icon, "soon"),
|
|
26
|
+
product("Docs", "Ta documentation en ligne, avec un assistant qui y répond.", BookOpen01Icon, "soon"),
|
|
27
|
+
];
|
|
28
|
+
/** Vrai si le produit répond déjà (en service ou en préversion). */
|
|
29
|
+
export function isProductOpen(item) {
|
|
30
|
+
return item.stage !== "soon";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Les entrées du menu du compte : Account d'abord, puis les produits ouverts. `current` est le
|
|
34
|
+
* mot du produit affiché (« Mail ») : il est coché. `accountHref` remplace l'adresse d'Account
|
|
35
|
+
* (un Account local en développement).
|
|
36
|
+
*/
|
|
37
|
+
export function productMenuEntries(options = {}) {
|
|
38
|
+
const current = options.current?.toLowerCase();
|
|
39
|
+
const account = { ...HGDC_ACCOUNT, href: options.accountHref ?? HGDC_ACCOUNT.href };
|
|
40
|
+
return [account, ...(options.catalog ?? HGDC_PRODUCTS).filter(isProductOpen)].map((item) => ({
|
|
41
|
+
name: item.name,
|
|
42
|
+
href: item.href,
|
|
43
|
+
icon: item.icon,
|
|
44
|
+
...(item.word.toLowerCase() === current ? { current: true } : {}),
|
|
45
|
+
...(item.stage === "preview" ? { hint: PRODUCT_STAGE_LABEL.preview } : {}),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* L'avancement d'une tâche qui finit : import, build, envoi d'une campagne. `value={null}` :
|
|
4
|
+
* durée inconnue, une barre qui glisse (immobile si le mouvement est réduit). Base UI pose
|
|
5
|
+
* `role="progressbar"` et ses valeurs `aria`.
|
|
6
|
+
*
|
|
7
|
+
* Pour une quantité dans une plage (quota, stockage), `Meter`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function Progress({ label, value, max, valueText, showValue, className, }: {
|
|
10
|
+
/** Ce qui avance, visible et nom accessible : « Envoi de la campagne ». */
|
|
11
|
+
label: ReactNode;
|
|
12
|
+
value: number | null;
|
|
13
|
+
max?: number;
|
|
14
|
+
/** Texte de la valeur, lu aussi par les lecteurs d'écran : « 120 sur 400 e-mails ». Pourcentage par défaut. */
|
|
15
|
+
valueText?: string;
|
|
16
|
+
showValue?: boolean;
|
|
17
|
+
className?: string;
|
|
18
|
+
}): React.JSX.Element;
|
|
19
|
+
/** Seuils d'une jauge : au-delà de `warning`, orange ; au-delà de `danger`, rouge. En fraction de `max`. */
|
|
20
|
+
export type MeterThresholds = {
|
|
21
|
+
warning?: number;
|
|
22
|
+
danger?: number;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Une quantité dans une plage connue : quota d'envoi du mois, stockage, sièges. La couleur passe
|
|
26
|
+
* à l'orange puis au rouge près de la limite, et le texte dit toujours la valeur : jamais la
|
|
27
|
+
* couleur seule. Base UI pose `role="meter"`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function Meter({ label, value, max, valueText, thresholds, className, }: {
|
|
30
|
+
/** « E-mails envoyés ce mois ». */
|
|
31
|
+
label: ReactNode;
|
|
32
|
+
value: number;
|
|
33
|
+
max?: number;
|
|
34
|
+
/** « 8’200 sur 10’000 ». Pourcentage par défaut. */
|
|
35
|
+
valueText?: string;
|
|
36
|
+
thresholds?: MeterThresholds;
|
|
37
|
+
className?: string;
|
|
38
|
+
}): React.JSX.Element;
|
package/dist/progress.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Meter as MeterPrimitive } from "@base-ui/react/meter";
|
|
3
|
+
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
|
|
4
|
+
import { cn } from "./cn.js";
|
|
5
|
+
const TRACK = "relative h-1.5 w-full overflow-hidden rounded-full bg-foreground/[0.08]";
|
|
6
|
+
const LABEL = "text-sm font-medium";
|
|
7
|
+
const VALUE = "text-sm text-muted-foreground tabular-nums";
|
|
8
|
+
/**
|
|
9
|
+
* L'avancement d'une tâche qui finit : import, build, envoi d'une campagne. `value={null}` :
|
|
10
|
+
* durée inconnue, une barre qui glisse (immobile si le mouvement est réduit). Base UI pose
|
|
11
|
+
* `role="progressbar"` et ses valeurs `aria`.
|
|
12
|
+
*
|
|
13
|
+
* Pour une quantité dans une plage (quota, stockage), `Meter`.
|
|
14
|
+
*/
|
|
15
|
+
export function Progress({ label, value, max = 100, valueText, showValue = true, className, }) {
|
|
16
|
+
return (_jsxs(ProgressPrimitive.Root, { "data-slot": "progress", value: value, max: max, locale: "fr-CH", ...(valueText !== undefined ? { getAriaValueText: () => valueText } : {}), className: cn("grid grid-cols-[1fr_auto] items-baseline gap-x-3 gap-y-2", className), children: [_jsx(ProgressPrimitive.Label, { className: LABEL, children: label }), showValue && value !== null ? (_jsx(ProgressPrimitive.Value, { className: VALUE, children: (formatted) => valueText ?? formatted })) : (_jsx("span", {})), _jsx(ProgressPrimitive.Track, { className: cn(TRACK, "col-span-2"), children: _jsx(ProgressPrimitive.Indicator, { className: cn("block h-full rounded-full bg-primary transition-[width] duration-(--duration-base) ease-out", "data-indeterminate:absolute data-indeterminate:inset-y-0 data-indeterminate:w-1/3 data-indeterminate:progress-slide") }) })] }));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Une quantité dans une plage connue : quota d'envoi du mois, stockage, sièges. La couleur passe
|
|
20
|
+
* à l'orange puis au rouge près de la limite, et le texte dit toujours la valeur : jamais la
|
|
21
|
+
* couleur seule. Base UI pose `role="meter"`.
|
|
22
|
+
*/
|
|
23
|
+
export function Meter({ label, value, max = 100, valueText, thresholds = { warning: 0.8, danger: 0.95 }, className, }) {
|
|
24
|
+
const ratio = max > 0 ? value / max : 0;
|
|
25
|
+
const level = thresholds.danger !== undefined && ratio >= thresholds.danger
|
|
26
|
+
? "danger"
|
|
27
|
+
: thresholds.warning !== undefined && ratio >= thresholds.warning
|
|
28
|
+
? "warning"
|
|
29
|
+
: "normal";
|
|
30
|
+
return (_jsxs(MeterPrimitive.Root, { "data-slot": "meter", "data-level": level, value: Math.min(Math.max(value, 0), max), max: max, locale: "fr-CH", ...(valueText !== undefined ? { getAriaValueText: () => valueText } : {}), className: cn("grid grid-cols-[1fr_auto] items-baseline gap-x-3 gap-y-2", className), children: [_jsx(MeterPrimitive.Label, { className: LABEL, children: label }), _jsx(MeterPrimitive.Value, { className: VALUE, children: (formatted) => valueText ?? formatted }), _jsx(MeterPrimitive.Track, { className: cn(TRACK, "col-span-2"), children: _jsx(MeterPrimitive.Indicator, { className: cn("block h-full rounded-full transition-[width] duration-(--duration-base) ease-out", level === "danger" ? "bg-destructive" : level === "warning" ? "bg-status-degraded" : "bg-primary") }) })] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
export type RankItem = {
|
|
3
|
+
/** Identifiant stable de la ligne. */
|
|
4
|
+
key: string;
|
|
5
|
+
label: ReactNode;
|
|
6
|
+
value: number;
|
|
7
|
+
/** Icône ou pastille avant le libellé (drapeau, favicon), décorative. */
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
/** La ligne mène au détail : `<Link to="…" />` ou `<a href="…" />`. */
|
|
10
|
+
render?: ReactElement;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Classement d'un tableau de bord : pages les plus vues, domaines qui envoient le plus, pays.
|
|
14
|
+
* Une ligne par élément, triée du plus grand au plus petit, sa barre proportionnelle au premier.
|
|
15
|
+
* Une liste ordonnée : le rang se lit sans la barre, et chaque valeur s'écrit.
|
|
16
|
+
*/
|
|
17
|
+
export declare function RankBar({ label, items, limit, formatValue, valueLabel, emptyLabel, className, }: {
|
|
18
|
+
/** Nom de la liste pour les lecteurs d'écran : « Pages les plus vues ». */
|
|
19
|
+
label: string;
|
|
20
|
+
items: ReadonlyArray<RankItem>;
|
|
21
|
+
/** Nombre de lignes affichées ; le reste n'est pas rendu (le produit offre « Tout voir »). */
|
|
22
|
+
limit?: number;
|
|
23
|
+
formatValue?: (value: number) => string;
|
|
24
|
+
/** En-tête de la colonne des valeurs, visible : « Visites ». */
|
|
25
|
+
valueLabel?: ReactNode;
|
|
26
|
+
emptyLabel?: ReactNode;
|
|
27
|
+
className?: string;
|
|
28
|
+
}): React.JSX.Element;
|
package/dist/rank-bar.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
const number = new Intl.NumberFormat("fr-CH");
|
|
5
|
+
function RankRow({ item, max, formatValue, }) {
|
|
6
|
+
const width = max > 0 ? Math.max(0, Math.min(1, item.value / max)) : 0;
|
|
7
|
+
const content = (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, "data-slot": "rank-bar-fill", className: "absolute inset-y-0 left-0 rounded-lg bg-foreground/[0.06] transition-[width] duration-(--duration-base) ease-out", style: { width: `${width * 100}%` } }), _jsxs("span", { className: "relative flex min-w-0 flex-1 items-center gap-2", children: [item.icon ? (_jsx("span", { "aria-hidden": true, className: "grid size-4 shrink-0 place-items-center", children: item.icon })) : null, _jsx("span", { className: "min-w-0 truncate", children: item.label })] }), _jsx("span", { className: "relative shrink-0 font-medium tabular-nums", children: formatValue(item.value) })] }));
|
|
8
|
+
const row = "relative flex min-h-9 items-center gap-3 rounded-lg px-2.5 text-sm";
|
|
9
|
+
const link = useRender({
|
|
10
|
+
render: item.render,
|
|
11
|
+
enabled: item.render !== undefined,
|
|
12
|
+
props: {
|
|
13
|
+
className: cn(row, "outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring/50"),
|
|
14
|
+
children: content,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
return _jsx("li", { children: link ?? _jsx("div", { className: row, children: content }) });
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Classement d'un tableau de bord : pages les plus vues, domaines qui envoient le plus, pays.
|
|
21
|
+
* Une ligne par élément, triée du plus grand au plus petit, sa barre proportionnelle au premier.
|
|
22
|
+
* Une liste ordonnée : le rang se lit sans la barre, et chaque valeur s'écrit.
|
|
23
|
+
*/
|
|
24
|
+
export function RankBar({ label, items, limit, formatValue = (v) => number.format(v), valueLabel, emptyLabel = "Rien à classer sur cette période.", className, }) {
|
|
25
|
+
const sorted = [...items].sort((a, b) => b.value - a.value);
|
|
26
|
+
const shown = limit === undefined ? sorted : sorted.slice(0, limit);
|
|
27
|
+
const max = shown[0]?.value ?? 0;
|
|
28
|
+
if (shown.length === 0)
|
|
29
|
+
return (_jsx("p", { "data-slot": "rank-bar", "data-state": "empty", className: cn("py-6 text-center text-sm text-muted-foreground", className), children: emptyLabel }));
|
|
30
|
+
return (_jsxs("div", { "data-slot": "rank-bar", className: cn("grid gap-1", className), children: [valueLabel ? (_jsx("p", { "aria-hidden": true, className: "flex justify-end px-2.5 text-xs text-muted-foreground", children: valueLabel })) : null, _jsx("ol", { "aria-label": label, className: "grid gap-1", children: shown.map((item) => (_jsx(RankRow, { item: item, max: max, formatValue: formatValue }, item.key))) })] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Un bloc de réglages : titre, description, champs, et un pied avec son propre bouton
|
|
4
|
+
* Enregistrer. Chaque bloc s'enregistre seul : on ne perd pas la saisie d'un bloc en validant
|
|
5
|
+
* un autre, et l'erreur dit lequel a échoué. Repris de la page de réglages de Host.
|
|
6
|
+
*
|
|
7
|
+
* Avec `onSave`, le bloc est un formulaire : Entrée dans un champ enregistre, le bouton reste
|
|
8
|
+
* grisé tant que `dirty` est faux et tourne pendant `saving`. Sans `onSave`, `footer` reçoit
|
|
9
|
+
* les actions telles quelles (« Supprimer le projet » d'un bloc `tone="danger"`).
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <SettingsSection
|
|
13
|
+
* title="Nom de l'espace"
|
|
14
|
+
* description="Visible par les membres et dans les e-mails d'invitation."
|
|
15
|
+
* dirty={name !== initial}
|
|
16
|
+
* saving={saving}
|
|
17
|
+
* onSave={save}
|
|
18
|
+
* >
|
|
19
|
+
* <Field><FieldLabel>Nom</FieldLabel><Input value={name} onChange={…} /></Field>
|
|
20
|
+
* </SettingsSection>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function SettingsSection({ title, description, children, footer, footerNote, tone, onSave, dirty, saving, saveLabel, className, }: {
|
|
24
|
+
title: string;
|
|
25
|
+
description?: ReactNode;
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
/** Actions du pied, à la place du bouton Enregistrer (ou avant lui avec `onSave`). */
|
|
28
|
+
footer?: ReactNode;
|
|
29
|
+
/** Texte discret à gauche du pied : « Appliqué au prochain déploiement. » */
|
|
30
|
+
footerNote?: ReactNode;
|
|
31
|
+
/** `danger` : la zone des actions irréversibles, cerclée de rouge. */
|
|
32
|
+
tone?: "default" | "danger";
|
|
33
|
+
/** Enregistre le bloc et affiche lui-même son erreur (toast, `FieldError`) : rien ne l'attrape ici. */
|
|
34
|
+
onSave?: () => void | Promise<void>;
|
|
35
|
+
/** Faux : rien n'a changé, le bouton reste grisé. */
|
|
36
|
+
dirty?: boolean;
|
|
37
|
+
saving?: boolean;
|
|
38
|
+
saveLabel?: string;
|
|
39
|
+
className?: string;
|
|
40
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
import { cn } from "./cn.js";
|
|
5
|
+
/**
|
|
6
|
+
* Un bloc de réglages : titre, description, champs, et un pied avec son propre bouton
|
|
7
|
+
* Enregistrer. Chaque bloc s'enregistre seul : on ne perd pas la saisie d'un bloc en validant
|
|
8
|
+
* un autre, et l'erreur dit lequel a échoué. Repris de la page de réglages de Host.
|
|
9
|
+
*
|
|
10
|
+
* Avec `onSave`, le bloc est un formulaire : Entrée dans un champ enregistre, le bouton reste
|
|
11
|
+
* grisé tant que `dirty` est faux et tourne pendant `saving`. Sans `onSave`, `footer` reçoit
|
|
12
|
+
* les actions telles quelles (« Supprimer le projet » d'un bloc `tone="danger"`).
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <SettingsSection
|
|
16
|
+
* title="Nom de l'espace"
|
|
17
|
+
* description="Visible par les membres et dans les e-mails d'invitation."
|
|
18
|
+
* dirty={name !== initial}
|
|
19
|
+
* saving={saving}
|
|
20
|
+
* onSave={save}
|
|
21
|
+
* >
|
|
22
|
+
* <Field><FieldLabel>Nom</FieldLabel><Input value={name} onChange={…} /></Field>
|
|
23
|
+
* </SettingsSection>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function SettingsSection({ title, description, children, footer, footerNote, tone = "default", onSave, dirty = true, saving = false, saveLabel = "Enregistrer", className, }) {
|
|
27
|
+
const titleId = useId();
|
|
28
|
+
const descriptionId = useId();
|
|
29
|
+
const hasFooter = Boolean(footer || footerNote || onSave);
|
|
30
|
+
const body = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid gap-4 p-5 sm:p-6", children: [_jsxs("div", { children: [_jsx("h2", { id: titleId, className: "text-base font-semibold text-balance", children: title }), description ? (_jsx("p", { id: descriptionId, className: "mt-1 max-w-prose text-sm text-pretty text-muted-foreground", children: description })) : null] }), children] }), hasFooter ? (_jsxs("div", { "data-slot": "settings-section-footer", className: "flex flex-wrap items-center justify-end gap-3 border-t border-border bg-muted/40 px-5 py-3 sm:px-6", children: [footerNote ? _jsx("p", { className: "mr-auto text-sm text-muted-foreground", children: footerNote }) : null, footer, onSave ? (_jsx(Button, { type: "submit", size: "sm", disabled: !dirty, isLoading: saving, children: saveLabel })) : null] })) : null] }));
|
|
31
|
+
const shared = {
|
|
32
|
+
"data-slot": "settings-section",
|
|
33
|
+
"data-tone": tone,
|
|
34
|
+
"aria-labelledby": titleId,
|
|
35
|
+
...(description ? { "aria-describedby": descriptionId } : {}),
|
|
36
|
+
className: cn("overflow-hidden rounded-3xl surface", tone === "danger" && "ring-1 ring-destructive/30", className),
|
|
37
|
+
};
|
|
38
|
+
if (!onSave)
|
|
39
|
+
return _jsx("section", { ...shared, children: body });
|
|
40
|
+
const submit = (event) => {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
if (!dirty || saving)
|
|
43
|
+
return;
|
|
44
|
+
void onSave();
|
|
45
|
+
};
|
|
46
|
+
// Un `<form>` nommé par son titre a le rôle « form » : il apparaît dans les repères du lecteur d'écran.
|
|
47
|
+
return (_jsx("form", { ...shared, onSubmit: submit, children: body }));
|
|
48
|
+
}
|