@hgdc/ui 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +56 -24
  2. package/dist/account-menu.d.ts +15 -2
  3. package/dist/account-menu.js +8 -2
  4. package/dist/address-fields.d.ts +50 -0
  5. package/dist/address-fields.js +79 -0
  6. package/dist/chart-path.d.ts +9 -0
  7. package/dist/chart-path.js +46 -0
  8. package/dist/color-field.d.ts +32 -0
  9. package/dist/color-field.js +80 -0
  10. package/dist/command-menu.d.ts +4 -2
  11. package/dist/command-menu.js +5 -3
  12. package/dist/copy-button.d.ts +5 -2
  13. package/dist/copy-button.js +5 -2
  14. package/dist/data-table.d.ts +70 -0
  15. package/dist/data-table.js +46 -0
  16. package/dist/date-range-picker.d.ts +50 -0
  17. package/dist/date-range-picker.js +118 -0
  18. package/dist/date-time-field.d.ts +18 -0
  19. package/dist/date-time-field.js +31 -0
  20. package/dist/destructive-dialog.d.ts +28 -0
  21. package/dist/destructive-dialog.js +74 -0
  22. package/dist/dns-record-table.d.ts +30 -0
  23. package/dist/dns-record-table.js +33 -0
  24. package/dist/error-card.d.ts +23 -0
  25. package/dist/error-card.js +35 -0
  26. package/dist/filter-bar.d.ts +50 -0
  27. package/dist/filter-bar.js +88 -0
  28. package/dist/log-viewer.d.ts +39 -0
  29. package/dist/log-viewer.js +114 -0
  30. package/dist/middle-truncate.d.ts +17 -0
  31. package/dist/middle-truncate.js +25 -0
  32. package/dist/money-field.d.ts +31 -0
  33. package/dist/money-field.js +98 -0
  34. package/dist/products.d.ts +44 -0
  35. package/dist/products.js +47 -0
  36. package/dist/progress.d.ts +38 -0
  37. package/dist/progress.js +31 -0
  38. package/dist/rank-bar.d.ts +28 -0
  39. package/dist/rank-bar.js +31 -0
  40. package/dist/settings-section.d.ts +40 -0
  41. package/dist/settings-section.js +48 -0
  42. package/dist/sonner.d.ts +20 -1
  43. package/dist/sonner.js +43 -2
  44. package/dist/sparkline.d.ts +21 -0
  45. package/dist/sparkline.js +28 -0
  46. package/dist/status.d.ts +31 -8
  47. package/dist/status.js +19 -3
  48. package/dist/time-chart.d.ts +7 -1
  49. package/dist/time-chart.js +7 -42
  50. package/dist/uptime-bar.d.ts +5 -0
  51. package/dist/uptime-bar.js +33 -1
  52. package/package.json +1 -1
  53. package/styles.css +19 -0
package/README.md CHANGED
@@ -39,35 +39,67 @@ Primitives déjà là : `avatar` `badge` `button` `dialog` `drawer` `dropdown-me
39
39
 
40
40
  ## Coque des consoles
41
41
 
42
- | Import | Exemple |
43
- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
44
- | `app-shell` | `<AppShell sidebarHeader={<OrgSwitcher …/>} nav={<SidebarNav label="Projet">…</SidebarNav>} sidebarFooter={<AccountMenu …/>}>` |
45
- | | `<SidebarNavItem icon={Rocket01Icon} label="Déploiements" render={<Link to="/deployments" />} />` |
46
- | `org-switcher` | `<OrgSwitcher organizations={orgs} activeId={org.id} onSelect={go} createHref="/organisations/nouvelle" />` |
47
- | `account-menu` | `<AccountMenu user={user} onProfile={…} onSecurity={…} onSignOut={signOut} products={HGDC_PRODUCTS} />` |
48
- | `command-menu` | `<CommandMenu groups={[{ label: "Aller à", items }, { label: "Créer", items }, { label: "Changer", items }]} />` (`Ctrl+K`) |
49
- | `kbd` | `<KbdGroup><Kbd>Ctrl</Kbd><Kbd>K</Kbd></KbdGroup>` |
50
- | `page-header` | `<PageHeader title="Domaines" breadcrumbs={[{ label: "Acme", render: <Link to="/o/acme" /> }, { label: "Domaines" }]} actions={<Button>Ajouter</Button>} />` |
42
+ | Import | Exemple |
43
+ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
+ | `app-shell` | `<AppShell sidebarHeader={<OrgSwitcher …/>} nav={<SidebarNav label="Projet">…</SidebarNav>} sidebarFooter={<AccountMenu …/>}>` |
45
+ | | `<SidebarNavItem icon={Rocket01Icon} label="Déploiements" render={<Link to="/deployments" />} />` |
46
+ | `org-switcher` | `<OrgSwitcher organizations={orgs} activeId={org.id} onSelect={go} createHref="/organisations/nouvelle" />` |
47
+ | `account-menu` | `<AccountMenu user={user} onProfile={…} onSecurity={…} onSignOut={signOut} product="Mail" />` : Account et les produits ouverts du catalogue, Mail coché |
48
+ | `command-menu` | `<CommandMenu groups={[{ label: "Aller à", items }, { label: "Créer", items }, { label: "Changer", items }]} />` (`Ctrl+K`), `<CommandMenuButton compact="mobile" …/>` |
49
+ | `products` | `HGDC_PRODUCTS` (mot, nom, sous-domaine, phrase, icône, état), `HGDC_ACCOUNT`, `productMenuEntries({ current: "Mail" })` |
50
+ | `kbd` | `<KbdGroup><Kbd>Ctrl</Kbd><Kbd>K</Kbd></KbdGroup>` |
51
+ | `page-header` | `<PageHeader title="Domaines" breadcrumbs={[{ label: "Acme", render: <Link to="/o/acme" /> }, { label: "Domaines" }]} actions={<Button>Ajouter</Button>} />` |
51
52
 
52
53
  La barre se réduit en rail (`Ctrl+B`, mémorisé) : poser `sidebarInitScript` dans le `<head>`. Le kit ne connaît ni routeur ni authentification : liens par `render={<Link />}`, actions par callbacks. Rôles d'organisation : `owner`, `admin`, `member` (Propriétaire, Admin, Membre).
53
54
 
54
55
  ## Contenu et données
55
56
 
56
- | Import | Exemple |
57
- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
- | `card` | `<Card><CardHeader><CardTitle>Domaines</CardTitle><CardAction>…</CardAction></CardHeader><CardContent>…</CardContent><CardFooter>…</CardFooter></Card>` |
59
- | `alert` | `<Alert tone="warning" title="Quota presque atteint" action={<Button size="sm">Voir</Button>}>90 % utilisés.</Alert>` |
60
- | `table` | `<TableHead sort="descending" onSort={…}>Date</TableHead>`, `<TableRowLink render={<Link … />}>Production</TableRowLink>`, `<TableEmpty colSpan={4}>…</TableEmpty>`, `<TableSkeleton columns={4} />` |
61
- | `resource-list` | `<ResourceList><ResourceItem icon={…} title="Site vitrine" meta="acme.ch" badge={…} actions={…} href="/p/site" /></ResourceList>` |
62
- | `stat` | `<StatGrid><StatTile label="Erreurs" value="12" delta={{ value: "8 %", direction: "up", good: "down" }} /></StatGrid>` |
63
- | `time-chart` | `<TimeChart label="Envois par jour" timestamps={days} series={[{ key: "sent", label: "Envoyés", values }]} kind="bar" />` |
64
- | `status` | `<StatusBadge status="degraded" />` (Opérationnel, Dégradé, Panne, Maintenance, Inconnu) |
65
- | `uptime-bar` | `<UptimeBar days={[{ date, status: "operational", uptime: "100 %" }]} summary="99,95 % sur 90 jours" />` |
66
- | `timeline` | `<Timeline entries={[{ id, title: "Résolu", time, status: "operational" }]} />` |
67
- | `relative-time` | `<RelativeTime date={deployment._creationTime} />` (« il y a 3 min », date exacte au survol) |
68
- | `copy-button` | `<Snippet value="bun add @hgdc/ui" prompt />`, `<CopyButton value={url} />` |
69
- | `secret-reveal` | `<SecretReveal label="Clé d'API" value={key} onDismiss={forget} />` : affichée une fois, puis retirée de la page |
70
- | `skeleton` | `<Pending pending={isLoading} fallback={<TableSkeleton columns={3} />}>…</Pending>` : après 200 ms, tenu 400 ms au moins |
57
+ | Import | Exemple |
58
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
+ | `card` | `<Card><CardHeader><CardTitle>Domaines</CardTitle><CardAction>…</CardAction></CardHeader><CardContent>…</CardContent><CardFooter>…</CardFooter></Card>` |
60
+ | `alert` | `<Alert tone="warning" title="Quota presque atteint" action={<Button size="sm">Voir</Button>}>90 % utilisés.</Alert>` |
61
+ | `table` | `<TableHead sort="descending" onSort={…}>Date</TableHead>`, `<TableRowLink render={<Link … />}>Production</TableRowLink>`, `<TableEmpty colSpan={4}>…</TableEmpty>`, `<TableSkeleton columns={4} />` |
62
+ | `resource-list` | `<ResourceList><ResourceItem icon={…} title="Site vitrine" meta="acme.ch" badge={…} actions={…} href="/p/site" /></ResourceList>` |
63
+ | `stat` | `<StatGrid><StatTile label="Erreurs" value="12" delta={{ value: "8 %", direction: "up", good: "down" }} /></StatGrid>` |
64
+ | `time-chart` | `<TimeChart label="Envois par jour" timestamps={days} series={[{ key: "sent", label: "Envoyés", values }]} kind="bar" lastIncomplete />` (dernier point en pointillés : la journée n'est pas finie) |
65
+ | `status` | `<StatusBadge status="degraded" />` (Opérationnel, Dégradé, Panne, Maintenance, Inconnu) ; hors services, `<StatusBadge tone="success" label="Remis" />` (`neutral`, `success`, `warning`, `danger`, `info`, `pending`) |
66
+ | `uptime-bar` | `<UptimeBar days={[{ date, status: "operational", uptime: "100 %" }]} summary="99,95 % sur 90 jours" />` |
67
+ | `timeline` | `<Timeline entries={[{ id, title: "Résolu", time, status: "operational" }]} />` |
68
+ | `relative-time` | `<RelativeTime date={deployment._creationTime} />` (« il y a 3 min », date exacte au survol) |
69
+ | `copy-button` | `<Snippet value="bun add @hgdc/ui" prompt />`, `<CopyButton value={url} />`, `<CopyButton value={all} label="Tout copier" showLabel />` |
70
+ | `progress` | `<Progress label="Envoi de la campagne" value={120} max={400} />` (`value={null}` : durée inconnue), `<Meter label="E-mails ce mois" value={8600} max={10000} />` (orange puis rouge près de la limite) |
71
+ | `rank-bar` | `<RankBar label="Pages les plus vues" items={[{ key, label: "/tarifs", value: 640, render: <Link … /> }]} limit={5} />` |
72
+ | `secret-reveal` | `<SecretReveal label="Clé d'API" value={key} onDismiss={forget} />` : affichée une fois, puis retirée de la page |
73
+ | `skeleton` | `<Pending pending={isLoading} fallback={<TableSkeleton columns={3} />}>…</Pending>` : après 200 ms, tenu 400 ms au moins |
74
+
75
+ ## Listes, journaux et retours
76
+
77
+ | Import | Exemple |
78
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
79
+ | `filter-bar` | `<FilterBar search={{ value: q, onChange }} filters={[{ key: "status", label: "Statut", options }]} values={{ status }} onFilterChange={…} onReset={…} />` |
80
+ | `data-table` | `<DataTable label="Déploiements" rows={rows} columns={columns} rowKey={(d) => d.id} sort={sort} onSortChange={…} filters={<FilterBar …/>} loadMore={…} />` |
81
+ | `dns-record-table` | `<DnsRecordTable records={[{ type: "MX", name: "send", value, priority: 10, status: "pending" }]} />` (Vérifié, En attente, Échec, À ajouter) |
82
+ | `log-viewer` | `<LogViewer lines={[{ id, time, level: "error", message }]} live />` : niveaux, recherche, « Suivre », copie ; seules les lignes visibles sont rendues |
83
+ | `sparkline` | `<Sparkline label="Envois sur 7 jours" values={[3, 8, 5]} color={2} />` |
84
+ | `middle-truncate` | `<MiddleTruncate text={deployment.url} />` : « …/routes/index.tsx », texte entier dans la page |
85
+ | `error-card` | `<ErrorCard title="Impossible de charger les journaux" detail={message} reference={requestId} onRetry={refetch} />` |
86
+ | `destructive-dialog` | `<DestructiveDialog open={open} onOpenChange={setOpen} title="Supprimer le domaine" question="…" name="acme.ch" onConfirm={remove} />` |
87
+ | `sonner` | `<Toaster />` une fois, puis `toastUndo("Domaine retiré", { onUndo: restore })` ; `errorMessage(error)` pour un toast d'erreur |
88
+
89
+ Filtres, tri et page vivent dans l'URL du produit : les composants sont contrôlés et ne connaissent pas le routeur.
90
+
91
+ ## Formulaires et réglages
92
+
93
+ | Import | Exemple |
94
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
95
+ | `settings-section` | `<SettingsSection title="Nom de l'espace" description="…" dirty={changed} saving={saving} onSave={save}>…</SettingsSection>` : un Enregistrer par bloc |
96
+ | `date-range-picker` | `<DateRangePicker value={{ from: "2026-10-01", to: "2026-10-15" }} onChange={(range, preset) => …} />` : raccourcis et période libre |
97
+ | `date-time-field` | `<DateTimeField value={sendAt} onChange={setSendAt} min={Date.now()} />` : millisecondes, heure du navigateur |
98
+ | `color-field` | `<ColorField value="#7c3aed" onChange={…} />` : pastille, code hexadécimal, contraste avec le texte blanc et son verdict |
99
+ | `money-field` | `<MoneyField value={centimes} onChange={…} />` : CHF en centimes, « 1’250.50 », accepte « 12.– » ; `formatChf`, `parseMoney` |
100
+ | `address-fields` | `<AddressFields value={address} onChange={…} errors={validatePostalAddress(address)} />` : rue, numéro, NPA, localité, pays (facture QR) |
101
+
102
+ Les champs de date sont ceux du navigateur, sans calendrier maison (ADR 0006). Les fonctions pures (`parseMoney`, `contrastRatio`, `validatePostalAddress`, `formatDateRange`) s'importent seules, côté serveur aussi.
71
103
 
72
104
  ## Contrôles et surfaces flottantes
73
105
 
@@ -1,3 +1,4 @@
1
+ import { type IconSvgElement } from "@hugeicons/react";
1
2
  import type { ReactNode } from "react";
2
3
  export type HgdcProduct = {
3
4
  /** « HGDC Host ». */
@@ -5,6 +6,10 @@ export type HgdcProduct = {
5
6
  href: string;
6
7
  /** Le produit ouvert : coché, sans lien. */
7
8
  current?: boolean;
9
+ /** Icône du produit ; celle d'un tableau de bord par défaut. */
10
+ icon?: IconSvgElement;
11
+ /** Texte discret à droite : « Préversion ». */
12
+ hint?: string;
8
13
  };
9
14
  export type AccountUser = {
10
15
  name: string;
@@ -16,16 +21,24 @@ export type AccountUser = {
16
21
  * produits HGDC, déconnexion. `row` : avatar, nom et adresse (l'avatar seul en rail) ; `avatar` :
17
22
  * l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
18
23
  * `ThemeProvider`.
24
+ *
25
+ * « Produits HGDC » liste par défaut Account et les produits ouverts du catalogue
26
+ * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
27
+ * liste ; `products={[]}` la retire.
19
28
  */
20
- export declare function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products, children, variant, }: {
29
+ export declare function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant, }: {
21
30
  user: AccountUser;
22
31
  onProfile?: () => void;
23
32
  onSecurity?: () => void;
24
33
  /** Ouvre l'aide des raccourcis clavier. */
25
34
  onShortcuts?: () => void;
26
35
  onSignOut: () => void;
27
- /** Les produits de la famille, pour passer de l'un à l'autre. */
36
+ /** Les produits de la famille, pour passer de l'un à l'autre. Le catalogue du kit par défaut. */
28
37
  products?: Array<HgdcProduct>;
38
+ /** Mot du produit ouvert (« Mail »), coché dans la liste par défaut. */
39
+ product?: string;
40
+ /** Adresse d'Account dans la liste par défaut (un Account local en développement). */
41
+ accountHref?: string;
29
42
  /** Entrées propres au produit (« Administration »), après le profil. */
30
43
  children?: ReactNode;
31
44
  variant?: "row" | "avatar";
@@ -13,6 +13,7 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu";
13
13
  import { Avatar, AvatarFallback, AvatarImage } from "./avatar.js";
14
14
  import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./dropdown-menu.js";
15
15
  import { MENU_LINK_ITEM } from "./org-switcher.js";
16
+ import { productMenuEntries } from "./products.js";
16
17
  import { useTheme } from "./theme.js";
17
18
  const THEMES = ["light", "dark", "system"];
18
19
  function initials(name) {
@@ -28,12 +29,17 @@ function initials(name) {
28
29
  * produits HGDC, déconnexion. `row` : avatar, nom et adresse (l'avatar seul en rail) ; `avatar` :
29
30
  * l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
30
31
  * `ThemeProvider`.
32
+ *
33
+ * « Produits HGDC » liste par défaut Account et les produits ouverts du catalogue
34
+ * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
35
+ * liste ; `products={[]}` la retire.
31
36
  */
32
- export function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products, children, variant = "row", }) {
37
+ export function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant = "row", }) {
33
38
  const { theme, setTheme } = useTheme();
39
+ const products = productsProp ?? productMenuEntries({ current: currentProduct, accountHref });
34
40
  return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: variant === "row" ? (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "flex w-full min-w-0 items-center gap-2.5 rounded-xl p-1.5 text-left outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted lg:hover:bg-shell-hover lg:aria-expanded:bg-shell-hover lg:rail:justify-center" })) : (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "touch-target grid size-9 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50" })), children: [_jsxs(Avatar, { children: [user.image ? _jsx(AvatarImage, { src: user.image, alt: "" }) : null, _jsx(AvatarFallback, { className: "text-xs font-semibold uppercase", children: initials(user.name) })] }), variant === "row" ? (_jsxs("span", { className: "min-w-0 flex-1 lg:rail:hidden", children: [_jsx("span", { className: "block truncate text-sm font-medium", children: user.name }), _jsx("span", { className: "block truncate text-xs text-muted-foreground lg:text-shell-muted", children: user.email })] })) : null] }), _jsxs(DropdownMenuContent, { align: variant === "row" ? "start" : "end", side: variant === "row" ? "top" : "bottom", className: "w-64", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { children: [_jsx("span", { className: "block truncate text-foreground", children: user.name }), _jsx("span", { className: "block truncate", children: user.email })] }) }), _jsx(DropdownMenuSeparator, {}), onProfile ? (_jsxs(DropdownMenuItem, { onClick: onProfile, children: [_jsx(HugeiconsIcon, { icon: UserIcon, "aria-hidden": true }), "Profil"] })) : null, onSecurity ? (_jsxs(DropdownMenuItem, { onClick: onSecurity, children: [_jsx(HugeiconsIcon, { icon: SecurityLockIcon, "aria-hidden": true }), "S\u00E9curit\u00E9"] })) : null, children, _jsxs(DropdownMenuSub, { children: [_jsxs(DropdownMenuSubTrigger, { children: [_jsx(HugeiconsIcon, { icon: theme === "dark" ? Moon02Icon : theme === "light" ? Sun03Icon : ComputerIcon, "aria-hidden": true }), "Th\u00E8me"] }), _jsx(DropdownMenuSubContent, { children: _jsxs(DropdownMenuRadioGroup, { value: theme, onValueChange: (value) => {
35
41
  const next = THEMES.find((t) => t === value);
36
42
  if (next)
37
43
  setTheme(next);
38
- }, children: [_jsx(DropdownMenuRadioItem, { value: "light", children: "Clair" }), _jsx(DropdownMenuRadioItem, { value: "dark", children: "Sombre" }), _jsx(DropdownMenuRadioItem, { value: "system", children: "Syst\u00E8me" })] }) })] }), onShortcuts ? (_jsxs(DropdownMenuItem, { onClick: onShortcuts, children: [_jsx(HugeiconsIcon, { icon: KeyboardIcon, "aria-hidden": true }), "Raccourcis clavier", _jsx(DropdownMenuShortcut, { children: "?" })] })) : null, products?.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Produits HGDC" }), products.map((product) => product.current ? (_jsxs(DropdownMenuItem, { disabled: true, className: "data-disabled:opacity-100", children: [_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true }), _jsx("span", { className: "flex-1", children: product.name }), _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Produit ouvert" })] }, product.name)) : (_jsxs(MenuPrimitive.LinkItem, { href: product.href, className: MENU_LINK_ITEM, children: [_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true }), product.name] }, product.name)))] })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: onSignOut, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
44
+ }, children: [_jsx(DropdownMenuRadioItem, { value: "light", children: "Clair" }), _jsx(DropdownMenuRadioItem, { value: "dark", children: "Sombre" }), _jsx(DropdownMenuRadioItem, { value: "system", children: "Syst\u00E8me" })] }) })] }), onShortcuts ? (_jsxs(DropdownMenuItem, { onClick: onShortcuts, children: [_jsx(HugeiconsIcon, { icon: KeyboardIcon, "aria-hidden": true }), "Raccourcis clavier", _jsx(DropdownMenuShortcut, { children: "?" })] })) : null, products.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Produits HGDC" }), products.map((product) => product.current ? (_jsxs(DropdownMenuItem, { disabled: true, className: "data-disabled:opacity-100", children: [_jsx(HugeiconsIcon, { icon: product.icon ?? DashboardSquare01Icon, "aria-hidden": true }), _jsx("span", { className: "flex-1", children: product.name }), _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Produit ouvert" })] }, product.name)) : (_jsxs(MenuPrimitive.LinkItem, { href: product.href, className: MENU_LINK_ITEM, children: [_jsx(HugeiconsIcon, { icon: product.icon ?? DashboardSquare01Icon, "aria-hidden": true }), _jsx("span", { className: "flex-1", children: product.name }), product.hint ? (_jsx("span", { className: "text-xs font-normal text-muted-foreground", children: product.hint })) : null] }, product.name)))] })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: onSignOut, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
39
45
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Adresse postale structurée, telle que la facture QR suisse l'exige depuis novembre 2025
3
+ * (adresse de type S) : rue et numéro séparés, NPA et localité séparés, pays en code ISO.
4
+ */
5
+ export type PostalAddress = {
6
+ /** « Rue du Lac ». */
7
+ street: string;
8
+ /** « 12 », « 12b » ; vide pour une adresse sans numéro. */
9
+ number: string;
10
+ /** Complément : « c/o Acme SA », « Case postale 42 ». */
11
+ extra: string;
12
+ /** NPA : « 1003 ». */
13
+ postalCode: string;
14
+ /** « Lausanne ». */
15
+ locality: string;
16
+ /** Code ISO 3166-1 alpha-2 : « CH ». */
17
+ country: string;
18
+ };
19
+ export type AddressCountry = {
20
+ code: string;
21
+ label: string;
22
+ };
23
+ /** Pays proposés par défaut : la Suisse et le Liechtenstein, qui partagent les NPA à quatre chiffres. */
24
+ export declare const DEFAULT_ADDRESS_COUNTRIES: ReadonlyArray<AddressCountry>;
25
+ export declare const EMPTY_ADDRESS: PostalAddress;
26
+ export type AddressErrors = Partial<Record<keyof PostalAddress, string>>;
27
+ /**
28
+ * Erreurs d'une adresse, champ par champ ; objet vide si elle tient. Limites de longueur de la
29
+ * facture QR : 70 caractères pour la rue, 16 pour le numéro et le NPA, 35 pour la localité.
30
+ */
31
+ export declare function validatePostalAddress(address: PostalAddress): AddressErrors;
32
+ /** L'adresse sur trois lignes, comme sur une enveloppe : « Rue du Lac 12 », « c/o… », « 1003 Lausanne ». */
33
+ export declare function formatPostalAddress(address: PostalAddress, countries?: ReadonlyArray<AddressCountry>): Array<string>;
34
+ /**
35
+ * Les champs d'une adresse postale suisse : rue et numéro, complément, NPA et localité, pays.
36
+ * Chaque champ porte son `autocomplete` : le navigateur remplit tout d'un geste.
37
+ * Rue et numéro, puis NPA et localité, restent côte à côte jusque sur téléphone.
38
+ *
39
+ * Contrôlé ; `errors` vient de `validatePostalAddress` (à l'envoi, pas à chaque frappe).
40
+ */
41
+ export declare function AddressFields({ value, onChange, errors, countries, legend, disabled, className, }: {
42
+ value: PostalAddress;
43
+ onChange: (address: PostalAddress) => void;
44
+ errors?: AddressErrors;
45
+ /** Un seul pays : le choix disparaît. */
46
+ countries?: ReadonlyArray<AddressCountry>;
47
+ legend?: string;
48
+ disabled?: boolean;
49
+ className?: string;
50
+ }): React.JSX.Element;
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from "react";
3
+ import { cn } from "./cn.js";
4
+ import { Input } from "./input.js";
5
+ import { Label } from "./label.js";
6
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select.js";
7
+ /** Pays proposés par défaut : la Suisse et le Liechtenstein, qui partagent les NPA à quatre chiffres. */
8
+ export const DEFAULT_ADDRESS_COUNTRIES = [
9
+ { code: "CH", label: "Suisse" },
10
+ { code: "LI", label: "Liechtenstein" },
11
+ ];
12
+ export const EMPTY_ADDRESS = {
13
+ street: "",
14
+ number: "",
15
+ extra: "",
16
+ postalCode: "",
17
+ locality: "",
18
+ country: "CH",
19
+ };
20
+ /**
21
+ * Erreurs d'une adresse, champ par champ ; objet vide si elle tient. Limites de longueur de la
22
+ * facture QR : 70 caractères pour la rue, 16 pour le numéro et le NPA, 35 pour la localité.
23
+ */
24
+ export function validatePostalAddress(address) {
25
+ const errors = {};
26
+ const street = address.street.trim();
27
+ const postalCode = address.postalCode.trim();
28
+ const locality = address.locality.trim();
29
+ if (!street)
30
+ errors.street = "Indique la rue.";
31
+ else if (street.length > 70)
32
+ errors.street = "70 caractères au plus.";
33
+ if (address.number.trim().length > 16)
34
+ errors.number = "16 caractères au plus.";
35
+ if (!postalCode)
36
+ errors.postalCode = "Indique le NPA.";
37
+ else if ((address.country === "CH" || address.country === "LI") && !/^[1-9]\d{3}$/.test(postalCode))
38
+ errors.postalCode = "Le NPA a quatre chiffres.";
39
+ else if (postalCode.length > 16)
40
+ errors.postalCode = "16 caractères au plus.";
41
+ if (!locality)
42
+ errors.locality = "Indique la localité.";
43
+ else if (locality.length > 35)
44
+ errors.locality = "35 caractères au plus.";
45
+ if (!/^[A-Z]{2}$/.test(address.country))
46
+ errors.country = "Choisis un pays.";
47
+ return errors;
48
+ }
49
+ /** L'adresse sur trois lignes, comme sur une enveloppe : « Rue du Lac 12 », « c/o… », « 1003 Lausanne ». */
50
+ export function formatPostalAddress(address, countries = DEFAULT_ADDRESS_COUNTRIES) {
51
+ const lines = [
52
+ [address.street.trim(), address.number.trim()].filter(Boolean).join(" "),
53
+ address.extra.trim(),
54
+ [address.postalCode.trim(), address.locality.trim()].filter(Boolean).join(" "),
55
+ ];
56
+ if (address.country !== "CH")
57
+ lines.push(countries.find((c) => c.code === address.country)?.label ?? address.country);
58
+ return lines.filter(Boolean);
59
+ }
60
+ function Part({ id, label, error, className, children, }) {
61
+ return (_jsxs("div", { className: cn("grid content-start gap-1.5", className), children: [_jsx(Label, { htmlFor: id, children: label }), children, error ? (_jsx("p", { id: `${id}-error`, role: "alert", className: "soft-in text-sm text-destructive", children: error })) : null] }));
62
+ }
63
+ /**
64
+ * Les champs d'une adresse postale suisse : rue et numéro, complément, NPA et localité, pays.
65
+ * Chaque champ porte son `autocomplete` : le navigateur remplit tout d'un geste.
66
+ * Rue et numéro, puis NPA et localité, restent côte à côte jusque sur téléphone.
67
+ *
68
+ * Contrôlé ; `errors` vient de `validatePostalAddress` (à l'envoi, pas à chaque frappe).
69
+ */
70
+ export function AddressFields({ value, onChange, errors = {}, countries = DEFAULT_ADDRESS_COUNTRIES, legend = "Adresse", disabled, className, }) {
71
+ const base = useId();
72
+ const id = (key) => `${base}-${key}`;
73
+ const set = (key) => (event) => onChange({ ...value, [key]: event.target.value });
74
+ const invalid = (key) => errors[key] ? { "aria-invalid": true, "aria-describedby": `${id(key)}-error` } : {};
75
+ return (_jsxs("fieldset", { "data-slot": "address-fields", disabled: disabled, className: cn("grid gap-4", className), children: [_jsx("legend", { className: "mb-3 text-base font-medium", children: legend }), _jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)_6rem] gap-3 sm:grid-cols-[minmax(0,1fr)_7rem] sm:gap-4", children: [_jsx(Part, { id: id("street"), label: "Rue", error: errors.street, children: _jsx(Input, { id: id("street"), value: value.street, onChange: set("street"), autoComplete: "address-line1", maxLength: 70, ...invalid("street") }) }), _jsx(Part, { id: id("number"), label: "Num\u00E9ro", error: errors.number, children: _jsx(Input, { id: id("number"), value: value.number, onChange: set("number"), autoComplete: "off", maxLength: 16, ...invalid("number") }) })] }), _jsx(Part, { id: id("extra"), label: "Compl\u00E9ment (facultatif)", error: errors.extra, children: _jsx(Input, { id: id("extra"), value: value.extra, onChange: set("extra"), autoComplete: "address-line2", placeholder: "c/o, case postale", ...invalid("extra") }) }), _jsxs("div", { className: "grid grid-cols-[6rem_minmax(0,1fr)] gap-3 sm:grid-cols-[7rem_minmax(0,1fr)] sm:gap-4", children: [_jsx(Part, { id: id("postalCode"), label: "NPA", error: errors.postalCode, children: _jsx(Input, { id: id("postalCode"), value: value.postalCode, onChange: set("postalCode"), autoComplete: "postal-code", inputMode: value.country === "CH" || value.country === "LI" ? "numeric" : "text", maxLength: 16, ...invalid("postalCode") }) }), _jsx(Part, { id: id("locality"), label: "Localit\u00E9", error: errors.locality, children: _jsx(Input, { id: id("locality"), value: value.locality, onChange: set("locality"), autoComplete: "address-level2", maxLength: 35, ...invalid("locality") }) })] }), countries.length > 1 ? (_jsx(Part, { id: id("country"), label: "Pays", error: errors.country, children: _jsxs(Select, { value: value.country, onValueChange: (next) => {
76
+ if (next)
77
+ onChange({ ...value, country: next });
78
+ }, ...(disabled ? { disabled } : {}), children: [_jsx(SelectTrigger, { id: id("country"), ...invalid("country"), children: _jsx(SelectValue, { items: Object.fromEntries(countries.map((c) => [c.code, c.label])) }) }), _jsx(SelectContent, { children: countries.map((country) => (_jsx(SelectItem, { value: country.code, children: country.label }, country.code))) })] }) })) : null] }));
79
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Tracés SVG partagés par `TimeChart` et `Sparkline`. Repère de `values.length` unités de large
3
+ * sur 100 de haut : le point `i` est au centre de sa colonne (`i + 0.5`), `min` en bas, `max` en
4
+ * haut. Le SVG s'étire ensuite (`preserveAspectRatio="none"`).
5
+ */
6
+ /** Une ligne, interrompue là où une valeur manque (`null`). */
7
+ export declare function linePath(values: ReadonlyArray<number | null>, max: number, min?: number): string;
8
+ /** L'aire sous la ligne : une forme par segment continu, fermée sur le bas du repère. */
9
+ export declare function areaPath(values: ReadonlyArray<number | null>, max: number, min?: number): string;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Tracés SVG partagés par `TimeChart` et `Sparkline`. Repère de `values.length` unités de large
3
+ * sur 100 de haut : le point `i` est au centre de sa colonne (`i + 0.5`), `min` en bas, `max` en
4
+ * haut. Le SVG s'étire ensuite (`preserveAspectRatio="none"`).
5
+ */
6
+ function y(value, min, max) {
7
+ const range = max - min;
8
+ return range > 0 ? 100 - ((value - min) / range) * 100 : 50;
9
+ }
10
+ /** Une ligne, interrompue là où une valeur manque (`null`). */
11
+ export function linePath(values, max, min = 0) {
12
+ let d = "";
13
+ let open = false;
14
+ values.forEach((v, i) => {
15
+ if (v === null) {
16
+ open = false;
17
+ return;
18
+ }
19
+ d += `${open ? "L" : "M"}${i + 0.5} ${y(v, min, max)}`;
20
+ open = true;
21
+ });
22
+ return d;
23
+ }
24
+ /** L'aire sous la ligne : une forme par segment continu, fermée sur le bas du repère. */
25
+ export function areaPath(values, max, min = 0) {
26
+ const parts = [];
27
+ let start = -1;
28
+ const close = (end) => {
29
+ if (start < 0)
30
+ return;
31
+ const top = values
32
+ .slice(start, end)
33
+ .map((v, j) => `${j === 0 ? "M" : "L"}${start + j + 0.5} ${y(v ?? min, min, max)}`)
34
+ .join("");
35
+ parts.push(`${top}L${end - 1 + 0.5} 100L${start + 0.5} 100Z`);
36
+ start = -1;
37
+ };
38
+ values.forEach((v, i) => {
39
+ if (v === null)
40
+ close(i);
41
+ else if (start < 0)
42
+ start = i;
43
+ });
44
+ close(values.length);
45
+ return parts.join("");
46
+ }
@@ -0,0 +1,32 @@
1
+ import { type StatusTone } from "./status.tsx";
2
+ /** `#7c3aed`, `7C3AED` ou `#abc` en `#7c3aed`, ou `null` si ce n'est pas une couleur hexadécimale. */
3
+ export declare function normalizeHexColor(value: string): string | null;
4
+ /** Rapport de contraste WCAG 2 entre deux couleurs hexadécimales (1 à 21), ou `null` si l'une est invalide. */
5
+ export declare function contrastRatio(a: string, b: string): number | null;
6
+ /** Verdict d'un contraste pour du texte : 4,5:1 pour le texte courant, 3:1 pour le gros texte et les icônes (WCAG AA). */
7
+ export declare function contrastVerdict(ratio: number): {
8
+ tone: StatusTone;
9
+ label: string;
10
+ };
11
+ /**
12
+ * Une couleur de marque : la pastille ouvre le sélecteur du navigateur, le champ accepte un code
13
+ * hexadécimal collé (`#7c3aed`). Sous le champ, le contraste avec `contrastWith` (le texte posé
14
+ * sur cette couleur, blanc par défaut) et son verdict : un bouton à l'accent d'un client doit
15
+ * rester lisible.
16
+ *
17
+ * La valeur rendue est toujours normalisée (`#rrggbb`) ; une saisie invalide n'appelle pas `onChange`
18
+ * et reprend la dernière couleur valable en quittant le champ.
19
+ */
20
+ export declare function ColorField({ value, onChange, contrastWith, contrastLabel, className, "aria-label": ariaLabel, id: idProp, disabled, }: {
21
+ /** `#rrggbb`. */
22
+ value: string;
23
+ onChange: (value: string) => void;
24
+ /** Couleur comparée ; `null` retire le contrôle de contraste. */
25
+ contrastWith?: string | null;
26
+ /** Ce qu'est cette couleur comparée, dans la phrase : « Contraste avec le texte blanc ». */
27
+ contrastLabel?: string;
28
+ className?: string;
29
+ "aria-label"?: string;
30
+ id?: string;
31
+ disabled?: boolean;
32
+ }): React.JSX.Element;
@@ -0,0 +1,80 @@
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
+ import { StatusBadge } from "./status.js";
7
+ /** `#7c3aed`, `7C3AED` ou `#abc` en `#7c3aed`, ou `null` si ce n'est pas une couleur hexadécimale. */
8
+ export function normalizeHexColor(value) {
9
+ const hex = value.trim().replace(/^#/, "").toLowerCase();
10
+ if (/^[0-9a-f]{3}$/.test(hex))
11
+ return `#${hex
12
+ .split("")
13
+ .map((c) => c + c)
14
+ .join("")}`;
15
+ return /^[0-9a-f]{6}$/.test(hex) ? `#${hex}` : null;
16
+ }
17
+ function luminance(hex) {
18
+ const channels = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255);
19
+ const [r, g, b] = channels.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
20
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
21
+ }
22
+ /** Rapport de contraste WCAG 2 entre deux couleurs hexadécimales (1 à 21), ou `null` si l'une est invalide. */
23
+ export function contrastRatio(a, b) {
24
+ const x = normalizeHexColor(a);
25
+ const y = normalizeHexColor(b);
26
+ if (!x || !y)
27
+ return null;
28
+ const [light, dark] = [luminance(x), luminance(y)].sort((m, n) => n - m);
29
+ return (light + 0.05) / (dark + 0.05);
30
+ }
31
+ /** Verdict d'un contraste pour du texte : 4,5:1 pour le texte courant, 3:1 pour le gros texte et les icônes (WCAG AA). */
32
+ export function contrastVerdict(ratio) {
33
+ if (ratio >= 4.5)
34
+ return { tone: "success", label: "Lisible" };
35
+ if (ratio >= 3)
36
+ return { tone: "warning", label: "Gros texte seulement" };
37
+ return { tone: "danger", label: "Illisible" };
38
+ }
39
+ const ratioFormat = new Intl.NumberFormat("fr-CH", { maximumFractionDigits: 1, minimumFractionDigits: 1 });
40
+ /**
41
+ * Une couleur de marque : la pastille ouvre le sélecteur du navigateur, le champ accepte un code
42
+ * hexadécimal collé (`#7c3aed`). Sous le champ, le contraste avec `contrastWith` (le texte posé
43
+ * sur cette couleur, blanc par défaut) et son verdict : un bouton à l'accent d'un client doit
44
+ * rester lisible.
45
+ *
46
+ * La valeur rendue est toujours normalisée (`#rrggbb`) ; une saisie invalide n'appelle pas `onChange`
47
+ * et reprend la dernière couleur valable en quittant le champ.
48
+ */
49
+ export function ColorField({ value, onChange, contrastWith = "#ffffff", contrastLabel = "le texte blanc", className, "aria-label": ariaLabel, id: idProp, disabled, }) {
50
+ const fieldId = useFieldId();
51
+ const fieldDescribedBy = useFieldDescribedBy();
52
+ const ownId = useId();
53
+ const id = idProp ?? fieldId ?? ownId;
54
+ const [text, setText] = useState(value);
55
+ // Une valeur changée par le produit (réinitialisation) remplace la saisie en cours.
56
+ const [shown, setShown] = useState(value);
57
+ if (shown !== value) {
58
+ setShown(value);
59
+ setText(value);
60
+ }
61
+ const ratio = contrastWith ? contrastRatio(value, contrastWith) : null;
62
+ const verdict = ratio !== null ? contrastVerdict(ratio) : null;
63
+ const contrastId = `${id}-contrast`;
64
+ const describedBy = [fieldDescribedBy, ratio !== null ? contrastId : null].filter(Boolean).join(" ") || undefined;
65
+ // Pendant la frappe, seul un code complet (6 chiffres) est transmis : « #7c3 » deviendrait
66
+ // « #77cc33 » sous les doigts. La forme courte se développe en quittant le champ.
67
+ const commit = (next, complete = /^#?[0-9a-f]{6}$/i.test(next.trim())) => {
68
+ setText(next);
69
+ const normalized = complete ? normalizeHexColor(next) : null;
70
+ if (normalized && normalized !== value)
71
+ onChange(normalized);
72
+ };
73
+ return (_jsxs("div", { "data-slot": "color-field", className: cn("grid gap-2", className), children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("label", { className: cn("relative grid size-11 shrink-0 cursor-pointer place-items-center rounded-2xl ring-1 ring-border/70 ring-inset focus-within:ring-2 focus-within:ring-ring lg:size-10", disabled && "pointer-events-none opacity-50"), style: { background: value }, children: [_jsx("span", { className: "sr-only", children: "Choisir dans le s\u00E9lecteur de couleur" }), _jsx("input", { type: "color", value: normalizeHexColor(value) ?? "#000000", disabled: disabled, onChange: (event) => commit(event.target.value), className: "absolute inset-0 size-full cursor-pointer opacity-0" })] }), _jsx(Input, { id: id, value: text, disabled: disabled, spellCheck: false, autoComplete: "off", inputMode: "text", maxLength: 7, placeholder: "#7c3aed", ...(ariaLabel !== undefined ? { "aria-label": ariaLabel } : {}), "aria-describedby": describedBy, onChange: (event) => commit(event.target.value), onBlur: () => {
74
+ const normalized = normalizeHexColor(text);
75
+ if (normalized)
76
+ commit(normalized, true);
77
+ else
78
+ setText(value);
79
+ }, className: "max-w-36 font-mono uppercase" })] }), ratio !== null && verdict ? (_jsxs("p", { id: contrastId, className: "flex flex-wrap items-center gap-2 text-sm text-muted-foreground", children: [_jsxs("span", { children: ["Contraste avec ", contrastLabel, " :", " ", _jsxs("span", { className: "font-medium text-foreground tabular-nums", children: [ratioFormat.format(ratio), ":1"] })] }), _jsx(StatusBadge, { tone: verdict.tone, label: verdict.label, live: false })] })) : null] }));
80
+ }
@@ -47,10 +47,12 @@ export declare function CommandMenu({ groups, open: openProp, onOpenChange, plac
47
47
  }): React.JSX.Element;
48
48
  /**
49
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.
50
+ * Dans la barre latérale ou l'en-tête. `compact` : la loupe seule, dans un carré de 36 px, le
51
+ * libellé devenant le nom accessible ; `"mobile"` ne compacte que sous `lg`, sans JavaScript.
51
52
  */
52
- export declare function CommandMenuButton({ onClick, label, className, }: {
53
+ export declare function CommandMenuButton({ onClick, label, compact, className, }: {
53
54
  onClick: () => void;
54
55
  label?: string;
56
+ compact?: boolean | "mobile";
55
57
  className?: string;
56
58
  }): React.JSX.Element;
@@ -69,11 +69,13 @@ function noop() { }
69
69
  function subscribeNever() {
70
70
  return noop;
71
71
  }
72
+ const COMPACT = "w-9 max-w-none justify-center px-0";
72
73
  /**
73
74
  * 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
+ * Dans la barre latérale ou l'en-tête. `compact` : la loupe seule, dans un carré de 36 px, le
76
+ * libellé devenant le nom accessible ; `"mobile"` ne compacte que sous `lg`, sans JavaScript.
75
77
  */
76
- export function CommandMenuButton({ onClick, label = "Rechercher", className, }) {
78
+ export function CommandMenuButton({ onClick, label = "Rechercher", compact = false, className, }) {
77
79
  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" })] })] }));
80
+ return (_jsxs("button", { type: "button", onClick: onClick, "aria-keyshortcuts": "Control+K Meta+K", ...(compact === true ? { "aria-label": label, title: label } : {}), "data-compact": compact === false ? undefined : String(compact), 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", compact === true && COMPACT, compact === "mobile" && "max-lg:w-9 max-lg:max-w-none max-lg:justify-center max-lg:px-0", className), children: [_jsx(HugeiconsIcon, { icon: Search01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4 shrink-0" }), _jsx("span", { className: cn("min-w-0 flex-1 truncate text-left", compact === true && "sr-only", compact === "mobile" && "max-lg:sr-only"), children: label }), _jsxs(KbdGroup, { "aria-hidden": true, className: cn("max-lg:hidden", compact === true && "hidden"), children: [_jsx(Kbd, { children: apple ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }));
79
81
  }
@@ -5,11 +5,14 @@ export declare function useCopy(): {
5
5
  };
6
6
  /**
7
7
  * Bouton-icône qui copie une valeur ; l'icône devient une coche, et « Copié » est annoncé aux
8
- * lecteurs d'écran.
8
+ * lecteurs d'écran. `showLabel` écrit le libellé à côté de l'icône (« Tout copier »), pour une
9
+ * copie qui n'est pas collée à sa valeur : tout un bloc d'enregistrements DNS, un journal.
9
10
  */
10
- export declare function CopyButton({ value, label, className, }: {
11
+ export declare function CopyButton({ value, label, showLabel, className, }: {
11
12
  value: string;
12
13
  label?: string;
14
+ /** Libellé visible, qui devient « Copié » le temps de la confirmation. */
15
+ showLabel?: boolean;
13
16
  className?: string;
14
17
  }): React.JSX.Element;
15
18
  /**
@@ -30,10 +30,13 @@ export function useCopy() {
30
30
  }
31
31
  /**
32
32
  * Bouton-icône qui copie une valeur ; l'icône devient une coche, et « Copié » est annoncé aux
33
- * lecteurs d'écran.
33
+ * lecteurs d'écran. `showLabel` écrit le libellé à côté de l'icône (« Tout copier »), pour une
34
+ * copie qui n'est pas collée à sa valeur : tout un bloc d'enregistrements DNS, un journal.
34
35
  */
35
- export function CopyButton({ value, label = "Copier", className, }) {
36
+ export function CopyButton({ value, label = "Copier", showLabel = false, className, }) {
36
37
  const { copied, copy } = useCopy();
38
+ if (showLabel)
39
+ return (_jsxs("button", { type: "button", onClick: () => void copy(value), "data-copied": copied || undefined, className: cn("touch-target inline-flex h-8 shrink-0 pressable items-center gap-1.5 rounded-full px-3 text-sm font-medium 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"), _jsxs("span", { className: "inline-grid", children: [_jsx("span", { className: cn("col-start-1 row-start-1", copied && "invisible"), children: label }), _jsx("span", { "aria-hidden": true, className: cn("col-start-1 row-start-1", !copied && "invisible"), children: "Copi\u00E9" })] }), _jsx("span", { className: "sr-only", "aria-live": "polite", children: copied ? "Copié" : "" })] }));
37
40
  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
41
  }
39
42
  /**