@hgdc/ui 0.20.0 → 0.22.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 CHANGED
@@ -80,25 +80,26 @@ Sur ordinateur, la coque ouvre sur la barre des produits (ADR 0011) : organisati
80
80
 
81
81
  ## Contenu et données
82
82
 
83
- | Import | Exemple |
84
- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
85
- | `card` | `<Card><CardHeader><CardTitle>Domaines</CardTitle><CardAction>…</CardAction></CardHeader><CardContent>…</CardContent><CardFooter>…</CardFooter></Card>` |
86
- | `alert` | `<Alert tone="warning" title="Quota presque atteint" action={<Button size="sm">Voir</Button>}>90 % utilisés.</Alert>` |
87
- | `table` | `<TableHead sort="descending" onSort={…}>Date</TableHead>`, `<TableRowLink render={<Link … />}>Production</TableRowLink>`, `<TableEmpty colSpan={4}>…</TableEmpty>`, `<TableSkeleton columns={4} />` |
88
- | `resource-list` | `<ResourceList><ResourceItem icon={…} title="Site vitrine" meta="acme.ch" badge={…} actions={…} href="/p/site" /></ResourceList>` |
89
- | `stat` | `<StatGrid><StatTile label="Erreurs" value="12" delta={{ value: "8 %", direction: "up", good: "down" }} /></StatGrid>` |
90
- | `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) |
91
- | `status` | `<StatusBadge status="degraded" />` (Opérationnel, Dégradé, Panne, Maintenance, Inconnu) ; hors services, `<StatusBadge tone="success" label="Remis" />` (`neutral`, `success`, `warning`, `danger`, `info`, `pending`) |
92
- | `uptime-bar` | `<UptimeBar days={[{ date, status: "operational", uptime: "100 %" }]} summary="99,95 % sur 90 jours" />` |
93
- | `timeline` | `<Timeline entries={[{ id, title: "Résolu", time, status: "operational" }]} />` |
94
- | `relative-time` | `<RelativeTime date={deployment._creationTime} />` (« il y a 3 min », date exacte au survol) |
95
- | `copy-button` | `<Snippet value="bun add @hgdc/ui" prompt />`, `<CopyButton value={url} />`, `<CopyButton value={all} label="Tout copier" showLabel />` |
96
- | `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) |
97
- | `rank-bar` | `<RankBar label="Pages les plus vues" items={[{ key, label: "/tarifs", value: 640, render: <Link … /> }]} limit={5} />` |
98
- | `secret-reveal` | `<SecretReveal label="Clé d'API" value={key} onDismiss={forget} />` : affichée une fois, puis retirée de la page |
99
- | `family-loader` | `<FamilyLoaderRoot>{children}</FamilyLoaderRoot>` une fois dans le `body` de la route racine : le seul H d'un chargement de page, fixe sur la zone de contenu de la coque, rendu par le serveur, calé sur l'horloge (même pixel et même phase d'une page et d'un produit à l'autre, ADR 0015). `<FamilyLoader size="lg" />` : le H en ligne (une boucle de 2 s, `transform` et `opacity`). Dans un bouton : `Spinner` |
100
- | `page-pending` | `createRouter({ routeTree, ...routerPendingDefaults })` (préchargement au survol, chargement après 50 ms, au moins 300 ms) ; `AppShell {...useRouterState({ select: shellNavigation, structuralSharing: true })}` : la zone de contenu se couvre dès qu'une navigation attend, la coque reste ; `PagePending` : marque la place d'une page qui attend, le H du document s'affiche |
101
- | `skeleton` | `<Pending pending={isLoading} fallback={<TableSkeleton columns={3} />}>…</Pending>` : après 200 ms, tenu 400 ms au moins |
83
+ | Import | Exemple |
84
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
85
+ | `card` | `<Card><CardHeader><CardTitle>Domaines</CardTitle><CardAction>…</CardAction></CardHeader><CardContent>…</CardContent><CardFooter>…</CardFooter></Card>` |
86
+ | `alert` | `<Alert tone="warning" title="Quota presque atteint" action={<Button size="sm">Voir</Button>}>90 % utilisés.</Alert>` |
87
+ | `table` | `<TableHead sort="descending" onSort={…}>Date</TableHead>`, `<TableRowLink render={<Link … />}>Production</TableRowLink>`, `<TableEmpty colSpan={4}>…</TableEmpty>`, `<TableSkeleton columns={4} />` |
88
+ | `resource-list` | `<ResourceList><ResourceItem icon={…} title="Site vitrine" meta="acme.ch" badge={…} actions={…} href="/p/site" /></ResourceList>` |
89
+ | `stat` | `<StatGrid><StatTile label="Erreurs" value="12" delta={{ value: "8 %", direction: "up", good: "down" }} /></StatGrid>` |
90
+ | `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) |
91
+ | `status` | `<StatusBadge status="degraded" />` (Opérationnel, Dégradé, Panne, Maintenance, Inconnu) ; hors services, `<StatusBadge tone="success" label="Remis" />` (`neutral`, `success`, `warning`, `danger`, `info`, `pending`) |
92
+ | `uptime-bar` | `<UptimeBar days={[{ date, status: "operational", uptime: "100 %" }]} summary="99,95 % sur 90 jours" />` |
93
+ | `timeline` | `<Timeline entries={[{ id, title: "Résolu", time, status: "operational" }]} />` |
94
+ | `relative-time` | `<RelativeTime date={deployment._creationTime} />` (« il y a 3 min », date exacte au survol) |
95
+ | `copy-button` | `<Snippet value="bun add @hgdc/ui" prompt />`, `<CopyButton value={url} />`, `<CopyButton value={all} label="Tout copier" showLabel />` |
96
+ | `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) |
97
+ | `rank-bar` | `<RankBar label="Pages les plus vues" items={[{ key, label: "/tarifs", value: 640, render: <Link … /> }]} limit={5} />` |
98
+ | `secret-reveal` | `<SecretReveal label="Clé d'API" value={key} onDismiss={forget} />` : affichée une fois, puis retirée de la page |
99
+ | `assistant-chat` | `<AssistantConversation><AssistantUserMessage>…</AssistantUserMessage><AssistantReply tools={chips} waiting={pending} actions={<AssistantActionCard title=… status="pending" onConfirm={…} onCancel={…} />}><Markdown>{text}</Markdown></AssistantReply></AssistantConversation>`, `<AssistantComposer value={v} onValueChange={setV} onSubmit={send} busy={busy} />`, `<AssistantExamples examples={…} onPick={setV} />` : le produit rend le Markdown |
100
+ | `family-loader` | `<FamilyLoaderRoot>{children}</FamilyLoaderRoot>` une fois dans le `body` de la route racine : le seul H d'un chargement de page, fixe sur la zone de contenu de la coque, rendu par le serveur, calé sur l'horloge (même pixel et même phase d'une page et d'un produit à l'autre, ADR 0015). `<FamilyLoader size="lg" />` : le H en ligne (une boucle de 2 s, `transform` et `opacity`). Dans un bouton : `Spinner` |
101
+ | `page-pending` | `createRouter({ routeTree, ...routerPendingDefaults })` (préchargement au survol, chargement après 50 ms, au moins 300 ms) ; `AppShell {...useRouterState({ select: shellNavigation, structuralSharing: true })}` : la zone de contenu se couvre dès qu'une navigation attend, la coque reste ; `PagePending` : marque la place d'une page qui attend, le H du document s'affiche |
102
+ | `skeleton` | `<Pending pending={isLoading} fallback={<TableSkeleton columns={3} />}>…</Pending>` : après 200 ms, tenu 400 ms au moins |
102
103
 
103
104
  ## Listes, journaux et retours
104
105
 
package/dist/app-shell.js CHANGED
@@ -92,7 +92,7 @@ export function SidebarNavItem({ icon, label, active, href, render, }) {
92
92
  href,
93
93
  "aria-current": active ? "page" : undefined,
94
94
  onClick: closeMobileNav,
95
- className: cn("flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-[current=page]:bg-muted aria-[current=page]:text-foreground data-[status=active]:bg-muted data-[status=active]:text-foreground", "lg:min-h-9 lg:gap-2.5 lg:py-2 lg:text-shell-muted lg:hover:bg-shell-hover lg:hover:text-shell-foreground", "lg:aria-[current=page]:bg-shell-active lg:aria-[current=page]:text-shell-foreground lg:data-[status=active]:bg-shell-active lg:data-[status=active]:text-shell-foreground", "lg:rail:justify-center lg:rail:px-0"),
95
+ className: cn("flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-[current=page]:bg-muted aria-[current=page]:text-foreground data-[status=active]:bg-muted data-[status=active]:text-foreground", "min-h-11 lg:min-h-9 coarse:min-h-11 lg:gap-2.5 lg:py-2 lg:text-shell-muted lg:hover:bg-shell-hover lg:hover:text-shell-foreground", "lg:aria-[current=page]:bg-shell-active lg:aria-[current=page]:text-shell-foreground lg:data-[status=active]:bg-shell-active lg:data-[status=active]:text-shell-foreground", "lg:rail:justify-center lg:rail:px-0"),
96
96
  children: (_jsxs(_Fragment, { children: [_jsx(HugeiconsIcon, { icon: icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-5 shrink-0 lg:size-[1.125rem]" }), _jsx("span", { className: "truncate lg:rail:sr-only", children: label })] })),
97
97
  },
98
98
  });
@@ -104,7 +104,7 @@ export function SidebarNavItem({ icon, label, active, href, render, }) {
104
104
  const COLLAPSE_ICON = PanelLeftOpenIcon;
105
105
  const EXPAND_ICON = PanelLeftCloseIcon;
106
106
  /** Même boîte qu'une entrée de la barre en rail : 40 × 36 px, icône de 18 px centrée. */
107
- const TOGGLE = "relative grid h-9 w-10 shrink-0 place-items-center rounded-xl text-shell-muted outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-shell-hover hover:text-shell-foreground focus-visible:ring-2 focus-visible:ring-ring/50";
107
+ const TOGGLE = "touch-target relative grid h-9 w-10 shrink-0 place-items-center rounded-xl text-shell-muted outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-shell-hover hover:text-shell-foreground focus-visible:ring-2 focus-visible:ring-ring/50";
108
108
  function ShortcutHint({ label }) {
109
109
  return (_jsxs("span", { className: "flex items-center gap-2", children: [label, _jsxs(KbdGroup, { children: [_jsx(Kbd, { children: isApplePlatform() ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: "B" })] })] }));
110
110
  }
@@ -211,5 +211,5 @@ export function AppShell({ product, organization, productRail = true, accountHre
211
211
  if (loading && contentRef.current)
212
212
  contentRef.current.scrollTop = 0;
213
213
  }, [loading]);
214
- return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)]" : "lg:grid-cols-[auto_minmax(0,1fr)]"), children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), productRail ? (_jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsx(SidebarHead, { product: product }), !productRail && organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null, nav, sidebarFooter ? _jsx("div", { className: "grid gap-0.5 px-3 pt-1 pb-3", children: sidebarFooter }) : null] }), _jsx("div", { className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex h-14 shrink-0 items-center gap-1 border-b border-foreground/5 bg-background px-2 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: productName(product) })] }), _jsx("div", { className: "flex min-w-0 flex-1 items-center gap-2", children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsx("main", { id: "contenu", tabIndex: -1, "aria-busy": loading ? true : undefined, inert: loading || undefined, className: cn("mx-auto w-full max-w-6xl flex-1 px-4 pt-6 pb-16 outline-none sm:px-6 lg:px-10 lg:pt-8", mainClassName), children: _jsx(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) }) })] }) }), hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, sidebarFooter ? (_jsx("div", { className: "grid gap-0.5 border-t border-foreground/5 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)]", children: sidebarFooter })) : null] }) })] }) }) }) }));
214
+ return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)]" : "lg:grid-cols-[auto_minmax(0,1fr)]"), children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), productRail ? (_jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsx(SidebarHead, { product: product }), !productRail && organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null, nav, sidebarFooter ? _jsx("div", { className: "grid gap-0.5 px-3 pt-1 pb-3", children: sidebarFooter }) : null] }), _jsx("div", { className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: productName(product) })] }), _jsx("div", { className: cn("flex min-w-0 items-center gap-2", header ? "flex-[1_1_7rem]" : "flex-1"), children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsx("main", { id: "contenu", tabIndex: -1, "aria-busy": loading ? true : undefined, inert: loading || undefined, className: cn("mx-auto w-full max-w-6xl flex-1 px-4 pt-6 pb-16 outline-none sm:px-6 lg:px-10 lg:pt-8", mainClassName), children: _jsx(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) }) })] }) }), hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, sidebarFooter ? (_jsx("div", { className: "grid gap-0.5 border-t border-foreground/5 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)]", children: sidebarFooter })) : null] }) })] }) }) }) }));
215
215
  }
@@ -0,0 +1,77 @@
1
+ import { type ReactNode } from "react";
2
+ export type AssistantToolStatus = "running" | "done" | "error";
3
+ export type AssistantTool = {
4
+ id: string;
5
+ label: string;
6
+ status: AssistantToolStatus;
7
+ };
8
+ export type AssistantActionStatus = "pending" | "confirmed" | "cancelled" | "failed";
9
+ /** La liste des messages, dans l'ordre. */
10
+ export declare function AssistantConversation({ className, ...props }: React.ComponentProps<"ol">): React.JSX.Element;
11
+ /** Ce que la personne a écrit, en bulle à droite. */
12
+ export declare function AssistantUserMessage({ children }: {
13
+ children: ReactNode;
14
+ }): React.JSX.Element;
15
+ /** Les outils consultés pendant une réponse ; une pastille tourne tant que l'outil travaille. */
16
+ export declare function AssistantToolChips({ tools, label, }: {
17
+ tools: ReadonlyArray<AssistantTool>;
18
+ label?: string;
19
+ }): React.JSX.Element | null;
20
+ /**
21
+ * Une réponse : pastilles, puis le texte rendu par le produit (`children`), puis les cartes.
22
+ * `waiting` : rien d'écrit encore, une ligne d'attente à la place du texte.
23
+ */
24
+ export declare function AssistantReply({ tools, waiting, waitingLabel, error, actions, children, }: {
25
+ tools?: ReadonlyArray<AssistantTool>;
26
+ waiting?: boolean;
27
+ waitingLabel?: string;
28
+ /** Réponse en échec : le texte passe en couleur d'erreur. */
29
+ error?: boolean;
30
+ /** Cartes d'action rattachées à cette réponse. */
31
+ actions?: ReactNode;
32
+ children?: ReactNode;
33
+ }): React.JSX.Element;
34
+ /**
35
+ * Une écriture proposée par l'assistant, qui n'a lieu qu'au clic sur « Confirmer ». Une fois
36
+ * traitée, la carte garde son état (fait, annulé, échec et sa raison).
37
+ */
38
+ export declare function AssistantActionCard({ title, details, status, result, onConfirm, onCancel, confirming, cancelling, confirmLabel, cancelLabel, }: {
39
+ title: ReactNode;
40
+ details?: ReadonlyArray<{
41
+ label: string;
42
+ value: string;
43
+ }>;
44
+ status: AssistantActionStatus;
45
+ /** Raison d'un échec. */
46
+ result?: string | null;
47
+ onConfirm: () => void;
48
+ onCancel: () => void;
49
+ confirming?: boolean;
50
+ cancelling?: boolean;
51
+ confirmLabel?: string;
52
+ cancelLabel?: string;
53
+ }): React.JSX.Element;
54
+ /** Des demandes d'exemple, pour une conversation vide : un clic remplit la saisie. */
55
+ export declare function AssistantExamples({ examples, onPick, disabled, label, }: {
56
+ examples: ReadonlyArray<string>;
57
+ onPick: (example: string) => void;
58
+ disabled?: boolean;
59
+ label?: string;
60
+ }): React.JSX.Element;
61
+ /**
62
+ * La saisie : Entrée envoie, Maj+Entrée va à la ligne (et rien pendant une composition IME).
63
+ * `busy` : une réponse est en cours, l'envoi attend.
64
+ */
65
+ export declare function AssistantComposer({ value, onValueChange, onSubmit, disabled, busy, sending, label, placeholder, sendLabel, hint, maxLength, }: {
66
+ value: string;
67
+ onValueChange: (value: string) => void;
68
+ onSubmit: () => void;
69
+ disabled?: boolean;
70
+ busy?: boolean;
71
+ sending?: boolean;
72
+ label?: string;
73
+ placeholder?: string;
74
+ sendLabel?: string;
75
+ hint?: ReactNode;
76
+ maxLength?: number;
77
+ }): React.JSX.Element;
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import ArrowUp02Icon from "@hugeicons/core-free-icons/ArrowUp02Icon";
3
+ import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
4
+ import CheckmarkCircle02Icon from "@hugeicons/core-free-icons/CheckmarkCircle02Icon";
5
+ import { HugeiconsIcon } from "@hugeicons/react";
6
+ import { useId } from "react";
7
+ import { Badge } from "./badge.js";
8
+ import { Button } from "./button.js";
9
+ import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "./card.js";
10
+ import { cn } from "./cn.js";
11
+ import { Spinner } from "./spinner.js";
12
+ import { Textarea } from "./textarea.js";
13
+ /** La liste des messages, dans l'ordre. */
14
+ export function AssistantConversation({ className, ...props }) {
15
+ return (_jsx("ol", { "data-slot": "assistant-conversation", "aria-label": "Conversation", className: cn("grid gap-6", className), ...props }));
16
+ }
17
+ /** Ce que la personne a écrit, en bulle à droite. */
18
+ export function AssistantUserMessage({ children }) {
19
+ return (_jsx("li", { "data-slot": "assistant-user-message", className: "flex min-w-0 justify-end", children: _jsx("p", { className: "max-w-[85%] rounded-2xl bg-muted px-4 py-2.5 text-sm break-words whitespace-pre-wrap", children: children }) }));
20
+ }
21
+ /** Les outils consultés pendant une réponse ; une pastille tourne tant que l'outil travaille. */
22
+ export function AssistantToolChips({ tools, label = "Ce que l'assistant a consulté", }) {
23
+ if (!tools.length)
24
+ return null;
25
+ return (_jsx("ul", { className: "flex flex-wrap gap-1.5", "aria-label": label, children: tools.map((tool) => (_jsx("li", { children: _jsxs(Badge, { tone: tool.status === "error" ? "destructive" : "neutral", children: [tool.status === "running" ? _jsx(Spinner, { size: 3 }) : null, tool.label] }) }, tool.id))) }));
26
+ }
27
+ /**
28
+ * Une réponse : pastilles, puis le texte rendu par le produit (`children`), puis les cartes.
29
+ * `waiting` : rien d'écrit encore, une ligne d'attente à la place du texte.
30
+ */
31
+ export function AssistantReply({ tools = [], waiting = false, waitingLabel = "L'assistant réfléchit…", error = false, actions, children, }) {
32
+ return (_jsxs("li", { "data-slot": "assistant-reply", className: "grid min-w-0 gap-3", children: [_jsx(AssistantToolChips, { tools: tools }), waiting ? (_jsxs("p", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Spinner, { size: 4 }), waitingLabel] })) : (_jsx("div", { className: cn("text-sm leading-relaxed break-words", error && "text-destructive"), children: children })), actions] }));
33
+ }
34
+ const ACTION_BADGE = {
35
+ confirmed: { tone: "success", label: "Fait" },
36
+ failed: { tone: "destructive", label: "Échec" },
37
+ cancelled: { tone: "neutral", label: "Annulé" },
38
+ };
39
+ /**
40
+ * Une écriture proposée par l'assistant, qui n'a lieu qu'au clic sur « Confirmer ». Une fois
41
+ * traitée, la carte garde son état (fait, annulé, échec et sa raison).
42
+ */
43
+ export function AssistantActionCard({ title, details = [], status, result, onConfirm, onCancel, confirming = false, cancelling = false, confirmLabel = "Confirmer", cancelLabel = "Annuler", }) {
44
+ const badge = status === "pending" ? null : ACTION_BADGE[status];
45
+ return (_jsxs(Card, { "data-slot": "assistant-action-card", className: "max-w-xl", children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: title }) }), details.length ? (_jsx(CardContent, { children: _jsx("dl", { className: "grid gap-2 text-sm", children: details.map((row) => (_jsxs("div", { className: "grid gap-0.5", children: [_jsx("dt", { className: "text-muted-foreground", children: row.label }), _jsx("dd", { className: "break-words whitespace-pre-wrap", children: row.value })] }, row.label))) }) })) : null, _jsx(CardFooter, { className: "justify-start", children: badge ? (_jsx(Badge, { tone: badge.tone, children: status === "failed" && result ? `${badge.label} : ${result}` : badge.label })) : (_jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsxs(Button, { size: "sm", isLoading: confirming, disabled: cancelling, onClick: onConfirm, children: [_jsx(HugeiconsIcon, { icon: CheckmarkCircle02Icon, "data-icon": "inline-start" }), confirmLabel] }), _jsxs(Button, { size: "sm", variant: "ghost", isLoading: cancelling, disabled: confirming, onClick: onCancel, children: [_jsx(HugeiconsIcon, { icon: Cancel01Icon, "data-icon": "inline-start" }), cancelLabel] })] })) })] }));
46
+ }
47
+ /** Des demandes d'exemple, pour une conversation vide : un clic remplit la saisie. */
48
+ export function AssistantExamples({ examples, onPick, disabled = false, label = "Par exemple :", }) {
49
+ return (_jsxs("div", { className: "grid gap-2", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: label }), _jsx("ul", { className: "flex flex-wrap gap-2", children: examples.map((example) => (_jsx("li", { children: _jsx(Button, { size: "sm", variant: "outline", disabled: disabled, onClick: () => onPick(example), children: example }) }, example))) })] }));
50
+ }
51
+ /**
52
+ * La saisie : Entrée envoie, Maj+Entrée va à la ligne (et rien pendant une composition IME).
53
+ * `busy` : une réponse est en cours, l'envoi attend.
54
+ */
55
+ export function AssistantComposer({ value, onValueChange, onSubmit, disabled = false, busy = false, sending = false, label = "Ta demande", placeholder = "Demande quelque chose à l'assistant…", sendLabel = "Envoyer", hint = "Entrée pour envoyer, Maj+Entrée pour aller à la ligne.", maxLength = 4000, }) {
56
+ const fieldId = useId();
57
+ const canSend = !disabled && !busy && !sending && value.trim().length > 0;
58
+ function submit(event) {
59
+ event?.preventDefault();
60
+ if (canSend)
61
+ onSubmit();
62
+ }
63
+ function onKeyDown(event) {
64
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
65
+ event.preventDefault();
66
+ submit();
67
+ }
68
+ }
69
+ return (_jsxs("form", { "data-slot": "assistant-composer", onSubmit: submit, className: "grid gap-2", children: [_jsx("label", { htmlFor: fieldId, className: "sr-only", children: label }), _jsxs("div", { className: "relative", children: [_jsx(Textarea, { id: fieldId, value: value, onChange: (event) => onValueChange(event.target.value), onKeyDown: onKeyDown, rows: 3, maxLength: maxLength, disabled: disabled, placeholder: placeholder, className: "pr-14" }), _jsx(Button, { type: "submit", size: "icon", "aria-label": sendLabel, className: "absolute right-2 bottom-2", disabled: !canSend, isLoading: sending, children: _jsx(HugeiconsIcon, { icon: ArrowUp02Icon }) })] }), hint ? _jsx("p", { className: "text-xs text-muted-foreground", children: hint }) : null] }));
70
+ }
package/dist/board.d.ts CHANGED
@@ -14,6 +14,15 @@ export type BoardMove = {
14
14
  toColumnId: string;
15
15
  toIndex: number;
16
16
  };
17
+ /**
18
+ * Défilement d'une image quand le pointeur tient une carte en `x` : négatif vers la gauche, positif
19
+ * vers la droite, nul hors des bandes de bord. Le bord est celui de la partie visible du tableau
20
+ * (`rect` rogné à l'écran) : sur téléphone, le tableau déborde de la page.
21
+ */
22
+ export declare function edgeScrollSpeed(x: number, rect: {
23
+ left: number;
24
+ right: number;
25
+ }, viewportWidth: number): number;
17
26
  /**
18
27
  * Tableau de colonnes (Kanban) dont les cartes se déplacent à la souris, au doigt ou au clavier,
19
28
  * par leur poignée (`handle`, à placer dans la carte) : le reste de la carte garde ses clics et le
package/dist/board.js CHANGED
@@ -1,8 +1,50 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId, useRef, useState } from "react";
2
+ import { useEffect, useId, useRef, useState } from "react";
3
3
  import { cn } from "./cn.js";
4
4
  /** Distance avant qu'un appui devienne un glisser : un clic reste un clic. */
5
5
  const DRAG_THRESHOLD = 6;
6
+ /** Bande, près du bord visible du tableau, où une carte tenue fait défiler les colonnes. */
7
+ const EDGE_ZONE = 56;
8
+ /** Vitesse au ras du bord, en pixels par image ; elle croît à mesure que le doigt s'en approche. */
9
+ const EDGE_MAX_SPEED = 14;
10
+ /**
11
+ * Défilement d'une image quand le pointeur tient une carte en `x` : négatif vers la gauche, positif
12
+ * vers la droite, nul hors des bandes de bord. Le bord est celui de la partie visible du tableau
13
+ * (`rect` rogné à l'écran) : sur téléphone, le tableau déborde de la page.
14
+ */
15
+ export function edgeScrollSpeed(x, rect, viewportWidth) {
16
+ const left = Math.max(rect.left, 0);
17
+ const right = Math.min(rect.right, viewportWidth);
18
+ const width = right - left;
19
+ if (width <= 0)
20
+ return 0;
21
+ const zone = Math.min(EDGE_ZONE, width / 4);
22
+ if (x < left + zone)
23
+ return -Math.ceil(EDGE_MAX_SPEED * Math.min(1, (left + zone - x) / zone));
24
+ if (x > right - zone)
25
+ return Math.ceil(EDGE_MAX_SPEED * Math.min(1, (x - (right - zone)) / zone));
26
+ return 0;
27
+ }
28
+ /** Centre horizontal de la carte fantôme, gardée dans l'écran avec une marge de 8 px. */
29
+ function clampGhostX(x, width) {
30
+ const half = width / 2 + 8;
31
+ const max = window.innerWidth - half;
32
+ return max < half ? window.innerWidth / 2 : Math.min(Math.max(x, half), max);
33
+ }
34
+ /** Colonne et place sous le pointeur, d'après le milieu des cartes. */
35
+ function positionAt(x, y, cardId) {
36
+ const element = document.elementFromPoint(x, y);
37
+ const column = element?.closest("[data-board-column]");
38
+ const columnId = column?.dataset.boardColumn;
39
+ if (!column || !columnId)
40
+ return null;
41
+ const cards = [...column.querySelectorAll("[data-board-card]")].filter((node) => node.dataset.boardCard !== cardId);
42
+ const index = cards.findIndex((node) => {
43
+ const rect = node.getBoundingClientRect();
44
+ return y < rect.top + rect.height / 2;
45
+ });
46
+ return { columnId, index: index === -1 ? cards.length : index };
47
+ }
6
48
  function moveInColumns(columns, cardKey, grab) {
7
49
  const base = columns.map((column) => ({ column, cards: [...column.cards] }));
8
50
  if (!grab)
@@ -33,6 +75,10 @@ export function Board({ label, columns, cardKey, cardLabel, renderCard, onMove,
33
75
  const [announcement, setAnnouncement] = useState("");
34
76
  const [ghost, setGhost] = useState(null);
35
77
  const pressed = useRef(null);
78
+ const scrollerRef = useRef(null);
79
+ const lastPoint = useRef(null);
80
+ const scrollFrame = useRef(0);
81
+ useEffect(() => () => cancelAnimationFrame(scrollFrame.current), []);
36
82
  const instructionsId = useId();
37
83
  const view = moveInColumns(columns, cardKey, grab);
38
84
  const byId = new Map(columns.flatMap((column) => column.cards.map((card) => [cardKey(card), card])));
@@ -64,6 +110,7 @@ export function Board({ label, columns, cardKey, cardLabel, renderCard, onMove,
64
110
  : `${cardLabel(card)} : déplacement annulé.`
65
111
  : "");
66
112
  const id = grab.cardId;
113
+ stopAutoScroll();
67
114
  setGrab(null);
68
115
  setGhost(null);
69
116
  if (grab.via === "keyboard")
@@ -114,19 +161,41 @@ export function Board({ label, columns, cardKey, cardLabel, renderCard, onMove,
114
161
  setAnnouncement(where(next));
115
162
  focusCard(id);
116
163
  }
117
- /** Colonne et place sous le pointeur, d'après le milieu des cartes. */
118
- function positionAt(x, y, cardId) {
119
- const element = document.elementFromPoint(x, y);
120
- const column = element?.closest("[data-board-column]");
121
- const columnId = column?.dataset.boardColumn;
122
- if (!column || !columnId)
123
- return null;
124
- const cards = [...column.querySelectorAll("[data-board-card]")].filter((node) => node.dataset.boardCard !== cardId);
125
- const index = cards.findIndex((node) => {
126
- const rect = node.getBoundingClientRect();
127
- return y < rect.top + rect.height / 2;
128
- });
129
- return { columnId, index: index === -1 ? cards.length : index };
164
+ /**
165
+ * Défilement automatique : une carte tenue près du bord visible fait défiler le tableau, image
166
+ * par image, tant que le doigt y reste. Sans lui, sur téléphone, seule la colonne voisine était
167
+ * atteignable (2026-10-18). La boucle s'arrête d'elle-même hors des bords ou en bout de course.
168
+ */
169
+ function autoScroll() {
170
+ if (scrollFrame.current !== 0)
171
+ return;
172
+ const step = () => {
173
+ scrollFrame.current = 0;
174
+ const scroller = scrollerRef.current;
175
+ const press = pressed.current;
176
+ const point = lastPoint.current;
177
+ if (!scroller || !press || !point)
178
+ return;
179
+ const speed = edgeScrollSpeed(point.x, scroller.getBoundingClientRect(), window.innerWidth);
180
+ if (speed === 0)
181
+ return;
182
+ const before = scroller.scrollLeft;
183
+ scroller.scrollLeft = before + speed;
184
+ if (scroller.scrollLeft === before)
185
+ return;
186
+ // Les colonnes ont glissé sous le doigt immobile : la place visée a changé.
187
+ const next = positionAt(point.x, point.y, press.cardId);
188
+ if (next)
189
+ setGrab((current) => (current ? { ...current, to: next } : current));
190
+ scrollFrame.current = requestAnimationFrame(step);
191
+ };
192
+ scrollFrame.current = requestAnimationFrame(step);
193
+ }
194
+ function stopAutoScroll() {
195
+ if (scrollFrame.current !== 0)
196
+ cancelAnimationFrame(scrollFrame.current);
197
+ scrollFrame.current = 0;
198
+ lastPoint.current = null;
130
199
  }
131
200
  function onPointerDown(event, card, position) {
132
201
  if (event.button !== 0 || !canMove(card))
@@ -158,6 +227,8 @@ export function Board({ label, columns, cardKey, cardLabel, renderCard, onMove,
158
227
  const next = positionAt(event.clientX, event.clientY, press.cardId);
159
228
  if (next)
160
229
  setGrab((current) => (current ? { ...current, to: next } : current));
230
+ lastPoint.current = { x: event.clientX, y: event.clientY };
231
+ autoScroll();
161
232
  }
162
233
  function onPointerUp(event) {
163
234
  const press = pressed.current;
@@ -168,7 +239,9 @@ export function Board({ label, columns, cardKey, cardLabel, renderCard, onMove,
168
239
  finish(true);
169
240
  }
170
241
  const ghostCard = grab?.via === "pointer" ? byId.get(grab.cardId) : undefined;
171
- return (_jsxs("div", { className: cn("grid gap-2", className), children: [_jsx("p", { id: instructionsId, className: "sr-only", children: "Espace ou Entr\u00E9e pour saisir une carte, fl\u00E8ches pour la d\u00E9placer, Espace pour la poser, \u00C9chap pour annuler." }), _jsx("div", { role: "region", "aria-label": label, className: "-mx-1 flex snap-x snap-mandatory gap-3 overflow-x-auto px-1 pb-2 sm:snap-none", children: view.map(({ column, cards }) => (_jsxs("section", { "data-board-column": column.id, "aria-label": column.label, className: cn("grid w-[85vw] max-w-80 shrink-0 snap-start content-start gap-2 rounded-3xl bg-foreground/[0.04] p-2 sm:w-72", grab?.to.columnId === column.id && "ring-2 ring-ring/40"), children: [_jsx("header", { className: "flex items-center justify-between gap-2 px-2 pt-1 text-sm font-medium", children: column.title }), _jsx("ul", { className: "grid min-h-12 gap-2", children: cards.map((card, index) => {
242
+ return (_jsxs("div", { className: cn("grid gap-2", className), children: [_jsx("p", { id: instructionsId, className: "sr-only", children: "Espace ou Entr\u00E9e pour saisir une carte, fl\u00E8ches pour la d\u00E9placer, Espace pour la poser, \u00C9chap pour annuler." }), _jsx("div", { ref: scrollerRef, role: "region", "aria-label": label, className: cn("-mx-1 flex snap-x snap-mandatory gap-3 overflow-x-auto px-1 pb-2 sm:snap-none",
243
+ // L'aimantation ramènerait le tableau à la colonne de départ après chaque pas du défilement automatique.
244
+ ghostCard !== undefined && "snap-none"), children: view.map(({ column, cards }) => (_jsxs("section", { "data-board-column": column.id, "aria-label": column.label, className: cn("grid w-[85vw] max-w-80 shrink-0 snap-start content-start gap-2 rounded-3xl bg-foreground/[0.04] p-2 sm:w-72", grab?.to.columnId === column.id && "ring-2 ring-ring/40"), children: [_jsx("header", { className: "flex items-center justify-between gap-2 px-2 pt-1 text-sm font-medium", children: column.title }), _jsx("ul", { className: "grid min-h-12 gap-2", children: cards.map((card, index) => {
172
245
  const id = cardKey(card);
173
246
  const dragging = grab?.cardId === id;
174
247
  const movable = canMove(card);
@@ -178,7 +251,9 @@ export function Board({ label, columns, cardKey, cardLabel, renderCard, onMove,
178
251
  pressed.current = null;
179
252
  if (grab)
180
253
  finish(false);
181
- }, className: "inline-flex size-7 shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing", children: _jsxs("svg", { viewBox: "0 0 16 16", "aria-hidden": true, className: "size-4 fill-current", children: [_jsx("circle", { cx: "5.5", cy: "3.5", r: "1.25" }), _jsx("circle", { cx: "10.5", cy: "3.5", r: "1.25" }), _jsx("circle", { cx: "5.5", cy: "8", r: "1.25" }), _jsx("circle", { cx: "10.5", cy: "8", r: "1.25" }), _jsx("circle", { cx: "5.5", cy: "12.5", r: "1.25" }), _jsx("circle", { cx: "10.5", cy: "12.5", r: "1.25" })] }) })) : null,
254
+ }, className: "touch-target inline-flex size-7 shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing", children: _jsxs("svg", { viewBox: "0 0 16 16", "aria-hidden": true, className: "size-4 fill-current", children: [_jsx("circle", { cx: "5.5", cy: "3.5", r: "1.25" }), _jsx("circle", { cx: "10.5", cy: "3.5", r: "1.25" }), _jsx("circle", { cx: "5.5", cy: "8", r: "1.25" }), _jsx("circle", { cx: "10.5", cy: "8", r: "1.25" }), _jsx("circle", { cx: "5.5", cy: "12.5", r: "1.25" }), _jsx("circle", { cx: "10.5", cy: "12.5", r: "1.25" })] }) })) : null,
182
255
  }) }, id));
183
- }) }), column.footer] }, column.id))) }), ghostCard !== undefined && ghost ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-1/2 rotate-1 opacity-90", style: { left: ghost.x, top: ghost.y, width: ghost.width }, children: renderCard(ghostCard, { dragging: true, handle: null }) })) : null, _jsx("div", { "aria-live": "assertive", className: "sr-only", children: announcement })] }));
256
+ }) }), column.footer] }, column.id))) }), ghostCard !== undefined && ghost ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-1/2 rotate-1 opacity-90",
257
+ // Tenue dans l'écran : une carte de 85 vw centrée sur un doigt au bord débordait de moitié.
258
+ style: { left: clampGhostX(ghost.x, ghost.width), top: ghost.y, width: ghost.width }, children: renderCard(ghostCard, { dragging: true, handle: null }) })) : null, _jsx("div", { "aria-live": "assertive", className: "sr-only", children: announcement })] }));
184
259
  }
@@ -64,7 +64,7 @@ export function CommandMenu({ groups, open: openProp, onOpenChange, placeholder
64
64
  return (_jsx(DialogPrimitive.Root, { open: open, onOpenChange: setOpen, children: _jsxs(DialogPrimitive.Portal, { children: [_jsx(DialogPrimitive.Backdrop, { className: "fixed inset-0 z-50 bg-foreground/30 dark:bg-black/60" }), _jsx(DialogPrimitive.Viewport, { className: "fixed inset-0 z-50 flex items-start justify-center px-2 pt-[min(12dvh,6rem)] pb-2", children: _jsx(DialogPrimitive.Popup, { "data-slot": "command-menu", "aria-label": "Palette de commandes", className: cn(FLOATING_SURFACE, "flex max-h-[min(36rem,calc(100dvh-2rem))] w-full max-w-xl flex-col overflow-hidden rounded-3xl shadow-2xl shadow-black/10 outline-none"), children: _jsxs(Autocomplete.Root, { open: true, inline: true, items: visibleGroups, itemToStringValue: (command) => command.label, filter: (command, query) => matchCommand(command, query), autoHighlight: "always", keepHighlight: true, children: [_jsxs(Autocomplete.InputGroup, { className: "flex items-center gap-3 border-b border-foreground/5 px-4", children: [_jsx(HugeiconsIcon, { icon: Search01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" }), _jsx(Autocomplete.Input, { "aria-label": "Rechercher une commande", placeholder: placeholder, className: "h-14 w-full min-w-0 bg-transparent text-base outline-none placeholder:text-muted-foreground" }), _jsx(DialogPrimitive.Close, { className: "sr-only", children: "Fermer la palette" })] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5 [scroll-padding-block:0.375rem]", children: [_jsx(Autocomplete.Empty, { className: "px-3 py-10 text-center text-sm text-muted-foreground empty:hidden", children: emptyLabel }), _jsx(Autocomplete.List, { children: (group) => (_jsxs(Autocomplete.Group, { items: group.items, className: "not-last:mb-1.5", children: [_jsx(Autocomplete.GroupLabel, { className: "px-3 pt-2 pb-1.5 text-xs font-medium text-muted-foreground", children: group.label }), _jsx(Autocomplete.Collection, { children: (command) => (_jsxs(Autocomplete.Item, { value: command, onClick: () => {
65
65
  setOpen(false);
66
66
  command.onSelect();
67
- }, className: "flex min-h-11 cursor-default items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium outline-none select-none data-highlighted:bg-muted lg:min-h-10", children: [command.badge ? (_jsx(ProductBadge, { product: command.badge, className: "size-4.5" })) : command.icon ? (_jsx(HugeiconsIcon, { icon: command.icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" })) : null, _jsx("span", { className: "min-w-0 flex-1 truncate", children: command.label }), command.hint ? (_jsx("span", { className: "shrink-0 truncate text-xs font-normal text-muted-foreground", children: command.hint })) : null, command.shortcut ? (_jsx(KbdGroup, { className: "hidden shrink-0 sm:inline-flex", children: command.shortcut.map((key) => (_jsx(Kbd, { children: key }, key))) })) : null] }, command.id)) })] }, group.label)) })] }), _jsxs("div", { className: "hidden items-center gap-4 border-t border-foreground/5 px-4 py-2.5 text-xs text-muted-foreground sm:flex", children: [_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsxs(KbdGroup, { children: [_jsx(Kbd, { children: "\u2191" }), _jsx(Kbd, { children: "\u2193" })] }), "Choisir"] }), _jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx(Kbd, { children: "Entr\u00E9e" }), "Ouvrir"] }), _jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx(Kbd, { children: "\u00C9chap" }), "Fermer"] })] })] }) }) })] }) }));
67
+ }, className: "flex min-h-11 cursor-default items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium outline-none select-none data-highlighted:bg-muted lg:min-h-10 coarse:min-h-11", children: [command.badge ? (_jsx(ProductBadge, { product: command.badge, className: "size-4.5" })) : command.icon ? (_jsx(HugeiconsIcon, { icon: command.icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" })) : null, _jsx("span", { className: "min-w-0 flex-1 truncate", children: command.label }), command.hint ? (_jsx("span", { className: "shrink-0 truncate text-xs font-normal text-muted-foreground", children: command.hint })) : null, command.shortcut ? (_jsx(KbdGroup, { className: "hidden shrink-0 sm:inline-flex", children: command.shortcut.map((key) => (_jsx(Kbd, { children: key }, key))) })) : null] }, command.id)) })] }, group.label)) })] }), _jsxs("div", { className: "hidden items-center gap-4 border-t border-foreground/5 px-4 py-2.5 text-xs text-muted-foreground sm:flex", children: [_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsxs(KbdGroup, { children: [_jsx(Kbd, { children: "\u2191" }), _jsx(Kbd, { children: "\u2193" })] }), "Choisir"] }), _jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx(Kbd, { children: "Entr\u00E9e" }), "Ouvrir"] }), _jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx(Kbd, { children: "\u00C9chap" }), "Fermer"] })] })] }) }) })] }) }));
68
68
  }
69
69
  function noop() { }
70
70
  function subscribeNever() {
@@ -113,6 +113,6 @@ export function DateRangePicker({ value, onChange, presets = DEFAULT_DATE_RANGE_
113
113
  return (_jsx("li", { children: _jsx("button", { type: "button", "aria-pressed": pressed, onClick: () => {
114
114
  onChange(preset.range(today), preset.key);
115
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));
116
+ }, className: cn("flex min-h-11 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 coarse:min-h-11", pressed && "bg-muted font-medium"), children: preset.label }) }, preset.key));
117
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
118
  }
@@ -34,19 +34,19 @@ function DropdownMenuLabel({ className, inset, ...props }) {
34
34
  return (_jsx(MenuPrimitive.GroupLabel, { "data-slot": "dropdown-menu-label", "data-inset": inset, className: cn("px-3 py-2.5 text-xs text-muted-foreground data-inset:pl-9.5", className), ...props }));
35
35
  }
36
36
  function DropdownMenuItem({ className, inset, variant = "default", ...props }) {
37
- return (_jsx(MenuPrimitive.Item, { "data-slot": "dropdown-menu-item", "data-inset": inset, "data-variant": variant, className: cn("group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-muted focus:text-foreground data-inset:pl-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive max-lg:min-h-11 dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", className), ...props }));
37
+ return (_jsx(MenuPrimitive.Item, { "data-slot": "dropdown-menu-item", "data-inset": inset, "data-variant": variant, className: cn("group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-muted focus:text-foreground data-inset:pl-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive max-lg:min-h-11 coarse:min-h-11 dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", className), ...props }));
38
38
  }
39
39
  function DropdownMenuSub({ ...props }) {
40
40
  return _jsx(MenuPrimitive.SubmenuRoot, { "data-slot": "dropdown-menu-sub", ...props });
41
41
  }
42
42
  function DropdownMenuSubTrigger({ className, inset, children, ...props }) {
43
- return (_jsxs(MenuPrimitive.SubmenuTrigger, { "data-slot": "dropdown-menu-sub-trigger", "data-inset": inset, className: cn("flex cursor-default items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-muted focus:text-foreground data-inset:pl-9.5 data-popup-open:bg-muted data-popup-open:text-foreground data-open:bg-muted data-open:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [children, _jsx(HugeiconsIcon, { icon: ArrowRight01Icon, strokeWidth: 2, className: "ml-auto" })] }));
43
+ return (_jsxs(MenuPrimitive.SubmenuTrigger, { "data-slot": "dropdown-menu-sub-trigger", "data-inset": inset, className: cn("flex cursor-default items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-muted focus:text-foreground data-inset:pl-9.5 max-lg:min-h-11 coarse:min-h-11 data-popup-open:bg-muted data-popup-open:text-foreground data-open:bg-muted data-open:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [children, _jsx(HugeiconsIcon, { icon: ArrowRight01Icon, strokeWidth: 2, className: "ml-auto" })] }));
44
44
  }
45
45
  function DropdownMenuSubContent({ align = "start", alignOffset = -3, side = "right", sideOffset = 0, className, ...props }) {
46
46
  return (_jsx(DropdownMenuContent, { "data-slot": "dropdown-menu-sub-content", collisionAvoidance: FLOATING_SUBMENU_COLLISION_AVOIDANCE, className: cn("relative w-auto min-w-36 rounded-2xl p-1.5 **:data-[slot$=-item]:focus:bg-muted **:data-[slot$=-item]:data-highlighted:bg-muted **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-muted **:data-[slot$=-trigger]:aria-expanded:bg-muted! **:data-[variant=destructive]:focus:bg-destructive/10!", className), align: align, alignOffset: alignOffset, side: side, sideOffset: sideOffset, ...props }));
47
47
  }
48
48
  function DropdownMenuCheckboxItem({ className, children, checked, inset, ...props }) {
49
- return (_jsxs(MenuPrimitive.CheckboxItem, { "data-slot": "dropdown-menu-checkbox-item", "data-inset": inset, className: cn("relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-muted focus:text-foreground data-inset:pl-9.5 max-lg:min-h-11 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), checked: checked, ...props, children: [_jsx("span", { className: "pointer-events-none absolute right-2 flex items-center justify-center", "data-slot": "dropdown-menu-checkbox-item-indicator", children: _jsx(MenuPrimitive.CheckboxItemIndicator, { children: _jsx(HugeiconsIcon, { icon: Tick02Icon, strokeWidth: 2 }) }) }), children] }));
49
+ return (_jsxs(MenuPrimitive.CheckboxItem, { "data-slot": "dropdown-menu-checkbox-item", "data-inset": inset, className: cn("relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-muted focus:text-foreground data-inset:pl-9.5 max-lg:min-h-11 coarse:min-h-11 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), checked: checked, ...props, children: [_jsx("span", { className: "pointer-events-none absolute right-2 flex items-center justify-center", "data-slot": "dropdown-menu-checkbox-item-indicator", children: _jsx(MenuPrimitive.CheckboxItemIndicator, { children: _jsx(HugeiconsIcon, { icon: Tick02Icon, strokeWidth: 2 }) }) }), children] }));
50
50
  }
51
51
  /**
52
52
  * `label` titre le groupe. `Menu.RadioGroup` ne pose pas le contexte de
@@ -60,7 +60,7 @@ function DropdownMenuRadioGroup({ label, ...props }) {
60
60
  return (_jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: label }), group] }));
61
61
  }
62
62
  function DropdownMenuRadioItem({ className, children, inset, ...props }) {
63
- return (_jsxs(MenuPrimitive.RadioItem, { "data-slot": "dropdown-menu-radio-item", "data-inset": inset, className: cn("relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-muted focus:text-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [_jsx("span", { className: "pointer-events-none absolute right-2 flex items-center justify-center", "data-slot": "dropdown-menu-radio-item-indicator", children: _jsx(MenuPrimitive.RadioItemIndicator, { children: _jsx(HugeiconsIcon, { icon: Tick02Icon, strokeWidth: 2 }) }) }), children] }));
63
+ return (_jsxs(MenuPrimitive.RadioItem, { "data-slot": "dropdown-menu-radio-item", "data-inset": inset, className: cn("relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-muted focus:text-foreground data-inset:pl-9.5 max-lg:min-h-11 coarse:min-h-11 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [_jsx("span", { className: "pointer-events-none absolute right-2 flex items-center justify-center", "data-slot": "dropdown-menu-radio-item-indicator", children: _jsx(MenuPrimitive.RadioItemIndicator, { children: _jsx(HugeiconsIcon, { icon: Tick02Icon, strokeWidth: 2 }) }) }), children] }));
64
64
  }
65
65
  function DropdownMenuSeparator({ className, ...props }) {
66
66
  return (_jsx(MenuPrimitive.Separator, { "data-slot": "dropdown-menu-separator", className: cn("-mx-1.5 my-1.5 h-px bg-border/50", className), ...props }));
package/dist/input-otp.js CHANGED
@@ -18,7 +18,7 @@ function InputOTPGroup({ className, ...props }) {
18
18
  function InputOTPSlot({ index, className, ...props }) {
19
19
  const context = React.useContext(OTPInputContext);
20
20
  const { char, hasFakeCaret, isActive } = context.slots[index] ?? {};
21
- return (_jsxs("div", { "data-slot": "input-otp-slot", "data-active": isActive, className: cn("relative flex h-13 w-11 items-center justify-center rounded-2xl bg-background text-xl font-medium tabular-nums ring-1 ring-border/70 transition-[box-shadow] duration-(--duration-fast) ring-inset aria-invalid:ring-2 aria-invalid:ring-destructive/60 data-[active=true]:ring-2 data-[active=true]:ring-ring sm:h-12 sm:w-10 sm:text-lg", className), ...props, children: [char, hasFakeCaret ? (_jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center", children: _jsx("div", { className: "h-5 w-px bg-foreground motion-safe:animate-pulse" }) })) : null] }));
21
+ return (_jsxs("div", { "data-slot": "input-otp-slot", "data-active": isActive, className: cn("relative flex h-13 w-11 items-center justify-center rounded-2xl bg-background text-xl font-medium tabular-nums ring-1 ring-control-off transition-[box-shadow] duration-(--duration-fast) ring-inset aria-invalid:ring-2 aria-invalid:ring-destructive/60 data-[active=true]:ring-2 data-[active=true]:ring-ring sm:h-12 sm:w-10 sm:text-lg", className), ...props, children: [char, hasFakeCaret ? (_jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center", children: _jsx("div", { className: "h-5 w-px bg-foreground motion-safe:animate-pulse" }) })) : null] }));
22
22
  }
23
23
  function InputOTPSeparator(props) {
24
24
  return (_jsx("div", { "data-slot": "input-otp-separator", role: "separator", className: "h-px w-2.5 bg-muted-foreground/40", ...props }));
package/dist/input.js CHANGED
@@ -21,9 +21,12 @@ const PICKER_ICONS = {
21
21
  time: Clock01Icon,
22
22
  };
23
23
  const INPUT_CLASS =
24
- // Le champ du site : fond de page, un filet à 70 % au repos, et l'anneau
25
- // vert plein au focus — le seul endroit où l'accent dit « c'est ici ».
26
- "h-11 lg:h-10 touch-field w-full min-w-0 rounded-2xl bg-background px-3.5 py-1 text-base ring-1 ring-border/70 transition-[color,box-shadow,background-color] duration-(--duration-fast) outline-none ring-inset file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-2 aria-invalid:ring-destructive/60 md:text-sm";
24
+ // Le champ du site : fond de page, un filet `control-off` au repos (3:1 du
25
+ // fond, WCAG 1.4.11 : à 70 % de `border`, un champ vide disparaissait sur une
26
+ // page blanche), et l'anneau plein au focus, le seul endroit où l'accent dit
27
+ // « c'est ici ». Texte à 16 px à toutes les largeurs : sous 16 px, Safari
28
+ // zoome au focus, et un iPad (768, 1024) en recevait 14.
29
+ "h-11 lg:h-10 touch-field w-full min-w-0 rounded-2xl bg-background px-3.5 py-1 text-base ring-1 ring-control-off transition-[color,box-shadow,background-color] duration-(--duration-fast) outline-none ring-inset file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-2 aria-invalid:ring-destructive/60";
27
30
  function Input({ className, type, ...props }) {
28
31
  const fieldId = useFieldId();
29
32
  const describedBy = useFieldDescribedBy();
@@ -22,7 +22,7 @@ export function RoleBadge({ orgRole: role, title, className, }) {
22
22
  return (_jsx(Badge, { tone: "neutral", "data-role": role, title: title, className: className, children: ORG_ROLE_LABEL[role] }));
23
23
  }
24
24
  /** Un lien dans un menu, à l'allure d'un `DropdownMenuItem`. */
25
- export const MENU_LINK_ITEM = "relative flex cursor-default items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-muted focus:text-foreground data-highlighted:bg-muted max-lg:min-h-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
25
+ export const MENU_LINK_ITEM = "relative flex cursor-default items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-muted focus:text-foreground data-highlighted:bg-muted max-lg:min-h-11 coarse:min-h-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
26
26
  /** Marque d'une organisation : son logo, ou ses initiales (`initials`) sur un carré arrondi. */
27
27
  export function OrgMark({ org, className, }) {
28
28
  if (org.logo)
@@ -43,7 +43,7 @@ export function OrgSwitcher({ organizations, activeId, onSelect, createHref, onC
43
43
  return null;
44
44
  // En haut de la barre des produits : la marque seule dans 40 px, le nom quand la barre s'élargit.
45
45
  const inRail = placement === "product-rail";
46
- return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": `Organisation ${active.name}, changer d'organisation`, className: cn("flex h-10 w-full min-w-0 items-center gap-2.5 rounded-xl px-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", inRail
46
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": `Organisation ${active.name}, changer d'organisation`, className: cn("flex h-10 w-full min-w-0 items-center gap-2.5 rounded-xl px-1.5 text-left coarse:h-11 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", inRail
47
47
  ? "shrink-0 gap-3 px-1 hover:bg-shell-hover aria-expanded:bg-shell-hover"
48
48
  : "lg:hover:bg-shell-hover lg:aria-expanded:bg-shell-hover lg:rail:w-auto lg:rail:justify-center lg:rail:px-1", className) }), children: [_jsx(OrgMark, { org: active }), _jsxs("span", { className: cn("min-w-0 flex-1", inRail
49
49
  ? "opacity-0 transition-opacity duration-(--duration-fast) ease-out group-data-expanded/rail:opacity-100 motion-reduce:transition-none"
@@ -3,7 +3,7 @@ import ArrowRight01Icon from "@hugeicons/core-free-icons/ArrowRight01Icon";
3
3
  import { HugeiconsIcon } from "@hugeicons/react";
4
4
  import { useRender } from "@base-ui/react/use-render";
5
5
  import { cn } from "./cn.js";
6
- const crumbLink = "rounded-md text-muted-foreground transition-colors duration-(--duration-fast) ease-out outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50";
6
+ const crumbLink = "touch-target rounded-md text-muted-foreground transition-colors duration-(--duration-fast) ease-out outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50";
7
7
  function CrumbLink({ crumb }) {
8
8
  return useRender({
9
9
  defaultTagName: "a",
@@ -10,7 +10,7 @@ export const PRODUCT_RAIL_OPEN_DELAY = 150;
10
10
  function ProductLink({ entry }) {
11
11
  // Chaque produit s'ouvre en document complet : la coque montre le H dès le clic.
12
12
  const leaveOnClick = useLeaveOnClick();
13
- return (_jsx("li", { children: _jsxs("a", { href: entry.href, onClick: leaveOnClick, "aria-current": entry.current ? "page" : undefined, className: cn("flex h-10 items-center gap-3 rounded-xl px-2 text-shell-muted outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-shell-hover hover:text-shell-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-[current=page]:bg-shell-active aria-[current=page]:text-shell-foreground"), children: [_jsx(ProductBadge, { product: entry.badge, className: "size-6" }), _jsxs("span", { className: "min-w-0 flex-1 truncate opacity-0 transition-opacity duration-(--duration-fast) ease-out group-data-expanded/rail:opacity-100 motion-reduce:transition-none", children: [_jsx("span", { className: "font-heading text-sm font-medium", children: entry.name }), entry.hint ? _jsx("span", { className: "sr-only", children: ", " }) : null, entry.hint ? _jsx("span", { className: "ml-1.5 text-xs text-shell-muted", children: entry.hint }) : null] })] }) }));
13
+ return (_jsx("li", { children: _jsxs("a", { href: entry.href, onClick: leaveOnClick, "aria-current": entry.current ? "page" : undefined, className: cn("flex h-10 items-center gap-3 rounded-xl px-2 text-shell-muted coarse:h-11 outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-shell-hover hover:text-shell-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-[current=page]:bg-shell-active aria-[current=page]:text-shell-foreground"), children: [_jsx(ProductBadge, { product: entry.badge, className: "size-6" }), _jsxs("span", { className: "min-w-0 flex-1 truncate opacity-0 transition-opacity duration-(--duration-fast) ease-out group-data-expanded/rail:opacity-100 motion-reduce:transition-none", children: [_jsx("span", { className: "font-heading text-sm font-medium", children: entry.name }), entry.hint ? _jsx("span", { className: "sr-only", children: ", " }) : null, entry.hint ? _jsx("span", { className: "ml-1.5 text-xs text-shell-muted", children: entry.hint }) : null] })] }) }));
14
14
  }
15
15
  /**
16
16
  * La barre des produits de la famille, tout à gauche de la coque sur ordinateur : organisation en
@@ -15,7 +15,7 @@ export declare function ResourceItem({ icon, title, meta, badge, actions, href,
15
15
  /** Icône, favicon ou marque, dans une tuile de 40 px. */
16
16
  icon?: ReactNode;
17
17
  title: ReactNode;
18
- /** Une ligne de contexte : adresse, date, auteur. Tronquée si trop longue. */
18
+ /** Une ligne de contexte : adresse, date, auteur. Deux lignes au plus sur un écran étroit, puis tronquée. */
19
19
  meta?: ReactNode;
20
20
  badge?: ReactNode;
21
21
  /** Boutons-icônes ou menu « … ». */
@@ -17,7 +17,7 @@ export function ResourceList({ className, surface = true, ...props }) {
17
17
  */
18
18
  export function ResourceItem({ icon, title, meta, badge, actions, href, render, className, }) {
19
19
  const linked = href !== undefined || render !== undefined;
20
- return (_jsxs("li", { "data-slot": "resource-item", className: cn("relative flex min-h-16 items-center gap-3 px-4 py-3 transition-colors duration-(--duration-fast) ease-out not-first:border-t not-first:border-foreground/5 sm:gap-4 sm:px-5", linked && "hover:bg-foreground/[0.03] has-focus-visible:bg-foreground/[0.03]", className), children: [icon ? (_jsx("span", { "aria-hidden": true, className: "grid size-10 shrink-0 place-items-center overflow-hidden rounded-xl bg-background text-muted-foreground dark:bg-muted [&_svg:not([class*='size-'])]:size-5", children: icon })) : null, _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: linked ? (_jsx(ResourceLink, { href: href, render: render, children: title })) : (title) }), meta ? _jsx("p", { className: "mt-0.5 truncate text-xs text-muted-foreground", children: meta }) : null] }), badge ? _jsx("div", { className: "shrink-0", children: badge }) : null, actions ? _jsx("div", { className: "relative z-1 flex shrink-0 items-center gap-1", children: actions }) : null] }));
20
+ return (_jsxs("li", { "data-slot": "resource-item", className: cn("relative flex min-h-16 items-center gap-3 px-4 py-3 transition-colors duration-(--duration-fast) ease-out not-first:border-t not-first:border-foreground/5 sm:gap-4 sm:px-5", linked && "hover:bg-foreground/[0.03] has-focus-visible:bg-foreground/[0.03]", className), children: [icon ? (_jsx("span", { "aria-hidden": true, className: "grid size-10 shrink-0 place-items-center overflow-hidden rounded-xl bg-background text-muted-foreground dark:bg-muted [&_svg:not([class*='size-'])]:size-5", children: icon })) : null, _jsxs("div", { className: "flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-2 sm:gap-x-4", children: [_jsxs("div", { className: "min-w-0 flex-[1_1_12rem]", children: [_jsx("p", { className: "truncate text-sm font-medium", children: linked ? (_jsx(ResourceLink, { href: href, render: render, children: title })) : (title) }), meta ? _jsx("p", { className: "mt-0.5 line-clamp-2 text-xs break-words text-muted-foreground", children: meta }) : null] }), badge || actions ? (_jsxs("div", { "data-slot": "resource-item-trailing", className: "flex max-w-full shrink-0 flex-wrap items-center gap-x-3 gap-y-2 sm:gap-x-4", children: [badge ? _jsx("div", { className: "shrink-0", children: badge }) : null, actions ? _jsx("div", { className: "relative z-1 flex shrink-0 items-center gap-1", children: actions }) : null] })) : null] })] }));
21
21
  }
22
22
  function ResourceLink({ href, render, children, }) {
23
23
  return useRender({
package/dist/select.js CHANGED
@@ -27,9 +27,9 @@ import { cn } from "./cn.js";
27
27
  * </SelectContent>
28
28
  * </Select>
29
29
  */
30
- // Même recette que `Input` : filet à 70 % au repos, anneau vert au focus et
31
- // quand le menu est ouvert.
32
- const triggerClasses = "flex h-11 lg:h-10 touch-field w-full min-w-0 items-center justify-between gap-2 rounded-2xl bg-background px-3.5 text-base ring-1 ring-border/70 transition-[color,box-shadow,background-color] duration-(--duration-fast) outline-none ring-inset placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-2 aria-invalid:ring-destructive/60 data-popup-open:ring-2 data-popup-open:ring-ring md:text-sm";
30
+ // Même recette que `Input` : filet `control-off` au repos, 16 px, anneau plein
31
+ // au focus et quand le menu est ouvert.
32
+ const triggerClasses = "flex h-11 lg:h-10 touch-field w-full min-w-0 items-center justify-between gap-2 rounded-2xl bg-background px-3.5 text-base ring-1 ring-control-off transition-[color,box-shadow,background-color] duration-(--duration-fast) outline-none ring-inset placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-2 aria-invalid:ring-destructive/60 data-popup-open:ring-2 data-popup-open:ring-ring";
33
33
  function Select(props) {
34
34
  return _jsx(SelectPrimitive.Root, { ...props });
35
35
  }
@@ -59,7 +59,7 @@ function SelectContent({ className, align = "start", alignOffset = 0, side = "bo
59
59
  "relative z-50 max-h-(--available-height) min-w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl p-1.5 transition-[scale,opacity] duration-(--duration-fast) ease-ios outline-none data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-(--duration-instant) data-starting-style:scale-[0.98] data-starting-style:opacity-0", className), ...props }) }) }));
60
60
  }
61
61
  function SelectItem({ className, children, ...props }) {
62
- return (_jsxs(SelectPrimitive.Item, { "data-slot": "select-item", className: cn("relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-muted data-highlighted:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [_jsx(SelectPrimitive.ItemText, { children: children }), _jsx(SelectPrimitive.ItemIndicator, { className: "pointer-events-none absolute right-2 flex items-center justify-center", children: _jsx(HugeiconsIcon, { icon: Tick02Icon, strokeWidth: 2, className: "size-4" }) })] }));
62
+ return (_jsxs(SelectPrimitive.Item, { "data-slot": "select-item", className: cn("relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-muted data-highlighted:bg-muted max-lg:min-h-11 coarse:min-h-11 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [_jsx(SelectPrimitive.ItemText, { children: children }), _jsx(SelectPrimitive.ItemIndicator, { className: "pointer-events-none absolute right-2 flex items-center justify-center", children: _jsx(HugeiconsIcon, { icon: Tick02Icon, strokeWidth: 2, className: "size-4" }) })] }));
63
63
  }
64
64
  function SelectGroup({ ...props }) {
65
65
  return _jsx(SelectPrimitive.Group, { "data-slot": "select-group", ...props });
package/dist/switch.js CHANGED
@@ -17,7 +17,7 @@ import { cn } from "./cn.js";
17
17
  * pouce le trouve sans viser.
18
18
  */
19
19
  export function Switch({ checked, onCheckedChange, disabled, className, ...props }) {
20
- return (_jsx(SwitchPrimitive.Root, { checked: checked, onCheckedChange: onCheckedChange, disabled: disabled, className: cn("relative inline-flex h-8 w-[3.25rem] shrink-0 items-center rounded-full p-0.5", "transition-colors duration-200 ease-ios", "outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2", "focus-visible:ring-offset-background",
20
+ return (_jsx(SwitchPrimitive.Root, { checked: checked, onCheckedChange: onCheckedChange, disabled: disabled, className: cn("touch-target relative inline-flex h-8 w-[3.25rem] shrink-0 items-center rounded-full p-0.5", "transition-colors duration-200 ease-ios", "outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2", "focus-visible:ring-offset-background",
21
21
  // Désactivé : tout l'interrupteur s'estompe, la piste ne disparaît pas.
22
22
  "disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed data-disabled:opacity-50",
23
23
  // Éteint : piste grise à 3:1 du fond, de la carte et de `--muted` ; allumé : l'accent d'état.
package/dist/table.js CHANGED
@@ -36,7 +36,7 @@ function TableRow({ className, ...props }) {
36
36
  function TableHead({ className, children, sort, onSort, numeric = false, ...props }) {
37
37
  const sortable = onSort !== undefined;
38
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"), 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) }));
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("touch-target -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"), 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
40
  }
41
41
  function TableCell({ className, numeric = false, ...props }) {
42
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",
package/dist/tabs.d.ts CHANGED
@@ -9,8 +9,12 @@ declare function Tabs({ className, ...props }: TabsPrimitive.Root.Props): React.
9
9
  /**
10
10
  * La rangée d'onglets. L'onglet ouvert est marqué d'une pastille teintée qui glisse de l'un à
11
11
  * l'autre, monochrome comme la barre latérale : l'accent reste au focus.
12
+ *
13
+ * Trop d'onglets pour la largeur : la rangée défile, et son bord s'efface en dégradé du côté où il
14
+ * en reste (masque, pas de couleur ajoutée). Sans cet indice, l'onglet coupé au bord passait pour
15
+ * le dernier (dix onglets d'un projet de Portal sur téléphone, 2026-10-18).
12
16
  */
13
- declare function TabsList({ className, children, ...props }: TabsPrimitive.List.Props): React.JSX.Element;
17
+ declare function TabsList({ className, children, ref, ...props }: TabsPrimitive.List.Props): React.JSX.Element;
14
18
  declare function TabsTab({ className, ...props }: TabsPrimitive.Tab.Props): React.JSX.Element;
15
19
  declare function TabsPanel({ className, ...props }: TabsPrimitive.Panel.Props): React.JSX.Element;
16
20
  export { Tabs, TabsList, TabsTab, TabsPanel };
package/dist/tabs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
3
+ import { useEffect, useImperativeHandle, useRef } from "react";
3
4
  import { cn } from "./cn.js";
4
5
  /**
5
6
  * Onglets d'une même page (Aperçu, Journal, Réglages). Flèches pour passer d'un onglet à
@@ -10,12 +11,47 @@ import { cn } from "./cn.js";
10
11
  function Tabs({ className, ...props }) {
11
12
  return _jsx(TabsPrimitive.Root, { "data-slot": "tabs", className: cn("grid gap-6", className), ...props });
12
13
  }
14
+ /**
15
+ * Pose `data-overflow-start` et `data-overflow-end` sur une rangée qui défile horizontalement,
16
+ * selon qu'il reste du contenu caché à gauche ou à droite. Recalculé au défilement et quand la
17
+ * rangée ou ses onglets changent de taille.
18
+ */
19
+ function useOverflowEdges(forwarded) {
20
+ const local = useRef(null);
21
+ useImperativeHandle(forwarded, () => local.current, []);
22
+ useEffect(() => {
23
+ const node = local.current;
24
+ if (!node)
25
+ return undefined;
26
+ const update = () => {
27
+ const max = node.scrollWidth - node.clientWidth;
28
+ node.toggleAttribute("data-overflow-start", max > 1 && node.scrollLeft > 1);
29
+ node.toggleAttribute("data-overflow-end", max > 1 && node.scrollLeft < max - 1);
30
+ };
31
+ update();
32
+ node.addEventListener("scroll", update, { passive: true });
33
+ const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(update);
34
+ observer?.observe(node);
35
+ for (const child of node.children)
36
+ observer?.observe(child);
37
+ return () => {
38
+ node.removeEventListener("scroll", update);
39
+ observer?.disconnect();
40
+ };
41
+ }, []);
42
+ return local;
43
+ }
13
44
  /**
14
45
  * La rangée d'onglets. L'onglet ouvert est marqué d'une pastille teintée qui glisse de l'un à
15
46
  * l'autre, monochrome comme la barre latérale : l'accent reste au focus.
47
+ *
48
+ * Trop d'onglets pour la largeur : la rangée défile, et son bord s'efface en dégradé du côté où il
49
+ * en reste (masque, pas de couleur ajoutée). Sans cet indice, l'onglet coupé au bord passait pour
50
+ * le dernier (dix onglets d'un projet de Portal sur téléphone, 2026-10-18).
16
51
  */
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" })] }));
52
+ function TabsList({ className, children, ref, ...props }) {
53
+ const edgesRef = useOverflowEdges(ref);
54
+ return (_jsxs(TabsPrimitive.List, { ref: edgesRef, "data-slot": "tabs-list", className: cn("relative z-0 flex w-fit max-w-full scroll-fade-x 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
55
  }
20
56
  function TabsTab({ className, ...props }) {
21
57
  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 }));
package/dist/textarea.js CHANGED
@@ -11,6 +11,6 @@ import { cn } from "./cn.js";
11
11
  function Textarea({ className, rows, style, ...props }) {
12
12
  const fieldId = useFieldId();
13
13
  const describedBy = useFieldDescribedBy();
14
- return (_jsx("textarea", { "data-slot": "textarea", rows: rows, style: rows ? { minHeight: `calc(${rows}lh + 1.5rem)`, ...style } : style, "aria-describedby": props["aria-describedby"] ?? describedBy, className: cn("flex field-sizing-content min-h-16 touch-field w-full resize-none rounded-2xl bg-background px-3.5 py-3 text-base ring-1 ring-border/70 transition-[color,box-shadow,background-color] duration-(--duration-fast) outline-none ring-inset placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-2 aria-invalid:ring-destructive/60 md:text-sm", className), id: props.id ?? fieldId, ...props }));
14
+ return (_jsx("textarea", { "data-slot": "textarea", rows: rows, style: rows ? { minHeight: `calc(${rows}lh + 1.5rem)`, ...style } : style, "aria-describedby": props["aria-describedby"] ?? describedBy, className: cn("flex field-sizing-content min-h-16 touch-field w-full resize-none rounded-2xl bg-background px-3.5 py-3 text-base ring-1 ring-control-off transition-[color,box-shadow,background-color] duration-(--duration-fast) outline-none ring-inset placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-2 aria-invalid:ring-destructive/60", className), id: props.id ?? fieldId, ...props }));
15
15
  }
16
16
  export { Textarea };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Interface de la H Collection : jetons de la DA, thèmes, polices et composants.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",
package/styles.css CHANGED
@@ -24,6 +24,9 @@
24
24
  @custom-variant rail (&:where([data-sidebar="rail"] *, [data-rail-peek] *));
25
25
  /* Pliée par la personne seulement (pas le temps du survol de la barre des produits). */
26
26
  @custom-variant folded (&:where([data-sidebar="rail"] *));
27
+ /* Écran tactile, quelle que soit la largeur : `coarse:min-h-11` rend ses 44 px à une entrée que
28
+ `lg:` avait resserrée pour la souris. Déclarée après les points de rupture, elle les emporte. */
29
+ @custom-variant coarse (@media (pointer: coarse));
27
30
 
28
31
  /* Courbes et durées. Pas d'ease-in : il démarre lentement à l'instant où l'œil regarde. */
29
32
  @theme {
@@ -133,7 +136,7 @@
133
136
  --destructive: oklch(0.577 0.245 27.325);
134
137
  --border: oklch(0.922 0 0);
135
138
  --input: oklch(0.922 0 0);
136
- /* Contrôle éteint (piste d'interrupteur, bord de case et de bouton radio) : 3:1 au moins contre
139
+ /* Contrôle éteint (piste d'interrupteur, bord de case, de bouton radio et de champ) : 3:1 au moins contre
137
140
  le fond, une carte et `--muted` (WCAG 1.4.11, vérifié par contrast.test.ts). */
138
141
  --control-off: oklch(0.62 0 0);
139
142
  --ring: var(--accent);
@@ -375,9 +378,12 @@
375
378
  }
376
379
  }
377
380
 
378
- /* Zone tactile de 44 px sur écran tactile, sans changer la taille visible. */
381
+ /* Zone tactile de 44 px sur écran tactile, sans changer la taille visible. Le pointeur seul
382
+ décide, jamais la largeur : un iPad en paysage (1024 px) a la coque d'ordinateur et le doigt
383
+ d'un téléphone. Sans `hover: none` non plus : un écran tactile qui annonce aussi un survol
384
+ (iPad avec clavier, portable tactile) gardait des cibles de 36 px. */
379
385
  @utility touch-target {
380
- @media (hover: none) and (pointer: coarse) {
386
+ @media (pointer: coarse) {
381
387
  position: relative;
382
388
  &::after {
383
389
  content: "";
@@ -394,11 +400,32 @@
394
400
  }
395
401
 
396
402
  @utility touch-field {
397
- @media (hover: none) and (pointer: coarse) {
403
+ @media (pointer: coarse) {
398
404
  min-height: 2.75rem;
399
405
  }
400
406
  }
401
407
 
408
+ /* Rangée qui défile à l'horizontale (onglets) : le bord s'efface en dégradé du côté où il reste du
409
+ contenu caché, `data-overflow-start` / `data-overflow-end` posés par le composant. Un masque, pas
410
+ une couleur : rien ne s'ajoute à la DA, et le fond de la page reste celui qu'on voit. */
411
+ @utility scroll-fade-x {
412
+ --fade-start: 0px;
413
+ --fade-end: 0px;
414
+ mask-image: linear-gradient(
415
+ to right,
416
+ transparent,
417
+ #000 var(--fade-start),
418
+ #000 calc(100% - var(--fade-end)),
419
+ transparent
420
+ );
421
+ &[data-overflow-start] {
422
+ --fade-start: 2.5rem;
423
+ }
424
+ &[data-overflow-end] {
425
+ --fade-end: 2.5rem;
426
+ }
427
+ }
428
+
402
429
  @utility date-field {
403
430
  appearance: none;
404
431
  }