@hgdc/ui 0.30.1 → 0.32.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
@@ -48,6 +48,7 @@ Primitives déjà là : `avatar` `badge` `button` `dialog` `drawer` `dropdown-me
48
48
  | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
49
  | `app-shell` | `<AppShell product="mail" organization={<OrgSwitcher …/>} nav={<SidebarNav label="Projet">…</SidebarNav>} links={{ documentation: "/docs", settings: { render: <Link to="/settings" /> } }} account={<AccountMenu …/>} onSearch={() => setOpen(true)} assistant={{ open, onOpenChange: setOpen, panel: <Conversation /> }}>` : Documentation, Réglages et État du service (la page de statut par défaut) juste au-dessus du compte ; bouton Assistant à côté de la recherche, panneau dans une colonne à droite (la page se rétrécit, vue dédiée sous 1280 px, Échap ferme, `shortcut: "j"` facultatif). `sidebarFooter` est déprécié |
50
50
  | | `<SidebarNavItem icon={Rocket01Icon} label="Déploiements" render={<Link to="/deployments" />} />` |
51
+ | | Navigation imbriquée (l'espace d'une page, d'un projet) : `<SidebarNavBack label="Retour aux pages" render={<Link to="/pages" />} />` en tête, puis `<SidebarNavGroup label="Réglages">…</SidebarNavGroup>` (titre muet, trait en rail) |
51
52
  | `org-switcher` | `<OrgSwitcher organizations={orgs} activeId={org.id} onSelect={go} createHref="/organisations/nouvelle" />` |
52
53
  | `project-switcher` | `<ProjectSwitcher projects={projects} activeId={project?.id} activeName={params.project} allHref="/o/acme" createHref="/o/acme/new" renderLink={(href) => <Link to={href} />} />` : projet actif ou « Tous les projets », marque du projet (`ProjectMark`), « Nouveau projet » |
53
54
  | `account-menu` | `<AccountMenu user={user} onProfile={…} onSecurity={…} onSignOut={signOut} product="Mail" />` : Account et les produits ouverts du catalogue, Mail coché. Dans l'ordre : identité, Profil, Sécurité, Thème (Système, Clair, Sombre, `ThemeSwitch`), entrées du produit, Se déconnecter. Ni « État du service » ni raccourcis : la coque pose le premier au-dessus du compte, l'aide garde sa touche `?` |
@@ -109,26 +110,28 @@ Sur ordinateur, la coque ouvre sur la barre des produits (ADR 0011) : organisati
109
110
  | `rank-bar` | `<RankBar label="Pages les plus vues" items={[{ key, label: "/tarifs", value: 640, render: <Link … /> }]} limit={5} />` |
110
111
  | `secret-reveal` | `<SecretReveal label="Clé d'API" value={key} onDismiss={forget} />` : affichée une fois, puis retirée de la page |
111
112
  | `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 |
113
+ | `assistant-view` | `<AssistantView status={status} threads={threads} threadId={id} onThreadChange={setId} thread={thread} renderText={(t) => <Markdown>{t}</Markdown>} onSend={send} onConfirm={confirm} onCancel={cancel} />` : le panneau complet pour `AppShell` (`assistant.panel`), aux formes de `@hgdc/assistant/convex` ; `subject` (page ouverte), `request` (demande venue de la page), `labels` (vouvoiement) |
112
114
  | `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` |
113
115
  | `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 |
114
116
  | `skeleton` | `<Pending pending={isLoading} fallback={<TableSkeleton columns={3} />}>…</Pending>` : après 200 ms, tenu 400 ms au moins |
115
117
 
116
118
  ## Listes, journaux et retours
117
119
 
118
- | Import | Exemple |
119
- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
120
- | `filter-bar` | `<FilterBar search={{ value: q, onChange }} filters={[{ key: "status", label: "Statut", options }]} values={{ status }} onFilterChange={…} onReset={…} />` |
121
- | `data-table` | `<DataTable label="Déploiements" rows={rows} columns={columns} rowKey={(d) => d.id} sort={sort} onSortChange={…} filters={<FilterBar …/>} loadMore={…} />` |
122
- | `cursor-pages` | `const pages = useCursorPages(JSON.stringify(filters))` : pile de curseurs, 200 lignes par page (`PAGE_SIZE`), retour à la page 1 quand la clé change, `pages.paginationOpts` pour `paginate` de Convex |
123
- | `cursor-pager` | `<CursorPager pages={pages} result={result} scrollTarget={topRef} />` : Précédent, Suivant, « Lignes 201 à 400 », chargement sans saut ; règle des grandes listes dans le guide |
124
- | `board` | `<Board label="Tableau du projet" columns={[{ id, label, title, cards }]} cardKey={(c) => c.id} cardLabel={(c) => c.title} renderCard={(c, { handle }) => …} onMove={…} />` : poignée souris, doigt, clavier |
125
- | `dns-record-table` | `<DnsRecordTable records={[{ type: "MX", name: "send", value, priority: 10, status: "pending" }]} />` (Vérifié, En attente, Échec, À ajouter) |
126
- | `log-viewer` | `<LogViewer lines={[{ id, time, level: "error", message }]} live />` : niveaux, recherche, « Suivre », copie ; seules les lignes visibles sont rendues |
127
- | `sparkline` | `<Sparkline label="Envois sur 7 jours" values={[3, 8, 5]} color={2} />` |
128
- | `middle-truncate` | `<MiddleTruncate text={deployment.url} />` : « …/routes/index.tsx », texte entier dans la page |
129
- | `error-card` | `<ErrorCard title="Impossible de charger les journaux" detail={message} reference={requestId} onRetry={refetch} />` |
130
- | `destructive-dialog` | `<DestructiveDialog open={open} onOpenChange={setOpen} title="Supprimer le domaine" question="…" name="acme.ch" onConfirm={remove} />` |
131
- | `sonner` | `<Toaster />` une fois, puis `toastUndo("Domaine retiré", { onUndo: restore })` ; `errorMessage(error)` pour un toast d'erreur |
120
+ | Import | Exemple |
121
+ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
122
+ | `filter-bar` | `<FilterBar search={{ value: q, onChange }} filters={[{ key: "status", label: "Statut", options }]} values={{ status }} onFilterChange={…} onReset={…} />` |
123
+ | | `variant="panel"` : un bouton « Filtres » (compteur des filtres posés) ouvre un panneau, bulle sur ordinateur et feuille du bas sur téléphone ; `onResetFilters` garde la recherche, `leading` pose une vue (`SegmentedControl`) avant la recherche |
124
+ | `data-table` | `<DataTable label="Déploiements" rows={rows} columns={columns} rowKey={(d) => d.id} sort={sort} onSortChange={…} filters={<FilterBar …/>} loadMore={…} />` |
125
+ | `cursor-pages` | `const pages = useCursorPages(JSON.stringify(filters))` : pile de curseurs, 200 lignes par page (`PAGE_SIZE`), retour à la page 1 quand la clé change, `pages.paginationOpts` pour `paginate` de Convex |
126
+ | `cursor-pager` | `<CursorPager pages={pages} result={result} scrollTarget={topRef} />` : Précédent, Suivant, « Lignes 201 à 400 », chargement sans saut ; règle des grandes listes dans le guide |
127
+ | `board` | `<Board label="Tableau du projet" columns={[{ id, label, title, cards }]} cardKey={(c) => c.id} cardLabel={(c) => c.title} renderCard={(c, { handle }) => …} onMove={…} />` : poignée souris, doigt, clavier |
128
+ | `dns-record-table` | `<DnsRecordTable records={[{ type: "MX", name: "send", value, priority: 10, status: "pending" }]} />` (Vérifié, En attente, Échec, À ajouter) |
129
+ | `log-viewer` | `<LogViewer lines={[{ id, time, level: "error", message }]} live />` : niveaux, recherche, « Suivre », copie ; seules les lignes visibles sont rendues |
130
+ | `sparkline` | `<Sparkline label="Envois sur 7 jours" values={[3, 8, 5]} color={2} />` |
131
+ | `middle-truncate` | `<MiddleTruncate text={deployment.url} />` : « …/routes/index.tsx », texte entier dans la page |
132
+ | `error-card` | `<ErrorCard title="Impossible de charger les journaux" detail={message} reference={requestId} onRetry={refetch} />` |
133
+ | `destructive-dialog` | `<DestructiveDialog open={open} onOpenChange={setOpen} title="Supprimer le domaine" question="…" name="acme.ch" onConfirm={remove} />` |
134
+ | `sonner` | `<Toaster />` une fois, puis `toastUndo("Domaine retiré", { onUndo: restore })` ; `errorMessage(error)` pour un toast d'erreur |
132
135
 
133
136
  Filtres, tri et page vivent dans l'URL du produit : les composants sont contrôlés et ne connaissent pas le routeur.
134
137
 
@@ -13,7 +13,10 @@ export declare function toggleSidebar(): void;
13
13
  export declare function useAppShell(): {
14
14
  closeMobileNav: () => void;
15
15
  };
16
- /** La navigation du produit : 4 à 7 entrées, à plat. */
16
+ /**
17
+ * La navigation du produit : 4 à 7 entrées, à plat. Une navigation imbriquée (l'espace d'une page,
18
+ * d'un projet) commence par `SidebarNavBack` et range ses entrées dans des `SidebarNavGroup`.
19
+ */
17
20
  export declare function SidebarNav({ label, className, children, }: {
18
21
  /** Nom de la navigation pour les lecteurs d'écran : « Projet », « Organisation ». */
19
22
  label: string;
@@ -33,6 +36,27 @@ export declare function SidebarNavItem({ icon, label, active, href, render, }: {
33
36
  /** Lien du routeur : `render={<Link to="…" />}`. */
34
37
  render?: React.ReactElement;
35
38
  }): React.JSX.Element;
39
+ /**
40
+ * Retour au niveau du dessus, en tête d'une navigation imbriquée : « Retour aux pages » quand la
41
+ * barre montre l'espace d'une page, d'un projet. Premier enfant de `SidebarNav`, au-dessus des
42
+ * groupes. Une pastille teintée, flèche à gauche, jamais « vous êtes ici ». En rail, la flèche seule
43
+ * et le texte en infobulle.
44
+ */
45
+ export declare function SidebarNavBack({ label, href, render, }: {
46
+ /** « Retour aux pages » */
47
+ label: string;
48
+ href?: string;
49
+ /** Lien du routeur : `render={<Link to="…" />}`. */
50
+ render?: React.ReactElement;
51
+ }): React.JSX.Element;
52
+ /**
53
+ * Un groupe titré de la barre : « Suivi », « Réglages ». Ses `SidebarNavItem` en enfants. Le titre
54
+ * nomme la liste pour les lecteurs d'écran ; en rail, il cède la place à un trait.
55
+ */
56
+ export declare function SidebarNavGroup({ label, children }: {
57
+ label: string;
58
+ children: ReactNode;
59
+ }): React.JSX.Element;
36
60
  /**
37
61
  * Un accès commun de la barre : une adresse, ou un lien du routeur (`{ render: <Link to="/settings" /> }`,
38
62
  * dont le `data-status="active"` marque la page ouverte), ou une adresse avec `active`.
package/dist/app-shell.js CHANGED
@@ -81,7 +81,10 @@ const ShellContext = createContext({ closeMobileNav: () => { } });
81
81
  export function useAppShell() {
82
82
  return useContext(ShellContext);
83
83
  }
84
- /** La navigation du produit : 4 à 7 entrées, à plat. */
84
+ /**
85
+ * La navigation du produit : 4 à 7 entrées, à plat. Une navigation imbriquée (l'espace d'une page,
86
+ * d'un projet) commence par `SidebarNavBack` et range ses entrées dans des `SidebarNavGroup`.
87
+ */
85
88
  export function SidebarNav({ label, className, children, }) {
86
89
  return (_jsx("nav", { "aria-label": label, className: cn("min-h-0 flex-1 overflow-y-auto px-3 py-2", className), children: _jsx("ul", { className: "grid gap-0.5", children: children }) }));
87
90
  }
@@ -106,6 +109,38 @@ export function SidebarNavItem({ icon, label, active, href, render, }) {
106
109
  });
107
110
  return (_jsx("li", { children: _jsxs(Tooltip, { disabled: !rail, children: [_jsx(TooltipTrigger, { render: link }), _jsx(TooltipContent, { side: "right", children: label })] }) }));
108
111
  }
112
+ /**
113
+ * Retour au niveau du dessus, en tête d'une navigation imbriquée : « Retour aux pages » quand la
114
+ * barre montre l'espace d'une page, d'un projet. Premier enfant de `SidebarNav`, au-dessus des
115
+ * groupes. Une pastille teintée, flèche à gauche, jamais « vous êtes ici ». En rail, la flèche seule
116
+ * et le texte en infobulle.
117
+ */
118
+ export function SidebarNavBack({ label, href, render, }) {
119
+ const rail = useSidebarRail();
120
+ const { closeMobileNav } = useAppShell();
121
+ const link = useRender({
122
+ defaultTagName: "a",
123
+ render,
124
+ props: {
125
+ href,
126
+ onClick: closeMobileNav,
127
+ "data-slot": "sidebar-nav-back",
128
+ className: cn(
129
+ // Jamais marqué actif : le lien du routeur pose `data-status="active"` sur un parent ouvert.
130
+ "group/back flex items-center gap-2 rounded-xl bg-muted/70 px-3 py-2.5 text-sm font-medium whitespace-nowrap text-foreground outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50", "min-h-11 lg:min-h-9 coarse:min-h-11 lg:py-2 lg:bg-shell-hover lg:text-shell-foreground lg:hover:bg-shell-active", "lg:rail:justify-center lg:rail:px-0"),
131
+ children: (_jsxs(_Fragment, { children: [_jsx(HugeiconsIcon, { icon: ArrowLeft01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground transition-transform duration-(--duration-fast) ease-out group-hover/back:-translate-x-0.5 motion-reduce:transition-none lg:size-4 lg:text-shell-muted" }), _jsx("span", { className: "truncate lg:rail:sr-only", children: label })] })),
132
+ },
133
+ });
134
+ return (_jsx("li", { className: "pb-2", children: _jsxs(Tooltip, { disabled: !rail, children: [_jsx(TooltipTrigger, { render: link }), _jsx(TooltipContent, { side: "right", children: label })] }) }));
135
+ }
136
+ /**
137
+ * Un groupe titré de la barre : « Suivi », « Réglages ». Ses `SidebarNavItem` en enfants. Le titre
138
+ * nomme la liste pour les lecteurs d'écran ; en rail, il cède la place à un trait.
139
+ */
140
+ export function SidebarNavGroup({ label, children }) {
141
+ const id = useId();
142
+ return (_jsxs("li", { "data-slot": "sidebar-nav-group", className: "pt-4 first:pt-0", children: [_jsx("p", { id: id, className: "px-3 pb-1.5 text-xs font-medium text-muted-foreground lg:text-shell-muted lg:rail:sr-only", children: label }), _jsx("span", { "aria-hidden": true, className: "mx-auto mb-2 hidden h-px w-6 bg-border lg:rail:block" }), _jsx("ul", { "aria-labelledby": id, className: "grid gap-0.5", children: children })] }));
143
+ }
109
144
  const COMMON_LINKS = [
110
145
  { key: "documentation", label: "Documentation", icon: BookOpen01Icon },
111
146
  { key: "settings", label: "Réglages", icon: Settings01Icon },
@@ -35,7 +35,7 @@ export declare function AssistantReply({ tools, waiting, waitingLabel, error, ac
35
35
  * Une écriture proposée par l'assistant, qui n'a lieu qu'au clic sur « Confirmer ». Une fois
36
36
  * traitée, la carte garde son état (fait, annulé, échec et sa raison).
37
37
  */
38
- export declare function AssistantActionCard({ title, details, status, result, onConfirm, onCancel, confirming, cancelling, confirmLabel, cancelLabel, }: {
38
+ export declare function AssistantActionCard({ title, details, status, result, onConfirm, onCancel, confirming, cancelling, confirmLabel, cancelLabel, pendingLabel, }: {
39
39
  title: ReactNode;
40
40
  details?: ReadonlyArray<{
41
41
  label: string;
@@ -50,6 +50,8 @@ export declare function AssistantActionCard({ title, details, status, result, on
50
50
  cancelling?: boolean;
51
51
  confirmLabel?: string;
52
52
  cancelLabel?: string;
53
+ /** Au-dessus du titre tant que la carte attend. */
54
+ pendingLabel?: string;
53
55
  }): React.JSX.Element;
54
56
  /** Des demandes d'exemple, pour une conversation vide : un clic remplit la saisie. */
55
57
  export declare function AssistantExamples({ examples, onPick, disabled, label, }: {
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Alert02Icon from "@hugeicons/core-free-icons/Alert02Icon";
2
3
  import ArrowUp02Icon from "@hugeicons/core-free-icons/ArrowUp02Icon";
3
4
  import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
4
5
  import CheckmarkCircle02Icon from "@hugeicons/core-free-icons/CheckmarkCircle02Icon";
@@ -22,7 +23,7 @@ export function AssistantUserMessage({ children }) {
22
23
  export function AssistantToolChips({ tools, label = "Ce que l'assistant a consulté", }) {
23
24
  if (!tools.length)
24
25
  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
+ 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", "data-status": tool.status, children: [tool.status === "running" ? (_jsx(Spinner, { size: 3 })) : (_jsx(HugeiconsIcon, { icon: tool.status === "done" ? CheckmarkCircle02Icon : Alert02Icon, "aria-hidden": true, className: cn("size-3.5", tool.status === "done" && "text-success") })), tool.label, tool.status === "error" ? _jsx("span", { className: "sr-only", children: " (\u00E9chec)" }) : null] }) }, tool.id))) }));
26
27
  }
27
28
  /**
28
29
  * Une réponse : pastilles, puis le texte rendu par le produit (`children`), puis les cartes.
@@ -40,9 +41,9 @@ const ACTION_BADGE = {
40
41
  * Une écriture proposée par l'assistant, qui n'a lieu qu'au clic sur « Confirmer ». Une fois
41
42
  * traitée, la carte garde son état (fait, annulé, échec et sa raison).
42
43
  */
43
- export function AssistantActionCard({ title, details = [], status, result, onConfirm, onCancel, confirming = false, cancelling = false, confirmLabel = "Confirmer", cancelLabel = "Annuler", }) {
44
+ export function AssistantActionCard({ title, details = [], status, result, onConfirm, onCancel, confirming = false, cancelling = false, confirmLabel = "Confirmer", cancelLabel = "Annuler", pendingLabel = "À confirmer", }) {
44
45
  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
+ return (_jsxs(Card, { "data-slot": "assistant-action-card", "data-status": status, className: "max-w-xl card-in rounded-2xl motion-reduce:animate-none", children: [_jsxs(CardHeader, { className: "gap-0.5 px-4 pt-3.5 sm:px-4 sm:pt-3.5", children: [status === "pending" ? _jsx("p", { className: "text-xs font-medium text-muted-foreground", children: pendingLabel }) : null, _jsx(CardTitle, { className: "text-[0.9375rem] leading-snug", children: title })] }), details.length ? (_jsx(CardContent, { className: "px-4 py-3 sm:px-4 sm:py-3 [[data-slot=card-header]+&]:pt-2", children: _jsx("dl", { className: "grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-4 gap-y-1 text-sm", children: details.map((row) => (_jsxs("div", { className: "contents", children: [_jsx("dt", { className: "text-muted-foreground", children: row.label }), _jsx("dd", { className: "break-words whitespace-pre-wrap", children: row.value })] }, row.label))) }) })) : (_jsx("div", { "aria-hidden": true, className: "h-3" })), _jsx(CardFooter, { className: "justify-start px-4 py-2.5 sm:px-4", 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
  }
47
48
  /** Des demandes d'exemple, pour une conversation vide : un clic remplit la saisie. */
48
49
  export function AssistantExamples({ examples, onPick, disabled = false, label = "Par exemple :", }) {
@@ -0,0 +1,157 @@
1
+ import { type IconSvgElement } from "@hugeicons/react";
2
+ import { type ReactNode } from "react";
3
+ import { type AssistantActionStatus, type AssistantTool } from "./assistant-chat.tsx";
4
+ /** État de l'assistant pour l'organisation (`readStatus`). */
5
+ export type AssistantStatus = {
6
+ ready: boolean;
7
+ ownKey: {
8
+ hint: string;
9
+ } | null;
10
+ credits: {
11
+ used: number;
12
+ limit: number;
13
+ };
14
+ };
15
+ export type AssistantThreadSummary = {
16
+ _id: string;
17
+ title: string;
18
+ updatedAt: number;
19
+ };
20
+ export type AssistantMessageStatus = "pending" | "streaming" | "done" | "error";
21
+ export type AssistantMessage = {
22
+ _id: string;
23
+ role: "user" | "assistant";
24
+ text: string;
25
+ status: AssistantMessageStatus;
26
+ tools: ReadonlyArray<AssistantTool>;
27
+ };
28
+ export type AssistantAction = {
29
+ _id: string;
30
+ messageId: string;
31
+ summary: string;
32
+ details?: ReadonlyArray<{
33
+ label: string;
34
+ value: string;
35
+ }>;
36
+ status: AssistantActionStatus;
37
+ result?: string | null;
38
+ };
39
+ /** Une conversation ouverte (`readThread`). */
40
+ export type AssistantThread = {
41
+ /** L'historique a atteint sa taille maximale : une nouvelle conversation s'impose. */
42
+ full?: boolean;
43
+ messages: ReadonlyArray<AssistantMessage>;
44
+ actions: ReadonlyArray<AssistantAction>;
45
+ };
46
+ /** La page ouverte, que l'assistant prend comme sujet tant que la personne ne la retire pas. */
47
+ export type AssistantSubject = {
48
+ /** « Campagne « Octobre » », « Déploiement abc123 ». */
49
+ label: string;
50
+ icon?: IconSvgElement;
51
+ attached: boolean;
52
+ onAttachedChange: (attached: boolean) => void;
53
+ /** Bouton pour la rattacher : « Parler de ce déploiement ». */
54
+ attachLabel: string;
55
+ };
56
+ /** Une demande venue d'un bouton de la page (« Écrire avec l'assistant ») : part une seule fois par `nonce`. */
57
+ export type AssistantRequest = {
58
+ prompt?: string;
59
+ nonce: number;
60
+ };
61
+ export type AssistantLabels = {
62
+ welcomeTitle: string;
63
+ welcomeDescription: string;
64
+ notReadyTitle: string;
65
+ notReadyDescription: string;
66
+ exhaustedTitle: string;
67
+ exhaustedDescription: string;
68
+ placeholder: string;
69
+ placeholderBusy: string;
70
+ composerLabel: string;
71
+ history: string;
72
+ noHistory: string;
73
+ newThread: string;
74
+ deleteThread: string;
75
+ deleteConfirm: string;
76
+ deleteYes: string;
77
+ deleteNo: string;
78
+ loadError: string;
79
+ full: string;
80
+ sendError: string;
81
+ creditsLeft: (remaining: number) => string;
82
+ ownKey: (hint: string) => string;
83
+ notReadyShort: string;
84
+ detach: string;
85
+ };
86
+ /** Tutoiement de la console ; un produit qui vouvoie (clients de Portal) remplace ce qu'il faut. */
87
+ export declare const ASSISTANT_LABELS: AssistantLabels;
88
+ /** Le message d'une erreur Convex (`ConvexError` avec un texte) ; sinon `fallback`. */
89
+ export declare function assistantErrorText(error: unknown, fallback: string): string;
90
+ /**
91
+ * Un lien écrit par l'assistant. Un chemin de l'application (`/…`) reste un lien interne ; une
92
+ * adresse `https:` s'ouvre dans un nouvel onglet, sans référent ni accès à la page, avec son domaine
93
+ * affiché à côté du texte (un texte trompeur ne cache pas la destination) ; tout le reste
94
+ * (`javascript:`, `http:`, `data:`…) devient du texte. À passer au rendu Markdown du produit
95
+ * (`components={{ a: AssistantLink }}`) ; `renderInternal` pour le lien du routeur.
96
+ */
97
+ export declare function AssistantLink({ href, children, renderInternal, }: {
98
+ href?: string | undefined;
99
+ children?: ReactNode;
100
+ renderInternal?: (href: string, children: ReactNode) => ReactNode;
101
+ }): React.JSX.Element;
102
+ /** Le rendu par défaut : texte brut en paragraphes, adresses https en liens sûrs. Aucun HTML. */
103
+ export declare function renderPlainText(text: string): ReactNode;
104
+ /** La ligne sous la barre : crédits restants, ou la clé de l'organisation. */
105
+ export declare function AssistantCredits({ status, labels, }: {
106
+ status: AssistantStatus | undefined;
107
+ labels?: Pick<AssistantLabels, "creditsLeft" | "ownKey" | "notReadyShort">;
108
+ }): React.JSX.Element;
109
+ /** Les conversations précédentes et « Nouvelle conversation ». */
110
+ export declare function AssistantHistory({ threads, threadId, onThreadChange, now, labels, }: {
111
+ threads: ReadonlyArray<AssistantThreadSummary> | undefined;
112
+ threadId: string | null;
113
+ onThreadChange: (threadId: string | null) => void;
114
+ now?: number;
115
+ labels?: Pick<AssistantLabels, "history" | "noHistory" | "newThread">;
116
+ }): React.JSX.Element;
117
+ /** La page ouverte en pastille au-dessus de la saisie, ou le lien pour la rattacher. */
118
+ export declare function AssistantSubjectChip({ subject, labels, }: {
119
+ subject: AssistantSubject;
120
+ labels?: Pick<AssistantLabels, "detach">;
121
+ }): React.JSX.Element;
122
+ export type AssistantViewProps = {
123
+ /** `undefined` pendant le chargement. */
124
+ status: AssistantStatus | undefined;
125
+ threads: ReadonlyArray<AssistantThreadSummary> | undefined;
126
+ /** Conversation ouverte ; `null` : une nouvelle. */
127
+ threadId: string | null;
128
+ onThreadChange: (threadId: string | null) => void;
129
+ /** La conversation ouverte ; `undefined` pendant son chargement, `null` si elle ne se lit pas. */
130
+ thread: AssistantThread | null | undefined;
131
+ /**
132
+ * Le texte d'une réponse, rendu par le produit (Markdown avec `AssistantLink` pour les liens,
133
+ * sans image). Par défaut : texte brut, paragraphes, adresses https en liens sûrs.
134
+ */
135
+ renderText?: (text: string) => ReactNode;
136
+ /** Envoie la demande ; une erreur remet le texte dans la saisie et s'affiche au-dessus. */
137
+ onSend: (text: string) => Promise<void>;
138
+ onConfirm: (actionId: string) => Promise<void>;
139
+ onCancel: (actionId: string) => Promise<void>;
140
+ onDeleteThread?: (threadId: string) => Promise<void>;
141
+ examples?: ReadonlyArray<string>;
142
+ /** Remplace le titre et le texte d'accueil (une page ouverte appelle un autre accueil). */
143
+ welcome?: {
144
+ title?: string;
145
+ description?: ReactNode;
146
+ };
147
+ /** Sous l'état « non branché » ou « crédits épuisés » : un lien vers les réglages, pour un admin. */
148
+ setup?: ReactNode;
149
+ subject?: AssistantSubject | null;
150
+ request?: AssistantRequest | null;
151
+ labels?: Partial<AssistantLabels>;
152
+ /** Instant de référence des dates relatives (tests, captures). */
153
+ now?: number;
154
+ className?: string;
155
+ };
156
+ /** Le panneau de l'assistant, prêt pour la colonne de `AppShell`. */
157
+ export declare function AssistantView({ status, threads, threadId, onThreadChange, thread, renderText, onSend, onConfirm, onCancel, onDeleteThread, examples, welcome, setup, subject, request, labels: overrides, now, className, }: AssistantViewProps): React.JSX.Element;
@@ -0,0 +1,232 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Add01Icon from "@hugeicons/core-free-icons/Add01Icon";
3
+ import AiMagicIcon from "@hugeicons/core-free-icons/AiMagicIcon";
4
+ import Alert02Icon from "@hugeicons/core-free-icons/Alert02Icon";
5
+ import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
6
+ import Clock01Icon from "@hugeicons/core-free-icons/Clock01Icon";
7
+ import Delete02Icon from "@hugeicons/core-free-icons/Delete02Icon";
8
+ import { HugeiconsIcon } from "@hugeicons/react";
9
+ import { useEffect, useEffectEvent, useRef, useState } from "react";
10
+ import { AssistantActionCard, AssistantComposer, AssistantConversation, AssistantExamples, AssistantReply, AssistantUserMessage, } from "./assistant-chat.js";
11
+ import { Button } from "./button.js";
12
+ import { cn } from "./cn.js";
13
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "./dropdown-menu.js";
14
+ import { formatRelative } from "./relative-time.js";
15
+ import { Spinner } from "./spinner.js";
16
+ /** Tutoiement de la console ; un produit qui vouvoie (clients de Portal) remplace ce qu'il faut. */
17
+ export const ASSISTANT_LABELS = {
18
+ welcomeTitle: "Qu'est-ce que je fais pour toi ?",
19
+ welcomeDescription: "Je lis ce que tu vois dans l'application et je t'aide à t'y retrouver. Je te demande toujours avant de modifier ou d'envoyer quoi que ce soit.",
20
+ notReadyTitle: "L'assistant n'est pas encore branché",
21
+ notReadyDescription: "Il manque une clé pour le modèle. Un administrateur peut l'ajouter dans les réglages.",
22
+ exhaustedTitle: "Les crédits du mois sont utilisés",
23
+ exhaustedDescription: "Le compteur repart le 1er du mois. Un administrateur peut aussi ajouter la clé de l'organisation dans les réglages.",
24
+ placeholder: "Demande-moi quelque chose",
25
+ placeholderBusy: "L'assistant répond…",
26
+ composerLabel: "Message pour l'assistant",
27
+ history: "Conversations précédentes",
28
+ noHistory: "Aucune conversation pour l'instant",
29
+ newThread: "Nouvelle conversation",
30
+ deleteThread: "Effacer cette conversation",
31
+ deleteConfirm: "Effacer pour de bon ?",
32
+ deleteYes: "Effacer",
33
+ deleteNo: "Garder",
34
+ loadError: "Cette conversation ne s'affiche pas. Réessaie dans un instant.",
35
+ full: "Cette conversation est pleine. Ouvre une nouvelle conversation pour continuer.",
36
+ sendError: "Le message n'est pas parti. Réessaie dans un instant.",
37
+ creditsLeft: (remaining) => remaining <= 0
38
+ ? "Plus de crédits ce mois-ci"
39
+ : `${remaining} crédit${remaining > 1 ? "s" : ""} restant${remaining > 1 ? "s" : ""} ce mois-ci`,
40
+ ownKey: (hint) => `Clé de l'organisation ••••${hint}`,
41
+ notReadyShort: "Pas encore branché",
42
+ detach: "Ne plus parler de cette page",
43
+ };
44
+ /** Le message d'une erreur Convex (`ConvexError` avec un texte) ; sinon `fallback`. */
45
+ export function assistantErrorText(error, fallback) {
46
+ if (typeof error === "object" && error !== null && "data" in error) {
47
+ const data = error.data;
48
+ if (typeof data === "string" && data.trim())
49
+ return data;
50
+ if (typeof data === "object" && data !== null && "message" in data) {
51
+ const message = data.message;
52
+ if (typeof message === "string" && message.trim())
53
+ return message;
54
+ }
55
+ }
56
+ return fallback;
57
+ }
58
+ /**
59
+ * Un lien écrit par l'assistant. Un chemin de l'application (`/…`) reste un lien interne ; une
60
+ * adresse `https:` s'ouvre dans un nouvel onglet, sans référent ni accès à la page, avec son domaine
61
+ * affiché à côté du texte (un texte trompeur ne cache pas la destination) ; tout le reste
62
+ * (`javascript:`, `http:`, `data:`…) devient du texte. À passer au rendu Markdown du produit
63
+ * (`components={{ a: AssistantLink }}`) ; `renderInternal` pour le lien du routeur.
64
+ */
65
+ export function AssistantLink({ href, children, renderInternal, }) {
66
+ const value = (href ?? "").trim();
67
+ const className = "font-medium underline underline-offset-2";
68
+ if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")) {
69
+ return renderInternal ? (_jsx(_Fragment, { children: renderInternal(value, children) })) : (_jsx("a", { href: value, className: className, children: children }));
70
+ }
71
+ let url = null;
72
+ try {
73
+ url = new URL(value);
74
+ }
75
+ catch {
76
+ url = null;
77
+ }
78
+ if (!url || url.protocol !== "https:" || url.username || url.password)
79
+ return _jsx("span", { children: children });
80
+ return (_jsxs("a", { href: url.href, target: "_blank", rel: "noopener noreferrer nofollow", className: className, children: [children, _jsxs("span", { className: "ml-1 text-xs font-normal text-muted-foreground no-underline", children: ["(", url.hostname, ")"] })] }));
81
+ }
82
+ const URL_IN_TEXT = /(https:\/\/[^\s<>"')]+)/g;
83
+ /** Le rendu par défaut : texte brut en paragraphes, adresses https en liens sûrs. Aucun HTML. */
84
+ export function renderPlainText(text) {
85
+ return text.split(/\n{2,}/).map((paragraph, i) => (_jsx("p", { className: "my-2 whitespace-pre-wrap first:mt-0 last:mb-0", children: paragraph.split(URL_IN_TEXT).map((part, j) => j % 2 === 1 ? (_jsx(AssistantLink, { href: part, children: part }, j)) : (part)) }, i)));
86
+ }
87
+ function remainingCredits(status) {
88
+ return Math.max(0, Math.floor(status.credits.limit - status.credits.used));
89
+ }
90
+ /** La ligne sous la barre : crédits restants, ou la clé de l'organisation. */
91
+ export function AssistantCredits({ status, labels = ASSISTANT_LABELS, }) {
92
+ return (_jsx("p", { "data-slot": "assistant-credits", className: "min-w-0 truncate text-xs text-muted-foreground", "aria-live": "polite", children: status === undefined
93
+ ? " "
94
+ : status.ownKey
95
+ ? labels.ownKey(status.ownKey.hint)
96
+ : !status.ready
97
+ ? labels.notReadyShort
98
+ : labels.creditsLeft(remainingCredits(status)) }));
99
+ }
100
+ /** Les conversations précédentes et « Nouvelle conversation ». */
101
+ export function AssistantHistory({ threads, threadId, onThreadChange, now, labels = ASSISTANT_LABELS, }) {
102
+ return (_jsxs("div", { className: "flex shrink-0 items-center gap-0.5", children: [_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": labels.history, title: labels.history, disabled: !threads?.length }), children: _jsx(HugeiconsIcon, { icon: Clock01Icon, "aria-hidden": true }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-72 max-w-[calc(100vw-2rem)]", children: _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: labels.history }), threads?.length ? (threads.map((thread) => (_jsxs(DropdownMenuItem, { onClick: () => onThreadChange(thread._id), "aria-current": thread._id === threadId ? "true" : undefined, className: "aria-[current]:font-medium", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: thread.title }), _jsx("span", { className: "shrink-0 text-xs text-muted-foreground", children: formatRelative(thread.updatedAt, now) })] }, thread._id)))) : (_jsx("p", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: labels.noHistory }))] }) })] }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": labels.newThread, title: labels.newThread, disabled: threadId === null, onClick: () => onThreadChange(null), children: _jsx(HugeiconsIcon, { icon: Add01Icon, "aria-hidden": true }) })] }));
103
+ }
104
+ /** Un état sans conversation : accueil, non branché, crédits épuisés. */
105
+ function AssistantState({ title, description, tone = "neutral", children, }) {
106
+ return (_jsxs("div", { "data-slot": "assistant-state", className: "grid gap-4 py-4", children: [_jsx("span", { "aria-hidden": true, className: cn("grid size-10 place-items-center rounded-2xl", tone === "warning" ? "bg-status-paused/15 text-status-paused-ink" : "bg-muted text-foreground"), children: _jsx(HugeiconsIcon, { icon: tone === "warning" ? Alert02Icon : AiMagicIcon, strokeWidth: 1.8, className: "size-5" }) }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx("p", { className: "font-heading text-lg leading-snug font-semibold text-balance", children: title }), _jsx("div", { className: "text-sm text-pretty text-muted-foreground", children: description })] }), children] }));
107
+ }
108
+ /** La page ouverte en pastille au-dessus de la saisie, ou le lien pour la rattacher. */
109
+ export function AssistantSubjectChip({ subject, labels = ASSISTANT_LABELS, }) {
110
+ if (!subject.attached) {
111
+ return (_jsx("button", { type: "button", onClick: () => subject.onAttachedChange(true), className: "touch-target w-fit rounded-lg text-xs text-muted-foreground underline-offset-2 outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring/50", children: subject.attachLabel }));
112
+ }
113
+ return (_jsxs("span", { "data-slot": "assistant-subject", className: "flex w-fit max-w-full min-w-0 soft-in items-center gap-1.5 rounded-full bg-muted py-1 pr-1 pl-2.5 text-xs font-medium motion-reduce:animate-none", children: [subject.icon ? _jsx(HugeiconsIcon, { icon: subject.icon, "aria-hidden": true, className: "size-3.5 shrink-0" }) : null, _jsx("span", { className: "truncate", children: subject.label }), _jsx("button", { type: "button", "aria-label": labels.detach, title: labels.detach, onClick: () => subject.onAttachedChange(false), className: "grid size-6 shrink-0 place-items-center rounded-full text-muted-foreground outline-none hover:bg-foreground/[0.08] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", children: _jsx(HugeiconsIcon, { icon: Cancel01Icon, "aria-hidden": true, className: "size-3" }) })] }));
114
+ }
115
+ function DeleteThread({ onDelete, labels, }) {
116
+ const [asking, setAsking] = useState(false);
117
+ const [busy, setBusy] = useState(false);
118
+ if (!asking) {
119
+ return (_jsxs("button", { type: "button", onClick: () => setAsking(true), className: "touch-target flex w-fit items-center gap-1 rounded-lg text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", children: [_jsx(HugeiconsIcon, { icon: Delete02Icon, "aria-hidden": true, className: "size-3.5" }), labels.deleteThread] }));
120
+ }
121
+ return (_jsxs("div", { role: "group", "aria-label": labels.deleteThread, className: "flex items-center gap-2 text-xs", children: [_jsx("span", { className: "text-muted-foreground", children: labels.deleteConfirm }), _jsx(Button, { size: "xs", variant: "destructive", isLoading: busy, onClick: async () => {
122
+ setBusy(true);
123
+ try {
124
+ await onDelete();
125
+ }
126
+ finally {
127
+ setBusy(false);
128
+ setAsking(false);
129
+ }
130
+ }, children: labels.deleteYes }), _jsx(Button, { size: "xs", variant: "ghost", disabled: busy, onClick: () => setAsking(false), children: labels.deleteNo })] }));
131
+ }
132
+ /** Le panneau de l'assistant, prêt pour la colonne de `AppShell`. */
133
+ export function AssistantView({ status, threads, threadId, onThreadChange, thread, renderText = renderPlainText, onSend, onConfirm, onCancel, onDeleteThread, examples = [], welcome, setup, subject, request, labels: overrides, now, className, }) {
134
+ const labels = { ...ASSISTANT_LABELS, ...overrides };
135
+ const [draft, setDraft] = useState("");
136
+ const [sending, setSending] = useState(false);
137
+ const [sendError, setSendError] = useState(null);
138
+ const [actionBusy, setActionBusy] = useState(null);
139
+ const scroller = useRef(null);
140
+ const messages = threadId && thread ? thread.messages : [];
141
+ const last = messages.at(-1);
142
+ const answering = last?.status === "pending" || last?.status === "streaming";
143
+ const busy = sending || answering;
144
+ const exhausted = Boolean(status && status.ready && !status.ownKey && remainingCredits(status) <= 0);
145
+ const full = Boolean(threadId && thread?.full);
146
+ const blocked = status === undefined || !status.ready || exhausted || full;
147
+ // Le fil suit la réponse en cours, sauf si la personne remonte pour relire.
148
+ useEffect(() => {
149
+ const el = scroller.current;
150
+ if (!el)
151
+ return;
152
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 160)
153
+ el.scrollTo({ top: el.scrollHeight });
154
+ }, [thread]);
155
+ async function submit(text) {
156
+ const value = text.trim();
157
+ if (!value || busy || blocked)
158
+ return;
159
+ setSending(true);
160
+ setSendError(null);
161
+ setDraft("");
162
+ try {
163
+ await onSend(value);
164
+ requestAnimationFrame(() => {
165
+ const el = scroller.current;
166
+ if (el)
167
+ el.scrollTo({ top: el.scrollHeight });
168
+ });
169
+ }
170
+ catch (error) {
171
+ setDraft(value);
172
+ setSendError(assistantErrorText(error, labels.sendError));
173
+ }
174
+ finally {
175
+ setSending(false);
176
+ }
177
+ }
178
+ async function act(id, kind) {
179
+ setActionBusy({ id, kind });
180
+ try {
181
+ await (kind === "confirm" ? onConfirm(id) : onCancel(id));
182
+ }
183
+ catch (error) {
184
+ setSendError(assistantErrorText(error, labels.sendError));
185
+ }
186
+ finally {
187
+ setActionBusy(null);
188
+ }
189
+ }
190
+ // Une demande venue de la page part une seule fois ; sans texte, elle met le curseur dans la saisie.
191
+ const handled = useRef(null);
192
+ const onRequest = useEffectEvent((next) => {
193
+ if (handled.current === next.nonce)
194
+ return;
195
+ handled.current = next.nonce;
196
+ if (next.prompt)
197
+ void submit(next.prompt);
198
+ });
199
+ useEffect(() => {
200
+ // oxlint-disable-next-line react/set-state-in-effect -- la demande est un clic sur la page, transmis par prop : exécutée une seule fois par nonce.
201
+ if (request)
202
+ onRequest(request);
203
+ }, [request]);
204
+ let body;
205
+ if (status === undefined) {
206
+ body = (_jsx("div", { className: "grid place-items-center py-16", children: _jsx(Spinner, { size: 5, label: "Chargement" }) }));
207
+ }
208
+ else if (!status.ready) {
209
+ body = (_jsx(AssistantState, { title: labels.notReadyTitle, description: labels.notReadyDescription, tone: "warning", children: setup }));
210
+ }
211
+ else if (!threadId) {
212
+ body = exhausted ? (_jsx(AssistantState, { title: labels.exhaustedTitle, description: labels.exhaustedDescription, tone: "warning", children: setup })) : (_jsx(AssistantState, { title: welcome?.title ?? labels.welcomeTitle, description: welcome?.description ?? labels.welcomeDescription, children: examples.length ? _jsx(AssistantExamples, { examples: examples, onPick: (e) => void submit(e) }) : null }));
213
+ }
214
+ else if (thread === undefined) {
215
+ body = (_jsx("div", { className: "grid place-items-center py-16", children: _jsx(Spinner, { size: 5, label: "Chargement de la conversation" }) }));
216
+ }
217
+ else if (thread === null) {
218
+ body = (_jsx("p", { role: "alert", className: "py-8 text-sm text-destructive", children: labels.loadError }));
219
+ }
220
+ else {
221
+ body = (_jsx(AssistantConversation, { className: "gap-5", children: thread.messages.map((message) => message.role === "user" ? (_jsx(AssistantUserMessage, { children: message.text }, message._id)) : (_jsx(AssistantReply, { tools: message.tools, waiting: !message.text && (message.status === "pending" || message.status === "streaming"), error: message.status === "error", actions: thread.actions
222
+ .filter((action) => action.messageId === message._id)
223
+ .map((action) => (_jsx(AssistantActionCard, { title: action.summary, details: action.details ?? [], status: action.status, result: action.result ?? null, confirming: actionBusy?.id === action._id && actionBusy.kind === "confirm", cancelling: actionBusy?.id === action._id && actionBusy.kind === "cancel", onConfirm: () => void act(action._id, "confirm"), onCancel: () => void act(action._id, "cancel") }, action._id))), children: message.text ? renderText(message.text) : null }, message._id))) }));
224
+ }
225
+ return (_jsxs("div", { "data-slot": "assistant-view", className: cn("flex min-h-0 flex-1 flex-col", className), children: [_jsxs("div", { className: "flex min-h-11 shrink-0 items-center gap-2 px-4 xl:pr-2 xl:pl-5", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(AssistantCredits, { status: status, labels: labels }) }), _jsx(AssistantHistory, { threads: threads, threadId: threadId, onThreadChange: (id) => {
226
+ setSendError(null);
227
+ onThreadChange(id);
228
+ }, labels: labels, ...(now === undefined ? {} : { now }) })] }), _jsx("div", { ref: scroller, "data-slot": "assistant-scroller", className: "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-2 pb-4 xl:pr-3 xl:pl-5", children: body }), _jsxs("div", { className: "grid shrink-0 gap-2 border-t border-foreground/5 px-4 pt-3 pb-[max(env(safe-area-inset-bottom),0.75rem)] xl:pr-3 xl:pl-5", children: [sendError ? (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: sendError })) : null, full ? _jsx("p", { className: "text-sm text-muted-foreground", children: labels.full }) : null, subject ? _jsx(AssistantSubjectChip, { subject: subject, labels: labels }) : null, _jsx(AssistantComposer, { value: draft, onValueChange: setDraft, onSubmit: () => void submit(draft), disabled: blocked, busy: answering, sending: sending, label: labels.composerLabel, placeholder: answering ? labels.placeholderBusy : labels.placeholder, hint: null }), threadId && onDeleteThread && thread ? (_jsx(DeleteThread, { labels: labels, onDelete: async () => {
229
+ await onDeleteThread(threadId);
230
+ onThreadChange(null);
231
+ } })) : null] })] }));
232
+ }
@@ -20,6 +20,8 @@ export declare function filterSummary(filter: FilterDefinition, selected?: Reado
20
20
  export declare function activeFilterCount(values?: FilterValues, search?: string): number;
21
21
  /** Ajoute ou retire une valeur d'un filtre à choix multiple, dans l'ordre des options. */
22
22
  export declare function toggleFilterValue(filter: FilterDefinition, selected: ReadonlyArray<string> | undefined, value: string, checked: boolean): Array<string>;
23
+ /** Nombre de filtres posés, sans la recherche : le compteur du bouton « Filtres ». */
24
+ export declare function activeFilterOnlyCount(values?: FilterValues): number;
23
25
  /**
24
26
  * Recherche et filtres en pastilles au-dessus d'une liste. Entièrement contrôlée, sans routeur :
25
27
  * le produit lit les valeurs dans les paramètres d'URL et les y réécrit dans les callbacks, pour
@@ -28,8 +30,12 @@ export declare function toggleFilterValue(filter: FilterDefinition, selected: Re
28
30
  * La recherche n'écrit qu'après 250 ms de calme (Entrée pour tout de suite, Échap pour vider).
29
31
  * Un filtre à choix multiple reste ouvert pendant qu'on coche ; `single` en fait un choix unique
30
32
  * avec « Tous ». « Effacer » apparaît dès qu'un critère est posé.
33
+ *
34
+ * `variant="panel"` range les filtres derrière un seul bouton « Filtres » (icône, compteur des
35
+ * filtres posés) qui ouvre un panneau : bulle sur ordinateur, feuille du bas sur téléphone. La
36
+ * recherche reste visible. Pour une liste qui a plus de deux filtres, ou un en-tête déjà chargé.
31
37
  */
32
- export declare function FilterBar({ search, filters, values, onFilterChange, onReset, actions, className, }: {
38
+ export declare function FilterBar({ search, filters, values, onFilterChange, onReset, onResetFilters, variant, panelLabel, leading, actions, className, }: {
33
39
  search?: {
34
40
  value: string;
35
41
  onChange: (value: string) => void;
@@ -44,6 +50,14 @@ export declare function FilterBar({ search, filters, values, onFilterChange, onR
44
50
  onFilterChange?: (key: string, values: Array<string>) => void;
45
51
  /** Retire tous les critères, recherche comprise. */
46
52
  onReset?: () => void;
53
+ /** `panel` : « Réinitialiser » du panneau, qui retire les filtres et garde la recherche. */
54
+ onResetFilters?: () => void;
55
+ /** `pills` (par défaut) : une pastille par filtre. `panel` : un bouton « Filtres » et son panneau. */
56
+ variant?: "pills" | "panel";
57
+ /** Nom du bouton et du panneau en `panel`, « Filtres » par défaut. */
58
+ panelLabel?: string;
59
+ /** Avant la recherche : une vue (`SegmentedControl`) qui choisit la liste plutôt que de la filtrer. */
60
+ leading?: ReactNode;
47
61
  /** À droite : la période, un bouton d'export. */
48
62
  actions?: ReactNode;
49
63
  className?: string;
@@ -1,13 +1,17 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import ArrowDown01Icon from "@hugeicons/core-free-icons/ArrowDown01Icon";
3
3
  import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
4
+ import FilterHorizontalIcon from "@hugeicons/core-free-icons/FilterHorizontalIcon";
4
5
  import Search01Icon from "@hugeicons/core-free-icons/Search01Icon";
5
6
  import { HugeiconsIcon } from "@hugeicons/react";
6
- import { useEffect, useRef, useState } from "react";
7
+ import { useEffect, useId, useRef, useState } from "react";
7
8
  import { Button, buttonVariants } from "./button.js";
8
9
  import { cn } from "./cn.js";
9
10
  import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "./dropdown-menu.js";
11
+ import { Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, } from "./drawer.js";
10
12
  import { Input } from "./input.js";
13
+ import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "./popover.js";
14
+ import { useIsMobile } from "./use-media-query.js";
11
15
  /** Le texte de la pastille : « Statut », « Statut : Erreur », « Statut : Erreur +2 ». */
12
16
  export function filterSummary(filter, selected = []) {
13
17
  const labels = filter.options.filter((o) => selected.includes(o.value)).map((o) => o.label);
@@ -58,9 +62,9 @@ function useDebouncedSearch(value, onChange, delay) {
58
62
  };
59
63
  return { draft, setDraft, flush };
60
64
  }
61
- function SearchField({ value, onChange, placeholder = "Rechercher…", label = "Rechercher", delay = 250, }) {
65
+ function SearchField({ value, onChange, placeholder = "Rechercher…", label = "Rechercher", delay = 250, className, }) {
62
66
  const { draft, setDraft, flush } = useDebouncedSearch(value, onChange, delay);
63
- return (_jsxs("div", { className: "relative min-w-0 flex-1 basis-full sm:basis-56 sm:max-w-sm", children: [_jsx(HugeiconsIcon, { icon: Search01Icon, strokeWidth: 2, "aria-hidden": true, className: "pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { type: "search", "aria-label": label, placeholder: placeholder, autoComplete: "off", spellCheck: false, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: (event) => {
67
+ return (_jsxs("div", { className: cn("relative min-w-0 flex-1 basis-full sm:basis-56 sm:max-w-sm", className), children: [_jsx(HugeiconsIcon, { icon: Search01Icon, strokeWidth: 2, "aria-hidden": true, className: "pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { type: "search", "aria-label": label, placeholder: placeholder, autoComplete: "off", spellCheck: false, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: (event) => {
64
68
  if (event.key === "Enter")
65
69
  flush(draft);
66
70
  if (event.key === "Escape" && draft !== "") {
@@ -73,6 +77,45 @@ function FilterPill({ filter, selected = [], onChange, }) {
73
77
  const active = selected.length > 0;
74
78
  return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { "data-slot": "filter-pill", "data-active": active || undefined, className: cn(buttonVariants({ variant: "outline", size: "sm" }), "max-w-64 gap-1 pr-2.5 font-normal text-muted-foreground", active && "bg-foreground/[0.06] font-medium text-foreground ring-0 dark:bg-foreground/[0.08]"), children: [_jsx("span", { className: "truncate", children: filterSummary(filter, selected) }), _jsx(HugeiconsIcon, { icon: ArrowDown01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-3.5 opacity-60" })] }), _jsx(DropdownMenuContent, { className: "w-auto", children: filter.single ? (_jsxs(DropdownMenuRadioGroup, { label: filter.label, value: selected[0] ?? "", onValueChange: (value) => onChange(value === "" ? [] : [value]), children: [_jsx(DropdownMenuRadioItem, { value: "", children: "Tous" }), filter.options.map((option) => (_jsx(DropdownMenuRadioItem, { value: option.value, children: option.label }, option.value)))] })) : (_jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: filter.label }), filter.options.map((option) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.includes(option.value), onCheckedChange: (checked) => onChange(toggleFilterValue(filter, selected, option.value, checked)), children: option.label }, option.value)))] })) })] }));
75
79
  }
80
+ /** Nombre de filtres posés, sans la recherche : le compteur du bouton « Filtres ». */
81
+ export function activeFilterOnlyCount(values = {}) {
82
+ return activeFilterCount(values, "");
83
+ }
84
+ /** Une option du panneau : une pastille qui se coche (choix multiple) ou se choisit (choix unique). */
85
+ function OptionChip({ label, checked, kind, onClick, }) {
86
+ return (_jsx("button", { type: "button", role: kind, "aria-checked": checked, onClick: onClick, className: cn("touch-target inline-flex h-9 items-center rounded-full px-3.5 text-sm outline-none transition-[background-color,color,box-shadow] duration-(--duration-fast) ease-out focus-visible:ring-3 focus-visible:ring-ring/50 lg:h-8 lg:px-3", checked
87
+ ? "bg-foreground font-medium text-background"
88
+ : "bg-background text-foreground ring-1 ring-border/70 ring-inset hover:bg-muted"), children: label }));
89
+ }
90
+ /** Un filtre du panneau : son nom, puis ses options en pastilles. */
91
+ function PanelFilter({ filter, selected = [], onChange, }) {
92
+ const id = useId();
93
+ return (_jsxs("div", { className: "grid gap-2", children: [_jsx("p", { id: id, className: "text-xs font-medium text-muted-foreground", children: filter.label }), _jsxs("div", { role: filter.single ? "radiogroup" : "group", "aria-labelledby": id, className: "flex flex-wrap gap-1.5", children: [filter.single ? (_jsx(OptionChip, { label: "Tous", kind: "radio", checked: selected.length === 0, onClick: () => onChange([]) })) : null, filter.options.map((option) => {
94
+ const checked = selected.includes(option.value);
95
+ return (_jsx(OptionChip, { label: option.label, kind: filter.single ? "radio" : "checkbox", checked: checked, onClick: () => onChange(filter.single
96
+ ? checked
97
+ ? []
98
+ : [option.value]
99
+ : toggleFilterValue(filter, selected, option.value, !checked)) }, option.value));
100
+ })] })] }));
101
+ }
102
+ /**
103
+ * Le bouton « Filtres » et son panneau : une bulle sous le bouton sur ordinateur, une feuille du
104
+ * bas sur téléphone. Chaque choix s'applique aussitôt ; « Réinitialiser » retire les filtres, pas
105
+ * la recherche.
106
+ */
107
+ function FilterPanel({ filters, values, onFilterChange, onReset, label, }) {
108
+ const isMobile = useIsMobile();
109
+ const [open, setOpen] = useState(false);
110
+ const count = activeFilterOnlyCount(values);
111
+ const trigger = (_jsx("button", { type: "button", "data-slot": "filter-panel-trigger", "data-active": count > 0 || undefined, "aria-label": count > 0 ? `${label}, ${count} actif${count > 1 ? "s" : ""}` : label, className: cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-2 font-normal", count > 0 && "font-medium") }));
112
+ const content = (_jsx(_Fragment, { children: filters.map((filter) => (_jsx(PanelFilter, { filter: filter, ...(values[filter.key] !== undefined ? { selected: values[filter.key] } : {}), onChange: (next) => onFilterChange?.(filter.key, next) }, filter.key))) }));
113
+ const face = (_jsxs(_Fragment, { children: [_jsx(HugeiconsIcon, { icon: FilterHorizontalIcon, strokeWidth: 2, "aria-hidden": true, className: "size-4" }), _jsx("span", { children: label }), count > 0 ? (_jsx("span", { "aria-hidden": true, className: "grid h-5 min-w-5 place-items-center rounded-full bg-foreground px-1.5 text-xs font-medium text-background tabular-nums", children: count })) : null] }));
114
+ const reset = onReset && count > 0 ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: onReset, className: "text-muted-foreground", children: "R\u00E9initialiser" })) : null;
115
+ if (isMobile)
116
+ return (_jsxs(Drawer, { side: "bottom", open: open, onOpenChange: setOpen, children: [_jsx(DrawerTrigger, { render: trigger, children: face }), _jsxs(DrawerContent, { children: [_jsx(DrawerHeader, { children: _jsx(DrawerTitle, { children: label }) }), _jsx(DrawerCloseButton, {}), _jsx(DrawerBody, { className: "grid gap-5 pb-4", children: content }), _jsxs(DrawerFooter, { children: [_jsx(Button, { onClick: () => setOpen(false), children: "Voir les r\u00E9sultats" }), reset] })] })] }));
117
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { render: trigger, children: face }), _jsxs(PopoverContent, { align: "end", className: "w-[22rem] gap-0 p-0", children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-4 pt-3.5 pb-1", children: [_jsx(PopoverTitle, { children: label }), reset ?? _jsx("span", { className: "h-10 lg:h-9", "aria-hidden": true })] }), _jsx("div", { className: "grid max-h-[min(28rem,70svh)] gap-4 overflow-y-auto px-4 pt-1 pb-4", children: content })] })] }));
118
+ }
76
119
  /**
77
120
  * Recherche et filtres en pastilles au-dessus d'une liste. Entièrement contrôlée, sans routeur :
78
121
  * le produit lit les valeurs dans les paramètres d'URL et les y réécrit dans les callbacks, pour
@@ -81,8 +124,14 @@ function FilterPill({ filter, selected = [], onChange, }) {
81
124
  * La recherche n'écrit qu'après 250 ms de calme (Entrée pour tout de suite, Échap pour vider).
82
125
  * Un filtre à choix multiple reste ouvert pendant qu'on coche ; `single` en fait un choix unique
83
126
  * avec « Tous ». « Effacer » apparaît dès qu'un critère est posé.
127
+ *
128
+ * `variant="panel"` range les filtres derrière un seul bouton « Filtres » (icône, compteur des
129
+ * filtres posés) qui ouvre un panneau : bulle sur ordinateur, feuille du bas sur téléphone. La
130
+ * recherche reste visible. Pour une liste qui a plus de deux filtres, ou un en-tête déjà chargé.
84
131
  */
85
- export function FilterBar({ search, filters = [], values = {}, onFilterChange, onReset, actions, className, }) {
132
+ export function FilterBar({ search, filters = [], values = {}, onFilterChange, onReset, onResetFilters, variant = "pills", panelLabel = "Filtres", leading, actions, className, }) {
86
133
  const count = activeFilterCount(values, search?.value);
87
- return (_jsxs("div", { role: "search", "data-slot": "filter-bar", className: cn("flex flex-wrap items-center gap-2", className), children: [search ? _jsx(SearchField, { ...search }) : null, filters.map((filter) => (_jsx(FilterPill, { filter: filter, ...(values[filter.key] !== undefined ? { selected: values[filter.key] } : {}), onChange: (next) => onFilterChange?.(filter.key, next) }, filter.key))), onReset && count > 0 ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: onReset, className: "text-muted-foreground", children: "Effacer" })) : null, actions ? _jsx("div", { className: "ml-auto flex items-center gap-2", children: actions }) : null] }));
134
+ if (variant === "panel")
135
+ return (_jsxs("div", { role: "search", "data-slot": "filter-bar", className: cn("flex flex-wrap items-center gap-2", className), children: [leading, search ? _jsx(SearchField, { ...search, className: "basis-40 sm:basis-56" }) : null, filters.length ? (_jsx(FilterPanel, { filters: filters, values: values, onFilterChange: onFilterChange, onReset: onResetFilters, label: panelLabel })) : null, actions ? _jsx("div", { className: "ml-auto flex items-center gap-2", children: actions }) : null] }));
136
+ return (_jsxs("div", { role: "search", "data-slot": "filter-bar", className: cn("flex flex-wrap items-center gap-2", className), children: [leading, search ? _jsx(SearchField, { ...search }) : null, filters.map((filter) => (_jsx(FilterPill, { filter: filter, ...(values[filter.key] !== undefined ? { selected: values[filter.key] } : {}), onChange: (next) => onFilterChange?.(filter.key, next) }, filter.key))), onReset && count > 0 ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: onReset, className: "text-muted-foreground", children: "Effacer" })) : null, actions ? _jsx("div", { className: "ml-auto flex items-center gap-2", children: actions }) : null] }));
88
137
  }
@@ -43,10 +43,14 @@ export declare function ProjectSwitcher({ projects, activeId, activeName, onSele
43
43
  onCreate?: () => void;
44
44
  /** Rend un lien du routeur du produit ; par défaut un `<a href>`. */
45
45
  renderLink?: (href: string) => ReactElement;
46
- /** Ce que l'on choisit, nom masculin : `{ one: "site", many: "sites" }` pour Analytics. */
46
+ /**
47
+ * Ce que l'on choisit : `{ one: "site", many: "sites" }` pour Analytics. Un nom féminin porte
48
+ * `feminine` : « Toutes les pages », « Page active », « Nouvelle page » (Status).
49
+ */
47
50
  noun?: {
48
51
  one: string;
49
52
  many: string;
53
+ feminine?: boolean;
50
54
  };
51
55
  createLabel?: string;
52
56
  className?: string;
@@ -39,12 +39,12 @@ function SwitcherEntry({ href, onSelect, renderLink, children, }) {
39
39
  * pendant le chargement : le déclencheur montre déjà `activeName` (le segment de l'URL), le menu
40
40
  * reste utilisable pour « Tous les projets » et « Nouveau projet ».
41
41
  */
42
- export function ProjectSwitcher({ projects, activeId, activeName, onSelect, allHref, createHref, onCreate, renderLink, noun = { one: "projet", many: "projets" }, createLabel = `Nouveau ${noun.one}`, className, }) {
42
+ export function ProjectSwitcher({ projects, activeId, activeName, onSelect, allHref, createHref, onCreate, renderLink, noun = { one: "projet", many: "projets" }, createLabel = `${noun.feminine ? "Nouvelle" : "Nouveau"} ${noun.one}`, className, }) {
43
43
  const capitalized = (word) => word.charAt(0).toUpperCase() + word.slice(1);
44
- const all = `Tous les ${noun.many}`;
44
+ const all = `${noun.feminine ? "Toutes" : "Tous"} les ${noun.many}`;
45
45
  const hasActive = activeId !== undefined && activeId !== null;
46
46
  const current = hasActive ? projects?.find((p) => p.id === activeId) : undefined;
47
47
  const label = hasActive ? (current?.name ?? activeName ?? "") : all;
48
48
  const canCreate = createHref !== undefined || onCreate !== undefined;
49
- return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": hasActive ? `${capitalized(noun.one)} ${label}, changer de ${noun.one}` : `${all}, changer de ${noun.one}`, className: cn("flex h-10 max-w-full min-w-0 items-center gap-2 rounded-xl px-2.5 text-sm font-medium 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 coarse:h-11", className) }), children: [hasActive ? (current ? (_jsx(ProjectMark, { project: current })) : (_jsx("span", { "aria-hidden": true, className: "size-6 shrink-0 rounded-md bg-muted skeleton-pulse" }))) : (_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" })), _jsx("span", { className: "min-w-0 truncate", children: label }), _jsx(HugeiconsIcon, { icon: UnfoldMoreIcon, "aria-hidden": true, className: "size-4 shrink-0 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", className: "w-72 max-w-[calc(100vw-2rem)]", children: [_jsxs(SwitcherEntry, { href: allHref, onSelect: () => onSelect?.(null), renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4" }), _jsx("span", { className: "flex-1", children: all }), hasActive ? null : _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Vue active", className: "size-4" })] }), projects?.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: capitalized(noun.many) }), projects.map((project) => (_jsxs(SwitcherEntry, { href: project.href, onSelect: () => onSelect?.(project.id), renderLink: renderLink, children: [_jsx(ProjectMark, { project: project }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: project.name }), project.id === activeId ? (_jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": `${capitalized(noun.one)} actif`, className: "size-4" })) : null] }, project.id)))] })] })) : null, canCreate ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(SwitcherEntry, { href: createHref, onSelect: onCreate, renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true, className: "size-4" }), createLabel] })] })) : null] })] }));
49
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": hasActive ? `${capitalized(noun.one)} ${label}, changer de ${noun.one}` : `${all}, changer de ${noun.one}`, className: cn("flex h-10 max-w-full min-w-0 items-center gap-2 rounded-xl px-2.5 text-sm font-medium 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 coarse:h-11", className) }), children: [hasActive ? (current ? (_jsx(ProjectMark, { project: current })) : (_jsx("span", { "aria-hidden": true, className: "size-6 shrink-0 rounded-md bg-muted skeleton-pulse" }))) : (_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" })), _jsx("span", { className: "min-w-0 truncate", children: label }), _jsx(HugeiconsIcon, { icon: UnfoldMoreIcon, "aria-hidden": true, className: "size-4 shrink-0 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", className: "w-72 max-w-[calc(100vw-2rem)]", children: [_jsxs(SwitcherEntry, { href: allHref, onSelect: () => onSelect?.(null), renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4" }), _jsx("span", { className: "flex-1", children: all }), hasActive ? null : _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Vue active", className: "size-4" })] }), projects?.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: capitalized(noun.many) }), projects.map((project) => (_jsxs(SwitcherEntry, { href: project.href, onSelect: () => onSelect?.(project.id), renderLink: renderLink, children: [_jsx(ProjectMark, { project: project }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: project.name }), project.id === activeId ? (_jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": `${capitalized(noun.one)} ${noun.feminine ? "active" : "actif"}`, className: "size-4" })) : null] }, project.id)))] })] })) : null, canCreate ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(SwitcherEntry, { href: createHref, onSelect: onCreate, renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true, className: "size-4" }), createLabel] })] })) : null] })] }));
50
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.30.1",
3
+ "version": "0.32.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",