@hgdc/ui 0.12.2 → 0.14.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,24 +80,25 @@ 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` | `<FamilyLoader page />` en `pendingComponent` d'une route, `<FamilyLoader size="lg" label="Ouverture du projet…" />` : le H de la famille qui se construit, après 200 ms. Dans un bouton : `Spinner` |
100
- | `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
+ | `family-loader` | `<FamilyLoader size="lg" label="Ouverture du projet…" />` : la silhouette grise du H, dont les pièces se remplissent de leurs dégradés en boucle (1,6 s, `transform` seul, fluide pendant un chargement). `page` le centre dans l'écran. Dans un bouton : `Spinner` |
100
+ | `page-pending` | `createRouter({ routeTree, ...routerPendingDefaults })` (préchargement au survol, chargement après 50 ms, au moins 300 ms) ; `AppShell navigating={…} contentKey={pathname}` : le H couvre la zone de contenu dès qu'une navigation attend, la coque reste ; `PagePending` : même H, même place, pour une page qui lit ses données |
101
+ | `skeleton` | `<Pending pending={isLoading} fallback={<TableSkeleton columns={3} />}>…</Pending>` : après 200 ms, tenu 400 ms au moins |
101
102
 
102
103
  ## Listes, journaux et retours
103
104
 
@@ -25,7 +25,8 @@ export type AccountUser = {
25
25
  * l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
26
26
  * `ThemeProvider`.
27
27
  *
28
- * « Produits » liste par défaut Account et les produits ouverts du catalogue
28
+ * « Produits » (masquée au-delà de `lg` dans une coque qui affiche la barre des produits, où ils
29
+ * sont déjà ; gardée sur téléphone) liste par défaut Account et les produits ouverts du catalogue
29
30
  * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
30
31
  * liste ; `products={[]}` la retire.
31
32
  */
@@ -16,6 +16,7 @@ import { initials } from "./initials.js";
16
16
  import { MENU_LINK_ITEM } from "./org-switcher.js";
17
17
  import { ProductBadge } from "./product-badge.js";
18
18
  import { productMenuEntries } from "./products.js";
19
+ import { useLeaveOnClick, useProductRailShown } from "./shell-placement.js";
19
20
  import { useTheme } from "./theme.js";
20
21
  const THEMES = ["light", "dark", "system"];
21
22
  /**
@@ -24,18 +25,21 @@ const THEMES = ["light", "dark", "system"];
24
25
  * l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
25
26
  * `ThemeProvider`.
26
27
  *
27
- * « Produits » liste par défaut Account et les produits ouverts du catalogue
28
+ * « Produits » (masquée au-delà de `lg` dans une coque qui affiche la barre des produits, où ils
29
+ * sont déjà ; gardée sur téléphone) liste par défaut Account et les produits ouverts du catalogue
28
30
  * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
29
31
  * liste ; `products={[]}` la retire.
30
32
  */
31
33
  export function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant = "row", }) {
32
34
  const { theme, setTheme } = useTheme();
33
35
  const products = productsProp ?? productMenuEntries({ current: currentProduct, accountHref });
36
+ const railShown = useProductRailShown();
37
+ const leaveOnClick = useLeaveOnClick();
34
38
  return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: variant === "row" ? (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "flex w-full min-w-0 items-center gap-2.5 rounded-xl p-1.5 text-left outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted lg:hover:bg-shell-hover lg:aria-expanded:bg-shell-hover lg:rail:justify-center" })) : (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "touch-target grid size-9 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50" })), children: [_jsxs(Avatar, { children: [user.image ? _jsx(AvatarImage, { src: user.image, alt: "" }) : null, _jsx(AvatarFallback, { className: "text-xs font-semibold uppercase", children: initials(user.name, user.email) })] }), variant === "row" ? (_jsxs("span", { className: "min-w-0 flex-1 lg:rail:hidden", children: [_jsx("span", { className: "block truncate text-sm font-medium", children: user.name }), _jsx("span", { className: "block truncate text-xs text-muted-foreground lg:text-shell-muted", children: user.email })] })) : null] }), _jsxs(DropdownMenuContent, { align: variant === "row" ? "start" : "end", side: variant === "row" ? "top" : "bottom", className: "w-64", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { children: [_jsx("span", { className: "block truncate text-foreground", children: user.name }), _jsx("span", { className: "block truncate", children: user.email })] }) }), _jsx(DropdownMenuSeparator, {}), onProfile ? (_jsxs(DropdownMenuItem, { onClick: onProfile, children: [_jsx(HugeiconsIcon, { icon: UserIcon, "aria-hidden": true }), "Profil"] })) : null, onSecurity ? (_jsxs(DropdownMenuItem, { onClick: onSecurity, children: [_jsx(HugeiconsIcon, { icon: SecurityLockIcon, "aria-hidden": true }), "S\u00E9curit\u00E9"] })) : null, children, _jsxs(DropdownMenuSub, { children: [_jsxs(DropdownMenuSubTrigger, { children: [_jsx(HugeiconsIcon, { icon: theme === "dark" ? Moon02Icon : theme === "light" ? Sun03Icon : ComputerIcon, "aria-hidden": true }), "Th\u00E8me"] }), _jsx(DropdownMenuSubContent, { children: _jsxs(DropdownMenuRadioGroup, { value: theme, onValueChange: (value) => {
35
39
  const next = THEMES.find((t) => t === value);
36
40
  if (next)
37
41
  setTheme(next);
38
- }, children: [_jsx(DropdownMenuRadioItem, { value: "light", children: "Clair" }), _jsx(DropdownMenuRadioItem, { value: "dark", children: "Sombre" }), _jsx(DropdownMenuRadioItem, { value: "system", children: "Syst\u00E8me" })] }) })] }), onShortcuts ? (_jsxs(DropdownMenuItem, { onClick: onShortcuts, children: [_jsx(HugeiconsIcon, { icon: KeyboardIcon, "aria-hidden": true }), "Raccourcis clavier", _jsx(DropdownMenuShortcut, { children: "?" })] })) : null, products.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Produits" }), products.map((product) => product.current ? (_jsxs(DropdownMenuItem, { disabled: true, className: "data-disabled:opacity-100", children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Produit ouvert" })] }, product.name)) : (_jsxs(MenuPrimitive.LinkItem, { href: product.href, className: MENU_LINK_ITEM, children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), product.hint ? (_jsx("span", { className: "text-xs font-normal text-muted-foreground", children: product.hint })) : null] }, product.name)))] })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: onSignOut, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
42
+ }, children: [_jsx(DropdownMenuRadioItem, { value: "light", children: "Clair" }), _jsx(DropdownMenuRadioItem, { value: "dark", children: "Sombre" }), _jsx(DropdownMenuRadioItem, { value: "system", children: "Syst\u00E8me" })] }) })] }), onShortcuts ? (_jsxs(DropdownMenuItem, { onClick: onShortcuts, children: [_jsx(HugeiconsIcon, { icon: KeyboardIcon, "aria-hidden": true }), "Raccourcis clavier", _jsx(DropdownMenuShortcut, { children: "?" })] })) : null, products.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, { className: railShown ? "lg:hidden" : undefined }), _jsxs(DropdownMenuGroup, { className: railShown ? "lg:hidden" : undefined, children: [_jsx(DropdownMenuLabel, { children: "Produits" }), products.map((product) => product.current ? (_jsxs(DropdownMenuItem, { disabled: true, className: "data-disabled:opacity-100", children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Produit ouvert" })] }, product.name)) : (_jsxs(MenuPrimitive.LinkItem, { href: product.href, onClick: leaveOnClick, className: MENU_LINK_ITEM, children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), product.hint ? (_jsx("span", { className: "text-xs font-normal text-muted-foreground", children: product.hint })) : null] }, product.name)))] })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: onSignOut, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
39
43
  }
40
44
  function ProductMenuIcon({ product }) {
41
45
  if (product.badge)
@@ -44,7 +44,7 @@ export declare function SidebarNavItem({ icon, label, active, href, render, }: {
44
44
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
45
45
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
46
46
  */
47
- export declare function AppShell({ product, organization, productRail, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, mainClassName, }: {
47
+ export declare function AppShell({ product, organization, productRail, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, contentKey, navigating, mainClassName, }: {
48
48
  /** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
49
49
  product: FamilyProduct;
50
50
  /** `OrgSwitcher` : en haut de la barre des produits, en haut du tiroir sur téléphone. */
@@ -73,6 +73,20 @@ export declare function AppShell({ product, organization, productRail, accountHr
73
73
  /** Texte du champ de recherche : « Rechercher » par défaut. */
74
74
  searchLabel?: string;
75
75
  children: ReactNode;
76
+ /**
77
+ * Clé de la page affichée (le chemin, `location.pathname`) : la zone de contenu devient une
78
+ * frontière `Suspense` neuve à chaque page, qui montre `PagePending` pendant qu'une page charge
79
+ * ses données dans ses composants (`useSuspenseQuery`), au lieu de garder l'ancienne page figée
80
+ * pendant la transition de la navigation. Sans clé, pas de frontière.
81
+ */
82
+ contentKey?: string | undefined;
83
+ /**
84
+ * Une navigation vers une autre page est en cours (TanStack Router :
85
+ * `useRouterState({ select: (s) => s.isLoading && s.location.pathname !== s.resolvedLocation?.pathname })`).
86
+ * Au-delà de 50 ms, la zone de contenu se couvre du H centré, la coque restant en place : jamais
87
+ * l'ancienne page figée, quel que soit ce qui attend (session, loaders, code de la page).
88
+ */
89
+ navigating?: boolean;
76
90
  /**
77
91
  * @deprecated La largeur du contenu est la même dans toute la famille (`max-w-6xl`, ADR 0012) :
78
92
  * une page étroite passe par `Page` (`@hgdc/ui/page-header`), pas par la coque.
package/dist/app-shell.js CHANGED
@@ -4,13 +4,15 @@ import PanelLeftCloseIcon from "@hugeicons/core-free-icons/PanelLeftCloseIcon";
4
4
  import PanelLeftOpenIcon from "@hugeicons/core-free-icons/PanelLeftOpenIcon";
5
5
  import { HugeiconsIcon } from "@hugeicons/react";
6
6
  import { useRender } from "@base-ui/react/use-render";
7
- import { createContext, useContext, useEffect, useState, useSyncExternalStore } from "react";
7
+ import { createContext, Suspense, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore, } from "react";
8
8
  import { CommandMenuButton } from "./command-menu.js";
9
9
  import { Drawer, DrawerContent, DrawerTitle } from "./drawer.js";
10
10
  import { isApplePlatform, Kbd, KbdGroup } from "./kbd.js";
11
+ import { CONTENT_LOADING_LAYER, ContentLoadingContext, ContentLoadingStage, PAGE_PENDING_MIN_MS, PAGE_PENDING_MS, PagePending, useDelayedFlag, } from "./page-pending.js";
11
12
  import { ProductBadge } from "./product-badge.js";
12
13
  import { ProductRail } from "./product-rail.js";
13
14
  import { HGDC_ACCOUNT, HGDC_PRODUCTS } from "./products.js";
15
+ import { ProductRailShownContext, ShellLeaveContext } from "./shell-placement.js";
14
16
  import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
15
17
  import { cn } from "./cn.js";
16
18
  // ── Rail : la barre latérale réduite aux icônes, mémorisée par navigateur ──────────────
@@ -129,10 +131,37 @@ function SidebarHead({ product }) {
129
131
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
130
132
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
131
133
  */
132
- export function AppShell({ product, organization, productRail = true, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, mainClassName, }) {
134
+ export function AppShell({ product, organization, productRail = true, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, contentKey, navigating = false, mainClassName, }) {
133
135
  const [menuOpen, setMenuOpen] = useState(false);
134
136
  // Barre des produits élargie : la barre latérale se plie le temps du survol (ADR 0011).
135
137
  const [railPeek, setRailPeek] = useState(false);
136
138
  useToggleShortcut();
137
- return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : 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", { className: "flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background", 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("main", { id: "contenu", tabIndex: -1, 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: children })] }) }), _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] }) })] }) }));
139
+ // Chargement de la zone de contenu : navigation en cours (après 50 ms, au moins 300 ms), ou départ
140
+ // vers un autre produit (aussitôt). Une page qui affiche déjà `PagePending` montre le même H au
141
+ // même endroit : le calque de la coque s'efface alors derrière lui.
142
+ const navigatingShown = useDelayedFlag(navigating, PAGE_PENDING_MS, PAGE_PENDING_MIN_MS);
143
+ const [leaving, setLeaving] = useState(false);
144
+ const [pagePending, setPagePending] = useState(0);
145
+ const registerPagePending = useCallback(() => {
146
+ setPagePending((count) => count + 1);
147
+ return () => setPagePending((count) => count - 1);
148
+ }, []);
149
+ const leave = useCallback(() => setLeaving(true), []);
150
+ useEffect(() => {
151
+ // Retour arrière depuis l'autre produit (cache de page) : la page revient telle quelle.
152
+ const onPageShow = (event) => {
153
+ if (event.persisted)
154
+ setLeaving(false);
155
+ };
156
+ window.addEventListener("pageshow", onPageShow);
157
+ return () => window.removeEventListener("pageshow", onPageShow);
158
+ }, []);
159
+ const loading = (navigatingShown || leaving) && pagePending === 0;
160
+ const contentRef = useRef(null);
161
+ useEffect(() => {
162
+ // Sur ordinateur, la zone de contenu défile seule : le calque couvre sa partie visible, en haut.
163
+ if (loading && contentRef.current)
164
+ contentRef.current.scrollTop = 0;
165
+ }, [loading]);
166
+ 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, 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", 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", "data-visible": loading ? "" : undefined, "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: _jsx(ContentLoadingStage, { label: "Chargement\u2026" }) }), _jsx("main", { id: "contenu", tabIndex: -1, "aria-busy": loading || pagePending > 0 ? 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)) }) })] }) }), _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] }) })] }) }) }) }));
138
167
  }
@@ -1,23 +1,24 @@
1
1
  declare const SIZES: {
2
- readonly sm: "size-6";
3
- readonly md: "size-10";
4
- readonly lg: "size-16";
2
+ readonly sm: 24;
3
+ readonly md: 40;
4
+ readonly lg: 64;
5
5
  };
6
6
  /**
7
- * Le chargement d'une page : le H tricolore de la H family se construit, tient, s'efface, en
8
- * boucle (CSS seul, `styles.css`). La forme et les dégradés sont ceux de `FAMILY_MARK_SVG`.
9
- * Il n'apparaît qu'après 200 ms (`delay`) : une page rapide ne le montre jamais, et sa place est
10
- * réservée dès le départ, rien ne saute. Mouvement réduit : le H entier respire.
7
+ * Le chargement d'une page : la silhouette grise du H de la famille, toujours visible, dont les
8
+ * trois pièces se remplissent de leurs dégradés tricolores l'une après l'autre, puis se vident, en
9
+ * boucle (1,6 s). CSS seul (`styles.css`) et seulement `transform` : l'animation tourne sur le
10
+ * compositeur et reste fluide pendant qu'une page charge. Mouvement réduit : la silhouette reste,
11
+ * le remplissage pulse doucement.
11
12
  *
12
- * Pour une page ou une route entière (`pendingComponent`, `Pending` de page) ; dans un bouton ou
13
- * une ligne, `Spinner` reste la règle. `page` le centre dans la hauteur visible.
13
+ * Dans la coque (`AppShell`), un chargement de page passe par `PagePending`, qui affiche ce H à une
14
+ * position fixe au centre de la zone de contenu. Ailleurs, `page` le centre dans l'écran. Dans un
15
+ * bouton ou une ligne, `Spinner` reste la règle.
14
16
  */
15
- export declare function FamilyLoader({ size, label, delay, page, className, }: {
17
+ export declare function FamilyLoader({ size, label, page, className, }: {
18
+ /** `sm` 24 px, `md` 40 px, `lg` 64 px. Par défaut `lg` en page, `md` sinon. */
16
19
  size?: keyof typeof SIZES;
17
20
  label?: string;
18
- /** Attente avant d'apparaître, en millisecondes. */
19
- delay?: number;
20
- /** Centré dans un bloc de la hauteur de l'écran, pour un chargement de page. */
21
+ /** Centré dans l'écran, pour un chargement hors de la coque. */
21
22
  page?: boolean;
22
23
  className?: string;
23
24
  }): React.JSX.Element;
@@ -1,21 +1,30 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId } from "react";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useRef } from "react";
3
3
  import { cn } from "./cn.js";
4
- const SIZES = { sm: "size-6", md: "size-10", lg: "size-16" };
4
+ const SIZES = { sm: 24, md: 40, lg: 64 };
5
5
  /**
6
- * Le chargement d'une page : le H tricolore de la H family se construit, tient, s'efface, en
7
- * boucle (CSS seul, `styles.css`). La forme et les dégradés sont ceux de `FAMILY_MARK_SVG`.
8
- * Il n'apparaît qu'après 200 ms (`delay`) : une page rapide ne le montre jamais, et sa place est
9
- * réservée dès le départ, rien ne saute. Mouvement réduit : le H entier respire.
6
+ * Le chargement d'une page : la silhouette grise du H de la famille, toujours visible, dont les
7
+ * trois pièces se remplissent de leurs dégradés tricolores l'une après l'autre, puis se vident, en
8
+ * boucle (1,6 s). CSS seul (`styles.css`) et seulement `transform` : l'animation tourne sur le
9
+ * compositeur et reste fluide pendant qu'une page charge. Mouvement réduit : la silhouette reste,
10
+ * le remplissage pulse doucement.
10
11
  *
11
- * Pour une page ou une route entière (`pendingComponent`, `Pending` de page) ; dans un bouton ou
12
- * une ligne, `Spinner` reste la règle. `page` le centre dans la hauteur visible.
12
+ * Dans la coque (`AppShell`), un chargement de page passe par `PagePending`, qui affiche ce H à une
13
+ * position fixe au centre de la zone de contenu. Ailleurs, `page` le centre dans l'écran. Dans un
14
+ * bouton ou une ligne, `Spinner` reste la règle.
13
15
  */
14
- export function FamilyLoader({ size = "md", label = "Chargement…", delay = 200, page = false, className, }) {
15
- // Ids des dégradés propres à chaque instance : deux loaders ne se volent pas leurs couleurs.
16
- const id = useId().replace(/[^\w-]/g, "");
17
- const loader = (_jsx("span", { role: "status", "aria-label": label, "data-slot": "family-loader", className: cn("family-loader inline-grid shrink-0 place-items-center", SIZES[size], !page && className), style: { ["--family-loader-delay"]: `${delay}ms` }, children: _jsxs("svg", { viewBox: "0 0 256 256", "aria-hidden": true, className: "size-full overflow-visible", children: [_jsxs("defs", { children: [_jsxs("linearGradient", { id: `${id}l`, x1: "0", y1: "24", x2: "0", y2: "232", gradientUnits: "userSpaceOnUse", children: [_jsx("stop", { offset: "0", stopColor: "#2c78fd" }), _jsx("stop", { offset: "1", stopColor: "#7343fe" })] }), _jsxs("linearGradient", { id: `${id}r`, x1: "0", y1: "24", x2: "0", y2: "232", gradientUnits: "userSpaceOnUse", children: [_jsx("stop", { offset: "0", stopColor: "#fe5292" }), _jsx("stop", { offset: "1", stopColor: "#ff9a46" })] }), _jsxs("linearGradient", { id: `${id}b`, x1: "64", y1: "0", x2: "192", y2: "0", gradientUnits: "userSpaceOnUse", children: [_jsx("stop", { offset: "0", stopColor: "#8145ff" }), _jsx("stop", { offset: "1", stopColor: "#fe5292" })] })] }), _jsx("rect", { "data-piece": "left", x: "43", y: "24", width: "57", height: "208", rx: "18", fill: `url(#${id}l)` }), _jsx("rect", { "data-piece": "right", x: "156", y: "24", width: "57", height: "208", rx: "18", fill: `url(#${id}r)` }), _jsx("rect", { "data-piece": "bar", x: "67", y: "107", width: "122", height: "43", rx: "14", fill: `url(#${id}b)` })] }) }));
16
+ export function FamilyLoader({ size, label = "Chargement…", page = false, className, }) {
17
+ const px = SIZES[size ?? (page ? "lg" : "md")];
18
+ const ref = useRef(null);
19
+ // Tous les H de la page battent en phase, calés sur l'horloge du document : passer du calque de
20
+ // navigation de la coque à celui de la page, ou du rendu serveur à l'hydratation, ne saute pas.
21
+ useIsomorphicLayoutEffect(() => {
22
+ for (const animation of ref.current?.getAnimations?.({ subtree: true }) ?? [])
23
+ animation.startTime = 0;
24
+ }, []);
25
+ const loader = (_jsx("span", { ref: ref, role: "status", "aria-label": label, "data-slot": "family-loader", className: cn("family-loader", !page && className), style: { ["--family-loader-size"]: `${px}px` }, children: ["left", "right", "bar"].map((piece) => (_jsx("span", { "data-piece": piece, "aria-hidden": true, children: _jsx("span", { "data-fill": true }) }, piece))) }));
18
26
  if (!page)
19
27
  return loader;
20
- return (_jsx("div", { "data-slot": "family-loader-page", className: cn("grid min-h-[60dvh] w-full place-items-center", className), children: loader }));
28
+ return (_jsx("div", { "data-slot": "family-loader-page", className: cn("grid min-h-dvh w-full place-items-center", className), children: loader }));
21
29
  }
30
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Délai avant de couvrir l'ancienne page pendant une navigation : une navigation préchargée
3
+ * (survol, `defaultPreload: "intent"`) se termine avant et ne montre rien.
4
+ */
5
+ export declare const PAGE_PENDING_MS = 50;
6
+ /** Durée minimale d'affichage une fois apparu : le H ne clignote pas. */
7
+ export declare const PAGE_PENDING_MIN_MS = 300;
8
+ /**
9
+ * Calque de chargement de la zone de contenu : sous l'en-tête de la coque (56 px), sur toute la
10
+ * largeur, opaque. Le H y est centré dans la hauteur visible et y reste (`sticky`) : même place
11
+ * pour le calque de navigation de la coque et pour `PagePending`, donc aucun saut de l'un à l'autre.
12
+ */
13
+ export declare const CONTENT_LOADING_LAYER = "absolute inset-x-0 top-14 bottom-0 z-20 bg-background";
14
+ /** Le H centré dans la zone de contenu (calque de `CONTENT_LOADING_LAYER`). */
15
+ export declare function ContentLoadingStage({ label }: {
16
+ label: string;
17
+ }): React.JSX.Element;
18
+ /**
19
+ * Fourni par `AppShell` autour de la zone de contenu : `PagePending` s'y signale, pour que la coque
20
+ * n'affiche pas son propre calque de navigation par-dessus.
21
+ */
22
+ export declare const ContentLoadingContext: React.Context<(() => () => void) | null>;
23
+ /**
24
+ * Le chargement d'une page. Dans la coque (`AppShell`) : le H centré sur un calque opaque de la
25
+ * zone de contenu, la coque restant en place, dès le rendu serveur. Hors de la coque : le H centré
26
+ * dans l'écran. Composant de `pendingComponent` (TanStack Router, `routerPendingDefaults`), repli
27
+ * de la frontière `Suspense` de la coque, et état de chargement d'une page qui lit ses données.
28
+ */
29
+ export declare function PagePending({ label }: {
30
+ label?: string;
31
+ }): React.JSX.Element;
32
+ /**
33
+ * Vrai après `delay` ms de `active`, et le reste au moins `min` ms une fois vrai : un état bref ne
34
+ * montre rien, un état montré ne clignote pas.
35
+ */
36
+ export declare function useDelayedFlag(active: boolean, delay: number, min: number): boolean;
37
+ /**
38
+ * Réglages communs de `createRouter` (TanStack Router) :
39
+ * - préchargement au survol ou au focus d'un lien (`intent`) : la plupart des pages s'ouvrent sans
40
+ * attente ; `defaultPreloadStaleTime: 0` laisse TanStack Query décider de la fraîcheur ;
41
+ * - une route qui attend encore ses données montre `PagePending` après 50 ms, au moins 300 ms.
42
+ *
43
+ * ```ts
44
+ * createRouter({ routeTree, ...routerPendingDefaults })
45
+ * ```
46
+ *
47
+ * La coque doit vivre dans une route de mise en page (`/o/$org`) et le `beforeLoad` de la racine
48
+ * ne doit pas appeler le serveur à chaque navigation : une attente là remplacerait toute l'app.
49
+ */
50
+ export declare const routerPendingDefaults: {
51
+ defaultPendingComponent: typeof PagePending;
52
+ defaultPendingMs: number;
53
+ defaultPendingMinMs: number;
54
+ defaultPreload: "intent";
55
+ defaultPreloadStaleTime: number;
56
+ };
@@ -0,0 +1,83 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useLayoutEffect, useState } from "react";
3
+ import { FamilyLoader } from "./family-loader.js";
4
+ /**
5
+ * Délai avant de couvrir l'ancienne page pendant une navigation : une navigation préchargée
6
+ * (survol, `defaultPreload: "intent"`) se termine avant et ne montre rien.
7
+ */
8
+ export const PAGE_PENDING_MS = 50;
9
+ /** Durée minimale d'affichage une fois apparu : le H ne clignote pas. */
10
+ export const PAGE_PENDING_MIN_MS = 300;
11
+ /**
12
+ * Calque de chargement de la zone de contenu : sous l'en-tête de la coque (56 px), sur toute la
13
+ * largeur, opaque. Le H y est centré dans la hauteur visible et y reste (`sticky`) : même place
14
+ * pour le calque de navigation de la coque et pour `PagePending`, donc aucun saut de l'un à l'autre.
15
+ */
16
+ export const CONTENT_LOADING_LAYER = "absolute inset-x-0 top-14 bottom-0 z-20 bg-background";
17
+ const CONTENT_LOADING_STAGE = "sticky top-14 grid h-[calc(100dvh-3.5rem)] place-items-center lg:h-[calc(100dvh-1rem-3.5rem)]";
18
+ /** Le H centré dans la zone de contenu (calque de `CONTENT_LOADING_LAYER`). */
19
+ export function ContentLoadingStage({ label }) {
20
+ return (_jsx("div", { className: CONTENT_LOADING_STAGE, children: _jsx(FamilyLoader, { size: "lg", label: label }) }));
21
+ }
22
+ /**
23
+ * Fourni par `AppShell` autour de la zone de contenu : `PagePending` s'y signale, pour que la coque
24
+ * n'affiche pas son propre calque de navigation par-dessus.
25
+ */
26
+ export const ContentLoadingContext = createContext(null);
27
+ /**
28
+ * Le chargement d'une page. Dans la coque (`AppShell`) : le H centré sur un calque opaque de la
29
+ * zone de contenu, la coque restant en place, dès le rendu serveur. Hors de la coque : le H centré
30
+ * dans l'écran. Composant de `pendingComponent` (TanStack Router, `routerPendingDefaults`), repli
31
+ * de la frontière `Suspense` de la coque, et état de chargement d'une page qui lit ses données.
32
+ */
33
+ export function PagePending({ label = "Chargement…" }) {
34
+ const register = useContext(ContentLoadingContext);
35
+ useIsomorphicLayoutEffect(() => register?.(), [register]);
36
+ if (!register)
37
+ return _jsx(FamilyLoader, { page: true, label: label });
38
+ return (_jsx("div", { "data-slot": "page-pending", className: "min-h-[calc(100dvh-10rem)]", children: _jsx("div", { className: CONTENT_LOADING_LAYER, children: _jsx(ContentLoadingStage, { label: label }) }) }));
39
+ }
40
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
41
+ /**
42
+ * Vrai après `delay` ms de `active`, et le reste au moins `min` ms une fois vrai : un état bref ne
43
+ * montre rien, un état montré ne clignote pas.
44
+ */
45
+ export function useDelayedFlag(active, delay, min) {
46
+ const [visible, setVisible] = useState(false);
47
+ const [shownAt, setShownAt] = useState(0);
48
+ useEffect(() => {
49
+ if (active && !visible) {
50
+ const timer = window.setTimeout(() => {
51
+ setShownAt(Date.now());
52
+ setVisible(true);
53
+ }, delay);
54
+ return () => window.clearTimeout(timer);
55
+ }
56
+ if (!active && visible) {
57
+ const timer = window.setTimeout(() => setVisible(false), Math.max(0, shownAt + min - Date.now()));
58
+ return () => window.clearTimeout(timer);
59
+ }
60
+ return undefined;
61
+ }, [active, visible, delay, min, shownAt]);
62
+ return visible;
63
+ }
64
+ /**
65
+ * Réglages communs de `createRouter` (TanStack Router) :
66
+ * - préchargement au survol ou au focus d'un lien (`intent`) : la plupart des pages s'ouvrent sans
67
+ * attente ; `defaultPreloadStaleTime: 0` laisse TanStack Query décider de la fraîcheur ;
68
+ * - une route qui attend encore ses données montre `PagePending` après 50 ms, au moins 300 ms.
69
+ *
70
+ * ```ts
71
+ * createRouter({ routeTree, ...routerPendingDefaults })
72
+ * ```
73
+ *
74
+ * La coque doit vivre dans une route de mise en page (`/o/$org`) et le `beforeLoad` de la racine
75
+ * ne doit pas appeler le serveur à chaque navigation : une attente là remplacerait toute l'app.
76
+ */
77
+ export const routerPendingDefaults = {
78
+ defaultPendingComponent: PagePending,
79
+ defaultPendingMs: PAGE_PENDING_MS,
80
+ defaultPendingMinMs: PAGE_PENDING_MIN_MS,
81
+ defaultPreload: "intent",
82
+ defaultPreloadStaleTime: 0,
83
+ };
@@ -2,12 +2,14 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { ProductBadge } from "./product-badge.js";
4
4
  import { productRailEntries } from "./products.js";
5
- import { ShellPlacementContext } from "./shell-placement.js";
5
+ import { ShellPlacementContext, useLeaveOnClick } from "./shell-placement.js";
6
6
  import { cn } from "./cn.js";
7
7
  /** Délai d'intention avant d'élargir la barre au survol : un passage de souris ne l'ouvre pas. */
8
8
  export const PRODUCT_RAIL_OPEN_DELAY = 150;
9
9
  function ProductLink({ entry }) {
10
- return (_jsx("li", { children: _jsxs("a", { href: entry.href, "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] })] }) }));
10
+ // Chaque produit s'ouvre en document complet : la coque montre le H dès le clic.
11
+ const leaveOnClick = useLeaveOnClick();
12
+ 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] })] }) }));
11
13
  }
12
14
  /**
13
15
  * La barre des produits de la famille, tout à gauche de la coque sur ordinateur : organisation en
@@ -5,3 +5,17 @@
5
5
  export type ShellPlacement = "sidebar" | "product-rail";
6
6
  export declare const ShellPlacementContext: React.Context<ShellPlacement>;
7
7
  export declare function useShellPlacement(): ShellPlacement;
8
+ /**
9
+ * Vrai quand la coque affiche la barre des produits (sur ordinateur) : les produits y sont déjà,
10
+ * le menu du compte ne les répète pas au-delà de `lg`.
11
+ */
12
+ export declare const ProductRailShownContext: React.Context<boolean>;
13
+ export declare function useProductRailShown(): boolean;
14
+ /**
15
+ * Prévient la coque qu'un lien vers un autre produit (ou une autre adresse servie en document
16
+ * complet) vient d'être suivi : elle couvre aussitôt la zone de contenu du H de chargement, le
17
+ * temps que le navigateur ouvre l'autre produit. Sans coque, ne fait rien.
18
+ */
19
+ export declare const ShellLeaveContext: React.Context<() => void>;
20
+ /** `onClick` d'un lien vers un autre produit : clic simple seulement (pas Ctrl, Cmd, Maj, molette). */
21
+ export declare function useLeaveOnClick(): (event: React.MouseEvent<HTMLAnchorElement>) => void;
@@ -3,3 +3,31 @@ export const ShellPlacementContext = createContext("sidebar");
3
3
  export function useShellPlacement() {
4
4
  return useContext(ShellPlacementContext);
5
5
  }
6
+ /**
7
+ * Vrai quand la coque affiche la barre des produits (sur ordinateur) : les produits y sont déjà,
8
+ * le menu du compte ne les répète pas au-delà de `lg`.
9
+ */
10
+ export const ProductRailShownContext = createContext(false);
11
+ export function useProductRailShown() {
12
+ return useContext(ProductRailShownContext);
13
+ }
14
+ /**
15
+ * Prévient la coque qu'un lien vers un autre produit (ou une autre adresse servie en document
16
+ * complet) vient d'être suivi : elle couvre aussitôt la zone de contenu du H de chargement, le
17
+ * temps que le navigateur ouvre l'autre produit. Sans coque, ne fait rien.
18
+ */
19
+ export const ShellLeaveContext = createContext(() => { });
20
+ /** `onClick` d'un lien vers un autre produit : clic simple seulement (pas Ctrl, Cmd, Maj, molette). */
21
+ export function useLeaveOnClick() {
22
+ const leave = useContext(ShellLeaveContext);
23
+ return (event) => {
24
+ if (event.defaultPrevented || event.button !== 0)
25
+ return;
26
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
27
+ return;
28
+ const target = event.currentTarget.getAttribute("target");
29
+ if (target && target !== "_self")
30
+ return;
31
+ leave();
32
+ };
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.12.2",
3
+ "version": "0.14.0",
4
4
  "description": "Interface de la H family : 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
@@ -481,93 +481,122 @@
481
481
  /* `FamilyLoader` : le H tricolore se construit (jambage gauche, barre, jambage droit), tient,
482
482
  s'efface, recommence. Les pièces sont coupées par `clip-path` (la forme ne change pas), un
483
483
  léger chevauchement lie les trois gestes. Visible après 200 ms seulement : pas de flash. */
484
+ /* Chargeur de la famille (`FamilyLoader`) : silhouette grise du H, pièces remplies de leurs
485
+ dégradés. Géométrie du H sur 256 (FAMILY_MARK_SVG). Seul `transform` s'anime : compositeur,
486
+ fluide pendant qu'une page charge. */
484
487
  .family-loader {
485
- opacity: 0;
486
- animation: family-loader-in var(--duration-base) var(--ease-out) var(--family-loader-delay, 200ms) forwards;
488
+ --fl: var(--family-loader-size, 40px);
489
+ --fl-ghost: color-mix(in oklab, var(--foreground) var(--fl-ghost-mix, 11%), var(--background));
490
+ position: relative;
491
+ display: inline-block;
492
+ flex-shrink: 0;
493
+ width: var(--fl);
494
+ height: var(--fl);
495
+ }
496
+ /* Sur fond sombre, la silhouette a besoin d'un peu plus de lumière pour rester lisible. */
497
+ .dark .family-loader {
498
+ --fl-ghost-mix: 19%;
499
+ }
500
+ .family-loader [data-piece] {
501
+ position: absolute;
502
+ overflow: hidden;
503
+ background: var(--fl-ghost);
504
+ /* Le coin arrondi découpe aussi le remplissage, sur sa propre couche. */
505
+ isolation: isolate;
506
+ }
507
+ .family-loader [data-piece="left"],
508
+ .family-loader [data-piece="right"] {
509
+ top: calc(var(--fl) * 24 / 256);
510
+ width: calc(var(--fl) * 57 / 256);
511
+ height: calc(var(--fl) * 208 / 256);
512
+ border-radius: calc(var(--fl) * 18 / 256);
487
513
  }
488
- /* Le nom dans chaque raccourci : sans lui, lightningcss (minification de Vite) réduit
489
- `animation: 1.8s infinite both` à `animation: none`. */
490
514
  .family-loader [data-piece="left"] {
491
- animation: family-loader-left 1.8s infinite both;
492
- }
493
- .family-loader [data-piece="bar"] {
494
- animation: family-loader-bar 1.8s infinite both;
515
+ left: calc(var(--fl) * 43 / 256);
495
516
  }
496
517
  .family-loader [data-piece="right"] {
497
- animation: family-loader-right 1.8s infinite both;
498
- }
499
- @keyframes family-loader-in {
500
- to {
501
- opacity: 1;
502
- }
518
+ left: calc(var(--fl) * 156 / 256);
503
519
  }
504
- @keyframes family-loader-left {
520
+ .family-loader [data-piece="bar"] {
521
+ left: calc(var(--fl) * 67 / 256);
522
+ top: calc(var(--fl) * 107 / 256);
523
+ width: calc(var(--fl) * 122 / 256);
524
+ height: calc(var(--fl) * 43 / 256);
525
+ border-radius: calc(var(--fl) * 14 / 256);
526
+ }
527
+ .family-loader [data-fill] {
528
+ position: absolute;
529
+ inset: 0;
530
+ will-change: transform;
531
+ }
532
+ .family-loader [data-piece="left"] [data-fill] {
533
+ background: linear-gradient(to bottom, #2c78fd, #7343fe);
534
+ animation: family-loader-rise 1.6s var(--ease-in-out) infinite both;
535
+ }
536
+ .family-loader [data-piece="right"] [data-fill] {
537
+ background: linear-gradient(to bottom, #fe5292, #ff9a46);
538
+ animation: family-loader-rise 1.6s var(--ease-in-out) 0.4s infinite both;
539
+ }
540
+ .family-loader [data-piece="bar"] [data-fill] {
541
+ background: linear-gradient(to right, #8145ff, #fe5292);
542
+ animation: family-loader-sweep 1.6s var(--ease-in-out) 0.2s infinite both;
543
+ }
544
+ /* Les jambes se remplissent par le bas, tiennent, puis la couleur continue de monter et laisse
545
+ la silhouette : le cycle suivant repart sans temps mort. */
546
+ @keyframes family-loader-rise {
505
547
  0% {
506
- clip-path: inset(100% 0 0 0);
507
- opacity: 1;
508
- animation-timing-function: var(--ease-out);
548
+ transform: translateY(101%);
509
549
  }
510
- 24%,
511
- 74% {
512
- clip-path: inset(0 0 0 0);
513
- opacity: 1;
514
- animation-timing-function: var(--ease-out);
550
+ 38%,
551
+ 62% {
552
+ transform: translateY(0);
515
553
  }
516
- 90%,
517
554
  100% {
518
- clip-path: inset(0 0 0 0);
519
- opacity: 0;
555
+ transform: translateY(-101%);
520
556
  }
521
557
  }
522
- @keyframes family-loader-bar {
523
- 0%,
524
- 16% {
525
- clip-path: inset(0 100% 0 0);
526
- opacity: 1;
527
- animation-timing-function: var(--ease-out);
558
+ /* La barre, de gauche à droite. */
559
+ @keyframes family-loader-sweep {
560
+ 0% {
561
+ transform: translateX(-101%);
528
562
  }
529
- 40%,
530
- 77% {
531
- clip-path: inset(0 0 0 0);
532
- opacity: 1;
533
- animation-timing-function: var(--ease-out);
563
+ 38%,
564
+ 62% {
565
+ transform: translateX(0);
534
566
  }
535
- 93%,
536
567
  100% {
537
- clip-path: inset(0 0 0 0);
538
- opacity: 0;
568
+ transform: translateX(101%);
539
569
  }
540
570
  }
541
- @keyframes family-loader-right {
571
+ @keyframes family-loader-glow {
542
572
  0%,
543
- 32% {
544
- clip-path: inset(100% 0 0 0);
545
- opacity: 1;
546
- animation-timing-function: var(--ease-out);
547
- }
548
- 56%,
549
- 80% {
550
- clip-path: inset(0 0 0 0);
551
- opacity: 1;
552
- animation-timing-function: var(--ease-out);
553
- }
554
- 96%,
555
573
  100% {
556
- clip-path: inset(0 0 0 0);
557
- opacity: 0;
574
+ opacity: 0.25;
558
575
  }
559
- }
560
- @keyframes family-loader-breathe {
561
576
  50% {
562
- opacity: 0.45;
577
+ opacity: 1;
563
578
  }
564
579
  }
565
580
  @media (prefers-reduced-motion: reduce) {
566
- /* Le H entier, qui respire lentement. */
567
- .family-loader [data-piece] {
568
- animation: none;
569
- }
570
- .family-loader svg {
571
- animation: family-loader-breathe 2.4s ease-in-out infinite;
581
+ /* La silhouette reste, le remplissage pulse en place, sans mouvement. */
582
+ .family-loader [data-piece] [data-fill] {
583
+ transform: none;
584
+ animation: family-loader-glow 1.6s ease-in-out infinite both;
572
585
  }
573
586
  }
587
+ /* Calque de chargement de la zone de contenu (`AppShell`) : opaque, au-dessus de l'ancienne page,
588
+ le H à une position fixe. Apparition courte, disparition plus courte encore. */
589
+ [data-slot="app-shell-loading"] {
590
+ opacity: 0;
591
+ visibility: hidden;
592
+ transition:
593
+ opacity 100ms var(--ease-out),
594
+ visibility 0s linear 100ms;
595
+ }
596
+ [data-slot="app-shell-loading"][data-visible] {
597
+ opacity: 1;
598
+ visibility: visible;
599
+ transition:
600
+ opacity 150ms var(--ease-out),
601
+ visibility 0s;
602
+ }