@hgdc/ui 0.37.0 → 0.38.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
@@ -63,6 +63,17 @@ Primitives déjà là : `avatar` `badge` `button` `dialog` `drawer` `dropdown-me
63
63
 
64
64
  Le menu « Produits » et la palette (`badge` d'une commande) montrent la pastille de chaque produit.
65
65
 
66
+ ### D'un produit à l'autre sous `app.hgdc.ch` (ADR 0020)
67
+
68
+ Entre deux produits servis sous la même origine, la coque fait comme une seule app : transition de vue entre documents (la barre des produits, la barre latérale et la barre du haut restent en place, le contenu passe en fondu de 150 ms, rien en mouvement réduit), lien relatif marqué `data-hgdc-prerender` et prérendu au survol (`PRODUCT_SPECULATION_RULES` de `@hgdc/ui/product-switch`). Le H couvre le contenu 100 ms après le clic (`PRODUCT_LEAVE_DELAY_MS`) : jamais pour une page prérendue. Chrome et Edge : tout ; Safari 18.2+ : la transition, sans prérendu ; Firefox : navigation ordinaire.
69
+
70
+ Ce que chaque produit fait :
71
+
72
+ - Monter `@hgdc/ui` et `@hgdc/auth` : `styles.css` déclare `@view-transition`, `AppShell` pose les noms et les règles. Rien d'autre à écrire.
73
+ - CSP : aucun produit ne pose `script-src` aujourd'hui. Celui qui en pose un ajoute `'inline-speculation-rules'` (règles en ligne insérées par la coque) ; sans cela, Chrome les ignore et rien ne casse.
74
+ - Une page qui compte une visite ou lance un effet au chargement attend l'affichage : `document.prerendering` faux, sinon `prerenderingchange`. La relecture des organisations le fait déjà.
75
+ - `localStorage` est commun à tous les produits de l'origine : `theme` et `sidebar` sont communs, c'est voulu ; les groupes repliables sont propres au produit (`sidebar-group:<produit>:<titre>`). Une clé du produit commence par son mot (`mail:last-org`).
76
+
66
77
  ### Mise en page (ADR 0012)
67
78
 
68
79
  Une seule règle dans les sept produits, posée par la coque :
@@ -77,7 +77,7 @@ export declare function SidebarNavGroup(props: {
77
77
  active?: boolean;
78
78
  /** Ouvert tant que la personne n'a rien choisi. Fermé par défaut. */
79
79
  defaultOpen?: boolean;
80
- /** Clé de mémorisation, par défaut le titre : à donner quand deux groupes portent le même. */
80
+ /** Clé de mémorisation, par défaut le titre, propre au produit de la coque : à donner quand deux groupes portent le même. */
81
81
  storageKey?: string;
82
82
  }): React.JSX.Element;
83
83
  /**
@@ -118,6 +118,11 @@ export type AppShellAssistant = {
118
118
  /** Lettre du raccourci `Ctrl` (`⌘`) + lettre qui ouvre et ferme l'assistant (`"j"`). Aucun par défaut. */
119
119
  shortcut?: string;
120
120
  };
121
+ /**
122
+ * Délai entre le clic vers un autre produit et le H : une page prérendue s'affiche avant (une ou
123
+ * deux images), le H n'apparaît alors jamais ; une page à charger le montre aussitôt après.
124
+ */
125
+ export declare const PRODUCT_LEAVE_DELAY_MS = 100;
121
126
  export declare function AppShell({ product, organization, productRail, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, }: {
122
127
  /** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
123
128
  product: FamilyProduct;
package/dist/app-shell.js CHANGED
@@ -77,7 +77,9 @@ function useToggleShortcut() {
77
77
  }, []);
78
78
  }
79
79
  // ── Navigation ──────────────────────────────────────────────────────────────────────
80
- const ShellContext = createContext({ closeMobileNav: () => { } });
80
+ const ShellContext = createContext({
81
+ closeMobileNav: () => { },
82
+ });
81
83
  /** Pour fermer le tiroir mobile depuis un élément de la barre (un sélecteur, un bouton). */
82
84
  export function useAppShell() {
83
85
  return useContext(ShellContext);
@@ -189,7 +191,11 @@ export function SidebarNavGroup(props) {
189
191
  return _jsx(TitledNavGroup, { label: props.label, children: props.children });
190
192
  }
191
193
  function CollapsibleNavGroup({ label, children, icon, active, defaultOpen = false, storageKey, }) {
192
- const key = storageKey ?? label;
194
+ // Propre au produit : sous une origine partagée (`app.hgdc.ch/<produit>`), `localStorage` est
195
+ // commun à tous. Le thème et la barre réduite le sont volontairement (`theme`, `sidebar`), un
196
+ // groupe « Réglages » de Mail et de Host ne l'est pas (ADR 0020).
197
+ const { product } = useContext(ShellContext);
198
+ const key = product ? `${product}:${storageKey ?? label}` : (storageKey ?? label);
193
199
  const buttonId = useId();
194
200
  const listId = useId();
195
201
  const listRef = useRef(null);
@@ -403,6 +409,11 @@ function useContentLoading({ navigating, pending }) {
403
409
  }, [active, visible]);
404
410
  return visible;
405
411
  }
412
+ /**
413
+ * Délai entre le clic vers un autre produit et le H : une page prérendue s'affiche avant (une ou
414
+ * deux images), le H n'apparaît alors jamais ; une page à charger le montre aussitôt après.
415
+ */
416
+ export const PRODUCT_LEAVE_DELAY_MS = 100;
406
417
  export function AppShell({ product, organization, productRail = true, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, }) {
407
418
  const [menuOpen, setMenuOpen] = useState(false);
408
419
  // Barre des produits élargie : la barre latérale se plie le temps du survol (ADR 0011).
@@ -420,15 +431,26 @@ export function AppShell({ product, organization, productRail = true, accountHre
420
431
  setPagePending((count) => count + 1);
421
432
  return () => setPagePending((count) => count - 1);
422
433
  }, []);
423
- const leave = useCallback(() => setLeaving(true), []);
434
+ // Après `PRODUCT_LEAVE_DELAY_MS` seulement : une page prérendue au survol s'affiche avant, sans
435
+ // que le H apparaisse (ADR 0020). Sinon le H couvre le contenu jusqu'au départ de la page.
436
+ const leaveTimer = useRef(undefined);
437
+ const leave = useCallback(() => {
438
+ window.clearTimeout(leaveTimer.current);
439
+ leaveTimer.current = window.setTimeout(() => setLeaving(true), PRODUCT_LEAVE_DELAY_MS);
440
+ }, []);
424
441
  useEffect(() => {
425
442
  // Retour arrière depuis l'autre produit (cache de page) : la page revient telle quelle.
426
443
  const onPageShow = (event) => {
427
- if (event.persisted)
428
- setLeaving(false);
444
+ if (!event.persisted)
445
+ return;
446
+ window.clearTimeout(leaveTimer.current);
447
+ setLeaving(false);
429
448
  };
430
449
  window.addEventListener("pageshow", onPageShow);
431
- return () => window.removeEventListener("pageshow", onPageShow);
450
+ return () => {
451
+ window.removeEventListener("pageshow", onPageShow);
452
+ window.clearTimeout(leaveTimer.current);
453
+ };
432
454
  }, []);
433
455
  const statusPageUrl = statusPage === false ? null : (statusPage ?? statusPageUrlFor(product));
434
456
  const loading = useContentLoading({ navigating, pending: pagePending > 0 || leaving });
@@ -455,5 +477,5 @@ export function AppShell({ product, organization, productRail = true, accountHre
455
477
  const assistantButtonRef = useRef(null);
456
478
  useAssistantShortcut(assistant);
457
479
  const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, _jsx(ShellCommonLinks, { links: links ?? {}, statusPageUrl: statusPageUrl }), account ?? sidebarFooter] }));
458
- return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsx(StatusPageContext, { value: statusPageUrl, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, "data-assistant": assistantOpen ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)_auto]" : "lg:grid-cols-[auto_minmax(0,1fr)_auto]"), 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("div", { className: "contents", inert: pageBlocked || undefined, children: _jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek }) })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", inert: pageBlocked || undefined, 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, _jsx("div", { "data-slot": "app-shell-footer", className: "grid gap-0.5 px-3 pt-1 pb-3 empty:hidden", children: footer })] }), _jsx("div", { inert: pageBlocked || undefined, className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: productName(product) })] }), _jsx("div", { className: cn("flex min-w-0 items-center gap-2", header ? "flex-[1_1_7rem]" : "flex-1"), children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null, assistant ? (_jsx(AssistantButton, { assistant: assistant, panelId: assistantPanelId, buttonRef: assistantButtonRef })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsxs("main", { id: "contenu", tabIndex: -1, "aria-busy": loading ? true : undefined, inert: loading || undefined, className: cn("mx-auto w-full max-w-6xl flex-1 px-4 pt-6 pb-16 outline-none sm:px-6 lg:px-10 lg:pt-8", mainClassName), children: [_jsx(ServiceStatus, { pageUrl: statusPageUrl, className: "mb-6" }), _jsx(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) })] })] }) }), assistant && assistantMounted ? (_jsx(AssistantPanel, { assistant: assistant, panelId: assistantPanelId, wide: wide, openAtShellMount: assistantOpenAtMount, buttonRef: assistantButtonRef })) : null, hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, _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)] empty:hidden", children: footer })] }) })] }) }) }) }) }));
480
+ return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false), product }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsx(StatusPageContext, { value: statusPageUrl, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, "data-assistant": assistantOpen ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)_auto]" : "lg:grid-cols-[auto_minmax(0,1fr)_auto]"), 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("div", { className: "contents", inert: pageBlocked || undefined, children: _jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek }) })) : null, _jsxs("aside", { "data-slot": "app-shell-sidebar", "aria-label": "Barre lat\u00E9rale", inert: pageBlocked || undefined, 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, _jsx("div", { "data-slot": "app-shell-footer", className: "grid gap-0.5 px-3 pt-1 pb-3 empty:hidden", children: footer })] }), _jsx("div", { inert: pageBlocked || undefined, className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { "data-slot": "app-shell-header", className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: productName(product) })] }), _jsx("div", { className: cn("flex min-w-0 items-center gap-2", header ? "flex-[1_1_7rem]" : "flex-1"), children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null, assistant ? (_jsx(AssistantButton, { assistant: assistant, panelId: assistantPanelId, buttonRef: assistantButtonRef })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsxs("main", { id: "contenu", tabIndex: -1, "aria-busy": loading ? true : undefined, inert: loading || undefined, className: cn("mx-auto w-full max-w-6xl flex-1 px-4 pt-6 pb-16 outline-none sm:px-6 lg:px-10 lg:pt-8", mainClassName), children: [_jsx(ServiceStatus, { pageUrl: statusPageUrl, className: "mb-6" }), _jsx(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) })] })] }) }), assistant && assistantMounted ? (_jsx(AssistantPanel, { assistant: assistant, panelId: assistantPanelId, wide: wide, openAtShellMount: assistantOpenAtMount, buttonRef: assistantButtonRef })) : null, hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, _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)] empty:hidden", children: footer })] }) })] }) }) }) }) }));
459
481
  }
@@ -1,16 +1,22 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
3
3
  import { ProductBadge } from "./product-badge.js";
4
+ import { PRODUCT_PRERENDER_ATTRIBUTE } from "./product-switch.js";
4
5
  import { productRailEntries } from "./products.js";
5
6
  import { ShellPlacementContext, useLeaveOnClick } from "./shell-placement.js";
6
7
  import { cn } from "./cn.js";
7
8
  const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
9
+ const noSubscription = () => () => { };
10
+ /** `location.origin` après l'hydratation, `undefined` au rendu serveur (liens absolus d'abord). */
11
+ function usePageOrigin() {
12
+ return useSyncExternalStore(noSubscription, () => window.location.origin, () => undefined);
13
+ }
8
14
  /** Délai d'intention avant d'élargir la barre au survol : un passage de souris ne l'ouvre pas. */
9
15
  export const PRODUCT_RAIL_OPEN_DELAY = 150;
10
16
  function ProductLink({ entry }) {
11
17
  // Chaque produit s'ouvre en document complet : la coque montre le H dès le clic.
12
18
  const leaveOnClick = useLeaveOnClick();
13
- return (_jsx("li", { children: _jsxs("a", { href: entry.href, onClick: leaveOnClick, "aria-current": entry.current ? "page" : undefined, className: cn("flex h-10 items-center gap-3 rounded-xl px-2 text-shell-muted coarse:h-11 outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-shell-hover hover:text-shell-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-[current=page]:bg-shell-active aria-[current=page]:text-shell-foreground"), children: [_jsx(ProductBadge, { product: entry.badge, className: "size-6" }), _jsxs("span", { className: "min-w-0 flex-1 truncate opacity-0 transition-opacity duration-(--duration-fast) ease-out group-data-expanded/rail:opacity-100 motion-reduce:transition-none", children: [_jsx("span", { className: "font-heading text-sm font-medium", children: entry.name }), entry.hint ? _jsx("span", { className: "sr-only", children: ", " }) : null, entry.hint ? _jsx("span", { className: "ml-1.5 text-xs text-shell-muted", children: entry.hint }) : null] })] }) }));
19
+ return (_jsx("li", { children: _jsxs("a", { href: entry.href, onClick: leaveOnClick, ...(entry.sameOrigin ? { [PRODUCT_PRERENDER_ATTRIBUTE]: "" } : {}), "aria-current": entry.current ? "page" : undefined, className: cn("flex h-10 items-center gap-3 rounded-xl px-2 text-shell-muted coarse:h-11 outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-shell-hover hover:text-shell-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-[current=page]:bg-shell-active aria-[current=page]:text-shell-foreground"), children: [_jsx(ProductBadge, { product: entry.badge, className: "size-6" }), _jsxs("span", { className: "min-w-0 flex-1 truncate opacity-0 transition-opacity duration-(--duration-fast) ease-out group-data-expanded/rail:opacity-100 motion-reduce:transition-none", children: [_jsx("span", { className: "font-heading text-sm font-medium", children: entry.name }), entry.hint ? _jsx("span", { className: "sr-only", children: ", " }) : null, entry.hint ? _jsx("span", { className: "ml-1.5 text-xs text-shell-muted", children: entry.hint }) : null] })] }) }));
14
20
  }
15
21
  /**
16
22
  * La barre des produits de la famille, tout à gauche de la coque sur ordinateur : organisation en
@@ -35,7 +41,8 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
35
41
  onExpandedChange?.(expanded);
36
42
  }, [expanded, onExpandedChange]);
37
43
  const ref = useRef(null);
38
- const { products, account } = productRailEntries({ current, accountHref, catalog });
44
+ const pageOrigin = usePageOrigin();
45
+ const { products, account } = productRailEntries({ current, accountHref, catalog, pageOrigin });
39
46
  // Écouteurs natifs : la barre est un repère `nav`, pas un contrôle ; c'est son survol, le focus
40
47
  // de ses liens et les menus qu'elle ouvre qui l'élargissent.
41
48
  useEffect(() => {
@@ -74,6 +81,17 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
74
81
  if (!(event.relatedTarget instanceof Node) || !rail.contains(event.relatedTarget))
75
82
  setFocused(false);
76
83
  };
84
+ // Un produit choisi : la barre se replie comme un menu qui se ferme, pendant que l'autre produit
85
+ // s'ouvre. La page d'arrivée la montre repliée : rien ne bouge au passage (ADR 0020).
86
+ const onClick = (event) => {
87
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
88
+ return;
89
+ if (!(event.target instanceof Element) || !event.target.closest("a[href]"))
90
+ return;
91
+ cancel();
92
+ setHovered(false);
93
+ setFocused(false);
94
+ };
77
95
  const onKey = (event) => {
78
96
  if (event.key !== "Escape")
79
97
  return;
@@ -94,6 +112,7 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
94
112
  rail.addEventListener("focusin", onFocusIn);
95
113
  rail.addEventListener("focusout", onFocusOut);
96
114
  rail.addEventListener("keydown", onKey);
115
+ rail.addEventListener("click", onClick);
97
116
  return () => {
98
117
  cancel();
99
118
  observer.disconnect();
@@ -104,6 +123,7 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
104
123
  rail.removeEventListener("focusin", onFocusIn);
105
124
  rail.removeEventListener("focusout", onFocusOut);
106
125
  rail.removeEventListener("keydown", onKey);
126
+ rail.removeEventListener("click", onClick);
107
127
  };
108
128
  }, []);
109
129
  return (_jsx("div", { "data-slot": "product-rail", "data-expanded": expanded ? "" : undefined, className: cn("hidden w-14 shrink-0 overflow-hidden lg:block",
@@ -4,13 +4,29 @@ export declare const ORG_SYNC_COOKIE = "hgdc-org-sync";
4
4
  export declare const BACKGROUND_REFRESH_PATH = "/auth/hgdc?refresh=1&frame=1";
5
5
  /** Abandon du cadre s'il ne s'est pas chargé : Account injoignable, réseau coupé. */
6
6
  export declare const BACKGROUND_REFRESH_TIMEOUT_MS = 20000;
7
+ /** Marque d'un lien de la barre vers un autre produit servi sous la même origine : prérendu au survol. */
8
+ export declare const PRODUCT_PRERENDER_ATTRIBUTE = "data-hgdc-prerender";
7
9
  /**
8
- * Liens préchargés au survol : l'entrée d'un autre produit en `refresh=background`. Elle ne fait
9
- * que lire la session et rediriger ; sans session, elle répond 503 à un préchargement (rien n'est
10
- * créé), et le clic repart normalement. Jamais de `prerender` : la page d'arrivée lit un cookie et
11
- * ouvre un cadre, ce qu'un prérendu ferait pour un simple survol.
10
+ * Règles de spéculation posées par la coque (Chrome et Edge ; ignorées ailleurs) :
11
+ *
12
+ * - **prérendu** au survol (`eagerness: "moderate"`, environ 200 ms) des liens marqués
13
+ * `PRODUCT_PRERENDER_ATTRIBUTE` vers l'entrée `refresh=background` d'un produit de la même
14
+ * origine (`app.hgdc.ch/<produit>`) : le motif est relatif, il ne vaut que pour l'origine de la
15
+ * page. L'entrée ne rend que la redirection d'une session ouverte ; sans session, elle répond 503
16
+ * à une requête `Sec-Purpose: prefetch;prerender`, le prérendu est jeté et le clic repart
17
+ * normalement. La page prérendue attend d'être affichée pour relire les organisations.
18
+ * - **préchargement** seul de l'entrée d'un produit encore sur son sous-domaine (même site, les
19
+ * cookies partent), comme avant (ADR 0016).
12
20
  */
21
+ export declare const PRODUCT_SPECULATION_RULES: string;
22
+ /** @deprecated Remplacé par `PRODUCT_SPECULATION_RULES` (prérendu sous la même origine, 0.38). */
13
23
  export declare const PRODUCT_PREFETCH_RULES: string;
24
+ /**
25
+ * Lit et efface le cookie de relecture. Rend la base de chemin du produit (`""` à la racine de son
26
+ * origine, `"/analytics"` sous `app.hgdc.ch/analytics`), `null` s'il n'était pas posé. Sous une
27
+ * base, l'entrée le limite à ce chemin : un autre produit de la même origine ne le voit pas.
28
+ */
29
+ export declare function takeOrgSync(doc?: Document): string | null;
14
30
  /** Lit et efface le cookie de relecture. Vrai s'il était posé. */
15
31
  export declare function takeOrgSyncCookie(doc?: Document): boolean;
16
32
  /**
@@ -18,7 +34,8 @@ export declare function takeOrgSyncCookie(doc?: Document): boolean;
18
34
  * ni script ni formulaire (`sandbox`), il suit seulement les redirections d'Account ; la nouvelle
19
35
  * session du produit recopie les organisations, et les requêtes Convex abonnées se mettent à jour.
20
36
  * Si la session d'Account a expiré, Account refuse d'être affiché dans un cadre : rien ne se passe,
21
- * la session du produit reste celle d'avant.
37
+ * la session du produit reste celle d'avant. Une page prérendue au survol attend d'être affichée :
38
+ * un survol sans clic ne relit rien.
22
39
  */
23
40
  export declare function startBackgroundRefresh(doc?: Document): HTMLIFrameElement | null;
24
41
  /**
@@ -30,6 +47,8 @@ export declare function startBackgroundRefresh(doc?: Document): HTMLIFrameElemen
30
47
  export declare function useBackgroundOrganizationRefresh(): void;
31
48
  /**
32
49
  * Dans la coque (`AppShell` le fait) : la relecture ci-dessus si la racine ne l'a pas déjà faite
33
- * (le cookie ne sert qu'une fois), et le préchargement au survol des autres produits.
50
+ * (le cookie ne sert qu'une fois), et le prérendu ou le préchargement au survol des autres
51
+ * produits. Les règles sont un `<script type="speculationrules">` en ligne : une CSP qui pose
52
+ * `script-src` doit y autoriser `'inline-speculation-rules'` (README).
34
53
  */
35
54
  export declare function useProductSwitch(enabled?: boolean): void;
@@ -1,6 +1,6 @@
1
1
  import { useEffect } from "react";
2
2
  /*
3
- * Passage d'un produit à l'autre (ADR 0016) : la barre des produits pointe vers
3
+ * Passage d'un produit à l'autre (ADR 0016, 0020) : la barre des produits pointe vers
4
4
  * `/auth/hgdc?refresh=background&next=/`. Avec une session du produit ouverte, l'entrée de
5
5
  * `@hgdc/auth` va droit à la page et pose un cookie « relire les organisations » ; la coque le lit
6
6
  * ici et relit les organisations dans un cadre caché, sans faire attendre la personne.
@@ -15,13 +15,33 @@ export const ORG_SYNC_COOKIE = "hgdc-org-sync";
15
15
  export const BACKGROUND_REFRESH_PATH = "/auth/hgdc?refresh=1&frame=1";
16
16
  /** Abandon du cadre s'il ne s'est pas chargé : Account injoignable, réseau coupé. */
17
17
  export const BACKGROUND_REFRESH_TIMEOUT_MS = 20_000;
18
+ /** Marque d'un lien de la barre vers un autre produit servi sous la même origine : prérendu au survol. */
19
+ export const PRODUCT_PRERENDER_ATTRIBUTE = "data-hgdc-prerender";
18
20
  /**
19
- * Liens préchargés au survol : l'entrée d'un autre produit en `refresh=background`. Elle ne fait
20
- * que lire la session et rediriger ; sans session, elle répond 503 à un préchargement (rien n'est
21
- * créé), et le clic repart normalement. Jamais de `prerender` : la page d'arrivée lit un cookie et
22
- * ouvre un cadre, ce qu'un prérendu ferait pour un simple survol.
21
+ * Règles de spéculation posées par la coque (Chrome et Edge ; ignorées ailleurs) :
22
+ *
23
+ * - **prérendu** au survol (`eagerness: "moderate"`, environ 200 ms) des liens marqués
24
+ * `PRODUCT_PRERENDER_ATTRIBUTE` vers l'entrée `refresh=background` d'un produit de la même
25
+ * origine (`app.hgdc.ch/<produit>`) : le motif est relatif, il ne vaut que pour l'origine de la
26
+ * page. L'entrée ne rend que la redirection d'une session ouverte ; sans session, elle répond 503
27
+ * à une requête `Sec-Purpose: prefetch;prerender`, le prérendu est jeté et le clic repart
28
+ * normalement. La page prérendue attend d'être affichée pour relire les organisations.
29
+ * - **préchargement** seul de l'entrée d'un produit encore sur son sous-domaine (même site, les
30
+ * cookies partent), comme avant (ADR 0016).
23
31
  */
24
- export const PRODUCT_PREFETCH_RULES = JSON.stringify({
32
+ export const PRODUCT_SPECULATION_RULES = JSON.stringify({
33
+ prerender: [
34
+ {
35
+ source: "document",
36
+ where: {
37
+ and: [
38
+ { selector_matches: `a[${PRODUCT_PRERENDER_ATTRIBUTE}]` },
39
+ { href_matches: "/*/auth/hgdc?refresh=background*" },
40
+ ],
41
+ },
42
+ eagerness: "moderate",
43
+ },
44
+ ],
25
45
  prefetch: [
26
46
  {
27
47
  source: "document",
@@ -30,24 +50,53 @@ export const PRODUCT_PREFETCH_RULES = JSON.stringify({
30
50
  },
31
51
  ],
32
52
  });
33
- /** Lit et efface le cookie de relecture. Vrai s'il était posé. */
34
- export function takeOrgSyncCookie(doc = document) {
35
- const present = doc.cookie.split(";").some((part) => part.trim() === `${ORG_SYNC_COOKIE}=1`);
36
- if (present) {
53
+ /** @deprecated Remplacé par `PRODUCT_SPECULATION_RULES` (prérendu sous la même origine, 0.38). */
54
+ export const PRODUCT_PREFETCH_RULES = PRODUCT_SPECULATION_RULES;
55
+ /** Base de chemin d'un produit (`/analytics`), telle que l'entrée la pose dans le cookie. */
56
+ const BASE_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/;
57
+ /**
58
+ * Lit et efface le cookie de relecture. Rend la base de chemin du produit (`""` à la racine de son
59
+ * origine, `"/analytics"` sous `app.hgdc.ch/analytics`), `null` s'il n'était pas posé. Sous une
60
+ * base, l'entrée le limite à ce chemin : un autre produit de la même origine ne le voit pas.
61
+ */
62
+ export function takeOrgSync(doc = document) {
63
+ let base = null;
64
+ for (const part of doc.cookie.split(";")) {
65
+ const [name, value] = part.trim().split("=", 2);
66
+ if (name !== ORG_SYNC_COOKIE || value === undefined)
67
+ continue;
68
+ if (value === "1")
69
+ base = "";
70
+ else if (BASE_PATH.test(value))
71
+ base = value;
72
+ if (base !== null)
73
+ break;
74
+ }
75
+ if (base !== null) {
37
76
  const secure = doc.location.protocol === "https:" ? "; Secure" : "";
38
- doc.cookie = `${ORG_SYNC_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`;
77
+ doc.cookie = `${ORG_SYNC_COOKIE}=; Path=${base || "/"}; Max-Age=0; SameSite=Lax${secure}`;
39
78
  }
40
- return present;
79
+ return base;
80
+ }
81
+ /** Lit et efface le cookie de relecture. Vrai s'il était posé. */
82
+ export function takeOrgSyncCookie(doc = document) {
83
+ return takeOrgSync(doc) !== null;
41
84
  }
42
85
  /**
43
86
  * Relit les organisations dans un cadre caché, une fois, si l'entrée l'a demandé. Le cadre n'a
44
87
  * ni script ni formulaire (`sandbox`), il suit seulement les redirections d'Account ; la nouvelle
45
88
  * session du produit recopie les organisations, et les requêtes Convex abonnées se mettent à jour.
46
89
  * Si la session d'Account a expiré, Account refuse d'être affiché dans un cadre : rien ne se passe,
47
- * la session du produit reste celle d'avant.
90
+ * la session du produit reste celle d'avant. Une page prérendue au survol attend d'être affichée :
91
+ * un survol sans clic ne relit rien.
48
92
  */
49
93
  export function startBackgroundRefresh(doc = document) {
50
- if (!takeOrgSyncCookie(doc))
94
+ if (doc.prerendering) {
95
+ doc.addEventListener("prerenderingchange", () => startBackgroundRefresh(doc), { once: true });
96
+ return null;
97
+ }
98
+ const base = takeOrgSync(doc);
99
+ if (base === null)
51
100
  return null;
52
101
  const frame = doc.createElement("iframe");
53
102
  frame.hidden = true;
@@ -63,7 +112,7 @@ export function startBackgroundRefresh(doc = document) {
63
112
  };
64
113
  const timer = window.setTimeout(done, BACKGROUND_REFRESH_TIMEOUT_MS);
65
114
  frame.addEventListener("load", done, { once: true });
66
- frame.src = BACKGROUND_REFRESH_PATH;
115
+ frame.src = `${base}${BACKGROUND_REFRESH_PATH}`;
67
116
  doc.body.append(frame);
68
117
  return frame;
69
118
  }
@@ -80,7 +129,9 @@ export function useBackgroundOrganizationRefresh() {
80
129
  }
81
130
  /**
82
131
  * Dans la coque (`AppShell` le fait) : la relecture ci-dessus si la racine ne l'a pas déjà faite
83
- * (le cookie ne sert qu'une fois), et le préchargement au survol des autres produits.
132
+ * (le cookie ne sert qu'une fois), et le prérendu ou le préchargement au survol des autres
133
+ * produits. Les règles sont un `<script type="speculationrules">` en ligne : une CSP qui pose
134
+ * `script-src` doit y autoriser `'inline-speculation-rules'` (README).
84
135
  */
85
136
  export function useProductSwitch(enabled = true) {
86
137
  useEffect(() => {
@@ -95,7 +146,7 @@ export function useProductSwitch(enabled = true) {
95
146
  // Créé par le DOM, pas par React : un script inséré par `innerHTML` n'est jamais lu.
96
147
  const script = document.createElement("script");
97
148
  script.type = "speculationrules";
98
- script.textContent = PRODUCT_PREFETCH_RULES;
149
+ script.textContent = PRODUCT_SPECULATION_RULES;
99
150
  document.head.append(script);
100
151
  return () => script.remove();
101
152
  }, [enabled]);
@@ -96,6 +96,11 @@ export declare function productMenuEntries(options?: {
96
96
  accountHref?: string | undefined;
97
97
  catalog?: ReadonlyArray<HgdcCatalogProduct> | undefined;
98
98
  }): Array<ProductMenuEntry>;
99
+ /**
100
+ * Chemin de `href` s'il est servi sous `pageOrigin` (`/mail/auth/hgdc?…` depuis
101
+ * `https://app.hgdc.ch`), sinon `null`. Sans origine connue (rendu serveur), `null`.
102
+ */
103
+ export declare function sameOriginPath(href: string, pageOrigin: string | undefined): string | null;
99
104
  /** Une entrée de la barre des produits d'`AppShell`. */
100
105
  export type ProductRailEntry = {
101
106
  name: string;
@@ -105,17 +110,27 @@ export type ProductRailEntry = {
105
110
  current?: boolean;
106
111
  /** Texte discret sous le nom : « Préversion ». */
107
112
  hint?: string;
113
+ /**
114
+ * Produit servi sous la même origine que la page : lien relatif, prérendu au survol
115
+ * (`PRODUCT_PRERENDER_ATTRIBUTE` de `@hgdc/ui/product-switch`) et transition de vue.
116
+ */
117
+ sameOrigin?: boolean;
108
118
  };
109
119
  /**
110
120
  * Les entrées de la barre des produits : les produits ouverts du catalogue et Account, affiché en
111
121
  * tête (le compte commun, pas un produit). Chaque produit pointe vers son entrée
112
122
  * `/auth/hgdc?refresh=background&next=/` (ADR 0010, 0013, 0016), Account vers sa racine (`accountHref` en
113
- * développement). Le produit courant pointe vers `/`, sans détour par l'entrée.
123
+ * développement). Le produit courant pointe vers sa racine, sans détour par l'entrée.
124
+ *
125
+ * `pageOrigin` (`location.origin`, absent au rendu serveur) : un produit servi sous cette origine
126
+ * (`PRODUCT_ORIGINS`, `app.hgdc.ch/<produit>`) reçoit un lien relatif et `sameOrigin` (ADR 0020),
127
+ * et le produit courant sa base (`/mail/`) plutôt que `/`.
114
128
  */
115
129
  export declare function productRailEntries(options?: {
116
130
  current?: string | undefined;
117
131
  accountHref?: string | undefined;
118
132
  catalog?: ReadonlyArray<HgdcCatalogProduct> | undefined;
133
+ pageOrigin?: string | undefined;
119
134
  }): {
120
135
  products: Array<ProductRailEntry>;
121
136
  account: ProductRailEntry;
package/dist/products.js CHANGED
@@ -34,6 +34,7 @@ export const PRODUCT_ORIGINS = {
34
34
  analytics: { origin: "https://app.hgdc.ch", basePath: "/analytics" },
35
35
  mail: { origin: "https://app.hgdc.ch", basePath: "/mail" },
36
36
  host: { origin: "https://app.hgdc.ch", basePath: "/host" },
37
+ docs: { origin: "https://app.hgdc.ch", basePath: "/docs" },
37
38
  };
38
39
  /** Adresse de l'app d'un produit : celle de `PRODUCT_ORIGINS`, sinon `https://<mot>.hgdc.ch`. */
39
40
  export function productOrigin(word, origins = PRODUCT_ORIGINS) {
@@ -104,22 +105,49 @@ export function productMenuEntries(options = {}) {
104
105
  ...(item.stage === "preview" ? { hint: PRODUCT_STAGE_LABEL.preview } : {}),
105
106
  }));
106
107
  }
108
+ /**
109
+ * Chemin de `href` s'il est servi sous `pageOrigin` (`/mail/auth/hgdc?…` depuis
110
+ * `https://app.hgdc.ch`), sinon `null`. Sans origine connue (rendu serveur), `null`.
111
+ */
112
+ export function sameOriginPath(href, pageOrigin) {
113
+ if (!pageOrigin)
114
+ return null;
115
+ try {
116
+ const url = new URL(href);
117
+ return url.origin === pageOrigin ? `${url.pathname}${url.search}${url.hash}` : null;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
107
123
  /**
108
124
  * Les entrées de la barre des produits : les produits ouverts du catalogue et Account, affiché en
109
125
  * tête (le compte commun, pas un produit). Chaque produit pointe vers son entrée
110
126
  * `/auth/hgdc?refresh=background&next=/` (ADR 0010, 0013, 0016), Account vers sa racine (`accountHref` en
111
- * développement). Le produit courant pointe vers `/`, sans détour par l'entrée.
127
+ * développement). Le produit courant pointe vers sa racine, sans détour par l'entrée.
128
+ *
129
+ * `pageOrigin` (`location.origin`, absent au rendu serveur) : un produit servi sous cette origine
130
+ * (`PRODUCT_ORIGINS`, `app.hgdc.ch/<produit>`) reçoit un lien relatif et `sameOrigin` (ADR 0020),
131
+ * et le produit courant sa base (`/mail/`) plutôt que `/`.
112
132
  */
113
133
  export function productRailEntries(options = {}) {
114
134
  const current = options.current?.toLowerCase();
115
- const entry = (item, href) => ({
116
- name: item.name,
117
- badge: item.badge,
118
- ...(item.badge === current ? { href: "/", current: true } : { href }),
119
- ...(item.stage === "preview" ? { hint: PRODUCT_STAGE_LABEL.preview } : {}),
120
- });
135
+ const { pageOrigin } = options;
136
+ const entry = (item, href, product) => {
137
+ const base = {
138
+ name: item.name,
139
+ badge: item.badge,
140
+ ...(item.stage === "preview" ? { hint: PRODUCT_STAGE_LABEL.preview } : {}),
141
+ };
142
+ if (item.badge === current) {
143
+ const app = productOrigin(item.badge);
144
+ return { ...base, href: pageOrigin === app.origin ? `${app.basePath}/` : "/", current: true };
145
+ }
146
+ const path = product ? sameOriginPath(href, pageOrigin) : null;
147
+ return path === null ? { ...base, href } : { ...base, href: path, sameOrigin: true };
148
+ };
121
149
  return {
122
- products: (options.catalog ?? HGDC_PRODUCTS).filter(isProductOpen).map((item) => entry(item, item.entryHref)),
123
- account: entry(HGDC_ACCOUNT, options.accountHref ?? HGDC_ACCOUNT.entryHref),
150
+ products: (options.catalog ?? HGDC_PRODUCTS).filter(isProductOpen).map((item) => entry(item, item.entryHref, true)),
151
+ account: entry(HGDC_ACCOUNT, options.accountHref ?? HGDC_ACCOUNT.entryHref, false),
124
152
  };
125
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Interface de la H Collection : jetons de la DA, thèmes, polices et composants.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",
package/styles.css CHANGED
@@ -870,3 +870,43 @@
870
870
  opacity 150ms var(--ease-out),
871
871
  visibility 0s;
872
872
  }
873
+
874
+ /* Passage d'un produit à l'autre sous la même origine (`app.hgdc.ch/<produit>`, ADR 0020) : une
875
+ transition de vue entre documents. La barre des produits, la barre latérale et la barre du haut
876
+ gardent leur place (même nom des deux côtés : elles ne bougent pas, leur contenu passe en
877
+ fondu) ; le reste de la page passe en fondu court. Le navigateur garde l'ancienne page à l'écran
878
+ jusqu'à ce que la nouvelle soit prête : pas de page blanche. Chrome, Edge et Safari 18.2+ ;
879
+ ailleurs, une navigation ordinaire. Entre deux origines (un produit encore sur son sous-domaine)
880
+ ou derrière une redirection vers Account, le navigateur n'en fait pas. Mouvement réduit : aucune
881
+ transition. */
882
+ @media (prefers-reduced-motion: no-preference) {
883
+ @view-transition {
884
+ navigation: auto;
885
+ }
886
+ }
887
+ [data-slot="product-rail"] {
888
+ view-transition-name: hgdc-product-rail;
889
+ }
890
+ [data-slot="app-shell-sidebar"] {
891
+ view-transition-name: hgdc-sidebar;
892
+ }
893
+ [data-slot="app-shell-header"] {
894
+ view-transition-name: hgdc-header;
895
+ }
896
+ ::view-transition-old(root),
897
+ ::view-transition-new(root) {
898
+ animation-duration: var(--duration-fast);
899
+ animation-timing-function: var(--ease-out);
900
+ }
901
+ ::view-transition-group(hgdc-product-rail),
902
+ ::view-transition-group(hgdc-sidebar),
903
+ ::view-transition-group(hgdc-header),
904
+ ::view-transition-old(hgdc-product-rail),
905
+ ::view-transition-new(hgdc-product-rail),
906
+ ::view-transition-old(hgdc-sidebar),
907
+ ::view-transition-new(hgdc-sidebar),
908
+ ::view-transition-old(hgdc-header),
909
+ ::view-transition-new(hgdc-header) {
910
+ animation-duration: var(--duration-base);
911
+ animation-timing-function: var(--ease-ios);
912
+ }