@hgdc/ui 0.37.0 → 0.39.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 +21 -0
- package/dist/account-menu.js +6 -1
- package/dist/app-shell.d.ts +15 -4
- package/dist/app-shell.js +53 -17
- package/dist/family-loader.js +2 -1
- package/dist/org-switcher.js +9 -0
- package/dist/product-rail.d.ts +18 -1
- package/dist/product-rail.js +66 -6
- package/dist/product-switch.d.ts +25 -6
- package/dist/product-switch.js +68 -17
- package/dist/products.d.ts +16 -1
- package/dist/products.js +37 -9
- package/dist/shell-frame.d.ts +21 -0
- package/dist/shell-frame.js +33 -0
- package/dist/shell-memory.d.ts +65 -0
- package/dist/shell-memory.js +169 -0
- package/package.json +1 -1
- package/styles.css +147 -10
package/README.md
CHANGED
|
@@ -63,6 +63,27 @@ 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, 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
|
+
Pendant le passage (0.39) :
|
|
71
|
+
|
|
72
|
+
- **Barre des produits** : immobile, sans fondu, seul l'indicateur du produit change. La page d'arrivée la peint identique dès sa première image, même quand ses données ne sont pas là : `OrgPlaceholder` tient la place de l'organisation (`organization` absent) avec la marque de la dernière organisation affichée sous l'origine (initiales ou logo, retenus par `OrgSwitcher`, oubliés à la déconnexion par `AccountMenu`), sinon un squelette de la même taille. Le clic la replie sans transition et la page d'arrivée ne l'élargit pas sous la souris restée dessus.
|
|
73
|
+
- **Barre latérale** : la marque du produit et sa navigation passent en fondu de 200 ms. Une page qui attend hors de la coque (`PagePending` sous `FamilyLoaderRoot`) dessine la coque entière : barre des produits, marque du produit de l'adresse, navigation en squelette à la hauteur retenue pour ce produit, barre du haut.
|
|
74
|
+
- **H** : un seul nom de transition (`hgdc-loader`), sans fondu, calé sur l'horloge : celui de l'arrivée reprend à la même phase.
|
|
75
|
+
- **Contenu** : fondu de 150 ms.
|
|
76
|
+
|
|
77
|
+
`sidebarInitScript` (dans le `<head>`, déjà posé par tous les produits) applique cette mémoire sur `<html>` avant la première peinture : `data-hgdc-product`, `data-hgdc-org`, `--hgdc-org-initials`, `--hgdc-org-logo` (adresse `https:` seulement), `--hgdc-nav-height`. Elle vit dans `localStorage` (`hgdc-shell:org`, `hgdc-shell:nav:<produit>`) et `sessionStorage` (`hgdc-shell:switch`) ; rien d'autre que des initiales, un logo et une hauteur.
|
|
78
|
+
|
|
79
|
+
Ce que chaque produit fait :
|
|
80
|
+
|
|
81
|
+
- Monter `@hgdc/ui` et `@hgdc/auth` : `styles.css` déclare `@view-transition`, `AppShell` pose les noms et les règles. Rien d'autre à écrire.
|
|
82
|
+
- Pendant le chargement de ses données, rendre `AppShell` sans `organization` (pas `organization={null}`) : la place reste tenue.
|
|
83
|
+
- 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.
|
|
84
|
+
- 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à.
|
|
85
|
+
- `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`).
|
|
86
|
+
|
|
66
87
|
### Mise en page (ADR 0012)
|
|
67
88
|
|
|
68
89
|
Une seule règle dans les sept produits, posée par la coque :
|
package/dist/account-menu.js
CHANGED
|
@@ -12,6 +12,7 @@ import { initials } from "./initials.js";
|
|
|
12
12
|
import { MENU_LINK_ITEM } from "./org-switcher.js";
|
|
13
13
|
import { ProductBadge } from "./product-badge.js";
|
|
14
14
|
import { productMenuEntries } from "./products.js";
|
|
15
|
+
import { forgetShellOrganization } from "./shell-memory.js";
|
|
15
16
|
import { useLeaveOnClick, useProductRailShown } from "./shell-placement.js";
|
|
16
17
|
import { ThemeSwitch } from "./theme-switch.js";
|
|
17
18
|
/** L'avatar du compte : la photo, sinon les initiales sur la teinte stable de l'adresse. */
|
|
@@ -38,7 +39,11 @@ export function AccountMenu({ user, onProfile, onSecurity, onSignOut, products:
|
|
|
38
39
|
const products = productsProp ?? productMenuEntries({ current: currentProduct, accountHref });
|
|
39
40
|
const railShown = useProductRailShown();
|
|
40
41
|
const leaveOnClick = useLeaveOnClick();
|
|
41
|
-
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: [_jsx(AccountAvatar, { user: user }), 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-72", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { className: "flex items-center gap-2.5 py-2", children: [_jsx(AccountAvatar, { user: user }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate text-sm font-medium 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, _jsx(ThemeSwitch, {}), children ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), 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:
|
|
42
|
+
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: [_jsx(AccountAvatar, { user: user }), 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-72", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { className: "flex items-center gap-2.5 py-2", children: [_jsx(AccountAvatar, { user: user }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate text-sm font-medium 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, _jsx(ThemeSwitch, {}), children ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), 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: () => {
|
|
43
|
+
// La marque de l'organisation retenue pour la coque ne survit pas à la session.
|
|
44
|
+
forgetShellOrganization();
|
|
45
|
+
onSignOut();
|
|
46
|
+
}, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
|
|
42
47
|
}
|
|
43
48
|
function ProductMenuIcon({ product }) {
|
|
44
49
|
if (product.badge)
|
package/dist/app-shell.d.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { type IconSvgElement } from "@hugeicons/react";
|
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
3
|
import type { FamilyProduct } from "./family-svg.ts";
|
|
4
4
|
/**
|
|
5
|
-
* À poser dans le `<head>`, avec `themeInitScript` :
|
|
6
|
-
*
|
|
5
|
+
* À poser dans le `<head>`, avec `themeInitScript` : avant la première peinture, il remet le
|
|
6
|
+
* rail (la variante CSS `rail:` le lit, rien ne saute au chargement) et la mémoire de la coque
|
|
7
|
+
* partagée sous la même origine (`shell-memory` : produit de l'adresse, dernière organisation,
|
|
8
|
+
* hauteur de la navigation), que le cadre du chargeur et `OrgPlaceholder` lisent (ADR 0020).
|
|
7
9
|
*/
|
|
8
10
|
export declare const sidebarInitScript: string;
|
|
9
11
|
/** La barre latérale est-elle réduite en rail ? L'état vit sur `<html data-sidebar="rail">`. */
|
|
@@ -77,7 +79,7 @@ export declare function SidebarNavGroup(props: {
|
|
|
77
79
|
active?: boolean;
|
|
78
80
|
/** Ouvert tant que la personne n'a rien choisi. Fermé par défaut. */
|
|
79
81
|
defaultOpen?: boolean;
|
|
80
|
-
/** Clé de mémorisation, par défaut le titre : à donner quand deux groupes portent le même. */
|
|
82
|
+
/** Clé de mémorisation, par défaut le titre, propre au produit de la coque : à donner quand deux groupes portent le même. */
|
|
81
83
|
storageKey?: string;
|
|
82
84
|
}): React.JSX.Element;
|
|
83
85
|
/**
|
|
@@ -118,10 +120,19 @@ export type AppShellAssistant = {
|
|
|
118
120
|
/** Lettre du raccourci `Ctrl` (`⌘`) + lettre qui ouvre et ferme l'assistant (`"j"`). Aucun par défaut. */
|
|
119
121
|
shortcut?: string;
|
|
120
122
|
};
|
|
123
|
+
/**
|
|
124
|
+
* Délai entre le clic vers un autre produit et le H : une page prérendue s'affiche avant (une ou
|
|
125
|
+
* deux images), le H n'apparaît alors jamais ; une page à charger le montre aussitôt après.
|
|
126
|
+
*/
|
|
127
|
+
export declare const PRODUCT_LEAVE_DELAY_MS = 100;
|
|
121
128
|
export declare function AppShell({ product, organization, productRail, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, }: {
|
|
122
129
|
/** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
|
|
123
130
|
product: FamilyProduct;
|
|
124
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* `OrgSwitcher` : en haut de la barre des produits, en haut du tiroir sur téléphone. Absent
|
|
133
|
+
* pendant que les données du produit arrivent : la barre lui garde sa place, avec la marque de
|
|
134
|
+
* la dernière organisation affichée (`OrgPlaceholder`, ADR 0020). `null` : aucune organisation.
|
|
135
|
+
*/
|
|
125
136
|
organization?: ReactNode;
|
|
126
137
|
/**
|
|
127
138
|
* `false` : pas de barre des produits (un écran hors de la famille). L'organisation passe alors
|
package/dist/app-shell.js
CHANGED
|
@@ -20,8 +20,9 @@ import { CONTENT_LOADING_LAYER, ContentLoadingContext, PAGE_PENDING_MIN_MS, PAGE
|
|
|
20
20
|
import { ProductBadge } from "./product-badge.js";
|
|
21
21
|
import { ProductRail } from "./product-rail.js";
|
|
22
22
|
import { useProductSwitch } from "./product-switch.js";
|
|
23
|
-
import { HGDC_ACCOUNT, HGDC_PRODUCTS } from "./products.js";
|
|
24
23
|
import { ProductRailShownContext, ShellLeaveContext, StatusPageContext } from "./shell-placement.js";
|
|
24
|
+
import { familyProductName, SIDEBAR_BRAND, SIDEBAR_BRAND_NAME, SIDEBAR_HEAD } from "./shell-frame.js";
|
|
25
|
+
import { rememberShellNavHeight, shellBootScript } from "./shell-memory.js";
|
|
25
26
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
|
|
26
27
|
import { cn } from "./cn.js";
|
|
27
28
|
import { ServiceStatus, statusPageUrlFor } from "./service-status.js";
|
|
@@ -30,10 +31,12 @@ import { useMediaQuery } from "./use-media-query.js";
|
|
|
30
31
|
const RAIL_KEY = "sidebar";
|
|
31
32
|
const RAIL_EVENT = "sidebarchange";
|
|
32
33
|
/**
|
|
33
|
-
* À poser dans le `<head>`, avec `themeInitScript` :
|
|
34
|
-
*
|
|
34
|
+
* À poser dans le `<head>`, avec `themeInitScript` : avant la première peinture, il remet le
|
|
35
|
+
* rail (la variante CSS `rail:` le lit, rien ne saute au chargement) et la mémoire de la coque
|
|
36
|
+
* partagée sous la même origine (`shell-memory` : produit de l'adresse, dernière organisation,
|
|
37
|
+
* hauteur de la navigation), que le cadre du chargeur et `OrgPlaceholder` lisent (ADR 0020).
|
|
35
38
|
*/
|
|
36
|
-
export const sidebarInitScript =
|
|
39
|
+
export const sidebarInitScript = shellBootScript();
|
|
37
40
|
function subscribeRail(onChange) {
|
|
38
41
|
window.addEventListener(RAIL_EVENT, onChange);
|
|
39
42
|
return () => window.removeEventListener(RAIL_EVENT, onChange);
|
|
@@ -77,7 +80,9 @@ function useToggleShortcut() {
|
|
|
77
80
|
}, []);
|
|
78
81
|
}
|
|
79
82
|
// ── Navigation ──────────────────────────────────────────────────────────────────────
|
|
80
|
-
const ShellContext = createContext({
|
|
83
|
+
const ShellContext = createContext({
|
|
84
|
+
closeMobileNav: () => { },
|
|
85
|
+
});
|
|
81
86
|
/** Pour fermer le tiroir mobile depuis un élément de la barre (un sélecteur, un bouton). */
|
|
82
87
|
export function useAppShell() {
|
|
83
88
|
return useContext(ShellContext);
|
|
@@ -189,7 +194,11 @@ export function SidebarNavGroup(props) {
|
|
|
189
194
|
return _jsx(TitledNavGroup, { label: props.label, children: props.children });
|
|
190
195
|
}
|
|
191
196
|
function CollapsibleNavGroup({ label, children, icon, active, defaultOpen = false, storageKey, }) {
|
|
192
|
-
|
|
197
|
+
// Propre au produit : sous une origine partagée (`app.hgdc.ch/<produit>`), `localStorage` est
|
|
198
|
+
// commun à tous. Le thème et la barre réduite le sont volontairement (`theme`, `sidebar`), un
|
|
199
|
+
// groupe « Réglages » de Mail et de Host ne l'est pas (ADR 0020).
|
|
200
|
+
const { product } = useContext(ShellContext);
|
|
201
|
+
const key = product ? `${product}:${storageKey ?? label}` : (storageKey ?? label);
|
|
193
202
|
const buttonId = useId();
|
|
194
203
|
const listId = useId();
|
|
195
204
|
const listRef = useRef(null);
|
|
@@ -338,11 +347,6 @@ const TOGGLE = "touch-target relative grid h-9 w-10 shrink-0 place-items-center
|
|
|
338
347
|
function ShortcutHint({ label }) {
|
|
339
348
|
return (_jsxs("span", { className: "flex items-center gap-2", children: [label, _jsxs(KbdGroup, { children: [_jsx(Kbd, { children: isApplePlatform() ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: "B" })] })] }));
|
|
340
349
|
}
|
|
341
|
-
function productName(product) {
|
|
342
|
-
if (product === HGDC_ACCOUNT.badge)
|
|
343
|
-
return HGDC_ACCOUNT.name;
|
|
344
|
-
return HGDC_PRODUCTS.find((item) => item.badge === product)?.name ?? `HGDC ${product}`;
|
|
345
|
-
}
|
|
346
350
|
/**
|
|
347
351
|
* Haut de la barre latérale. Dépliée : la marque du produit, et le bouton « Réduire » à droite.
|
|
348
352
|
* En rail : la marque seule, qui devient le bouton « Déplier » au survol de la barre ou au focus,
|
|
@@ -350,7 +354,7 @@ function productName(product) {
|
|
|
350
354
|
* choisis par la variante CSS `rail:`, donc justes dès la première peinture.
|
|
351
355
|
*/
|
|
352
356
|
function SidebarHead({ product }) {
|
|
353
|
-
return (_jsxs("div", { className:
|
|
357
|
+
return (_jsxs("div", { className: SIDEBAR_HEAD, children: [_jsxs("div", { "data-slot": "sidebar-brand", className: cn(SIDEBAR_BRAND, "rail:hidden"), children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: SIDEBAR_BRAND_NAME, children: familyProductName(product) })] }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("button", { type: "button", "data-slot": "sidebar-collapse", onClick: toggleSidebar, "aria-label": "R\u00E9duire la barre lat\u00E9rale", "aria-keyshortcuts": "Control+B Meta+B", className: cn(TOGGLE, "rail:hidden") }), children: _jsx(HugeiconsIcon, { icon: COLLAPSE_ICON, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem]" }) }), _jsx(TooltipContent, { side: "right", children: _jsx(ShortcutHint, { label: "R\u00E9duire" }) })] }), _jsxs(Tooltip, { children: [_jsxs(TooltipTrigger, { render: _jsx("button", { type: "button", "data-slot": "sidebar-expand", onClick: toggleSidebar, "aria-label": "D\u00E9plier la barre lat\u00E9rale", "aria-keyshortcuts": "Control+B Meta+B", className: cn(TOGGLE, "group/toggle hidden rail:grid") }), children: [_jsx(ProductBadge, { product: product, className: "col-start-1 row-start-1 size-6 transition-opacity duration-(--duration-fast) ease-out group-hover/sidebar:opacity-0 group-focus-visible/toggle:opacity-0 motion-reduce:transition-none" }), _jsx(HugeiconsIcon, { icon: EXPAND_ICON, strokeWidth: 1.8, "aria-hidden": true, className: "col-start-1 row-start-1 size-[1.125rem] opacity-0 transition-opacity duration-(--duration-fast) ease-out group-hover/sidebar:opacity-100 group-focus-visible/toggle:opacity-100 motion-reduce:transition-none" })] }), _jsx(TooltipContent, { side: "right", children: _jsx(ShortcutHint, { label: "D\u00E9plier" }) })] })] }));
|
|
354
358
|
}
|
|
355
359
|
/**
|
|
356
360
|
* La coque commune des consoles HGDC, celle de Host. Sur ordinateur, de gauche à droite : la barre
|
|
@@ -403,6 +407,11 @@ function useContentLoading({ navigating, pending }) {
|
|
|
403
407
|
}, [active, visible]);
|
|
404
408
|
return visible;
|
|
405
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* Délai entre le clic vers un autre produit et le H : une page prérendue s'affiche avant (une ou
|
|
412
|
+
* deux images), le H n'apparaît alors jamais ; une page à charger le montre aussitôt après.
|
|
413
|
+
*/
|
|
414
|
+
export const PRODUCT_LEAVE_DELAY_MS = 100;
|
|
406
415
|
export function AppShell({ product, organization, productRail = true, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, }) {
|
|
407
416
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
408
417
|
// Barre des produits élargie : la barre latérale se plie le temps du survol (ADR 0011).
|
|
@@ -420,15 +429,27 @@ export function AppShell({ product, organization, productRail = true, accountHre
|
|
|
420
429
|
setPagePending((count) => count + 1);
|
|
421
430
|
return () => setPagePending((count) => count - 1);
|
|
422
431
|
}, []);
|
|
423
|
-
|
|
432
|
+
// Après `PRODUCT_LEAVE_DELAY_MS` seulement : une page prérendue au survol s'affiche avant, sans
|
|
433
|
+
// que le H apparaisse (ADR 0020). Sinon le H couvre le contenu jusqu'au départ de la page.
|
|
434
|
+
const leaveTimer = useRef(undefined);
|
|
435
|
+
const leave = useCallback(() => {
|
|
436
|
+
window.clearTimeout(leaveTimer.current);
|
|
437
|
+
leaveTimer.current = window.setTimeout(() => setLeaving(true), PRODUCT_LEAVE_DELAY_MS);
|
|
438
|
+
}, []);
|
|
424
439
|
useEffect(() => {
|
|
425
440
|
// Retour arrière depuis l'autre produit (cache de page) : la page revient telle quelle.
|
|
426
441
|
const onPageShow = (event) => {
|
|
427
|
-
if (event.persisted)
|
|
428
|
-
|
|
442
|
+
if (!event.persisted)
|
|
443
|
+
return;
|
|
444
|
+
window.clearTimeout(leaveTimer.current);
|
|
445
|
+
setLeaving(false);
|
|
446
|
+
delete document.documentElement.dataset.hgdcSwitching;
|
|
429
447
|
};
|
|
430
448
|
window.addEventListener("pageshow", onPageShow);
|
|
431
|
-
return () =>
|
|
449
|
+
return () => {
|
|
450
|
+
window.removeEventListener("pageshow", onPageShow);
|
|
451
|
+
window.clearTimeout(leaveTimer.current);
|
|
452
|
+
};
|
|
432
453
|
}, []);
|
|
433
454
|
const statusPageUrl = statusPage === false ? null : (statusPage ?? statusPageUrlFor(product));
|
|
434
455
|
const loading = useContentLoading({ navigating, pending: pagePending > 0 || leaving });
|
|
@@ -437,6 +458,21 @@ export function AppShell({ product, organization, productRail = true, accountHre
|
|
|
437
458
|
const hydrated = useHydrated();
|
|
438
459
|
const hasRoot = useContext(FamilyLoaderRootContext);
|
|
439
460
|
const contentRef = useRef(null);
|
|
461
|
+
const sidebarRef = useRef(null);
|
|
462
|
+
// Mémoire de la coque (ADR 0020) : le produit affiché, pour le cadre d'un chargement qui suivrait
|
|
463
|
+
// hors de la coque dans ce document, et la hauteur de sa navigation, pour le squelette de la
|
|
464
|
+
// barre latérale à la prochaine arrivée sur ce produit.
|
|
465
|
+
useIsomorphicLayoutEffect(() => {
|
|
466
|
+
document.documentElement.dataset.hgdcProduct = product;
|
|
467
|
+
}, [product]);
|
|
468
|
+
const navHeight = useRef(0);
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
const list = sidebarRef.current?.querySelector(":scope > nav > ul");
|
|
471
|
+
if (!(list instanceof HTMLElement) || list.offsetHeight === navHeight.current)
|
|
472
|
+
return;
|
|
473
|
+
navHeight.current = list.offsetHeight;
|
|
474
|
+
rememberShellNavHeight(product, list.offsetHeight);
|
|
475
|
+
});
|
|
440
476
|
useEffect(() => {
|
|
441
477
|
// Sur ordinateur, la zone de contenu défile seule : le calque couvre sa partie visible, en haut.
|
|
442
478
|
if (loading && contentRef.current)
|
|
@@ -455,5 +491,5 @@ export function AppShell({ product, organization, productRail = true, accountHre
|
|
|
455
491
|
const assistantButtonRef = useRef(null);
|
|
456
492
|
useAssistantShortcut(assistant);
|
|
457
493
|
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:
|
|
494
|
+
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", { ref: sidebarRef, "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: familyProductName(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
495
|
}
|
package/dist/family-loader.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { createContext, useContext, useEffect, useLayoutEffect, useRef } from "react";
|
|
3
3
|
import { cn } from "./cn.js";
|
|
4
4
|
import { useBackgroundOrganizationRefresh } from "./product-switch.js";
|
|
5
|
+
import { ShellFrame } from "./shell-frame.js";
|
|
5
6
|
const SIZES = { sm: 24, md: 40, lg: 64 };
|
|
6
7
|
/** Durée de la boucle du H (`styles.css`, `.family-loader [data-fill]`). */
|
|
7
8
|
export const FAMILY_LOADER_PERIOD_MS = 2000;
|
|
@@ -92,7 +93,7 @@ function FamilyMark({ px, label, className, }) {
|
|
|
92
93
|
export function FamilyLoaderHost() {
|
|
93
94
|
const ref = useRef(null);
|
|
94
95
|
useClockSync(ref);
|
|
95
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: ref, "data-slot": "family-loader-root", "aria-hidden": true, children: [_jsx("div", { "data-frame": true }), _jsx("div", { "data-stage": true, children: _jsx("span", { "data-slot": "family-loader", className: "family-loader", style: { ["--family-loader-size"]: "64px" }, children: _jsx(Mark, {}) }) })] }), _jsx("script", { dangerouslySetInnerHTML: { __html: SYNC_SCRIPT } })] }));
|
|
96
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: ref, "data-slot": "family-loader-root", "aria-hidden": true, children: [_jsx("div", { "data-frame": true, children: _jsx(ShellFrame, {}) }), _jsx("div", { "data-stage": true, children: _jsx("span", { "data-slot": "family-loader", className: "family-loader", style: { ["--family-loader-size"]: "64px" }, children: _jsx(Mark, {}) }) })] }), _jsx("script", { dangerouslySetInnerHTML: { __html: SYNC_SCRIPT } })] }));
|
|
96
97
|
}
|
|
97
98
|
/**
|
|
98
99
|
* Le chargeur du document, à poser une fois dans le `body` de la route racine, autour de l'app :
|
package/dist/org-switcher.js
CHANGED
|
@@ -4,9 +4,11 @@ import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
|
|
|
4
4
|
import UnfoldMoreIcon from "@hugeicons/core-free-icons/UnfoldMoreIcon";
|
|
5
5
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
6
6
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
|
7
|
+
import { useEffect } from "react";
|
|
7
8
|
import { Badge } from "./badge.js";
|
|
8
9
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
|
|
9
10
|
import { initials } from "./initials.js";
|
|
11
|
+
import { rememberShellOrganization } from "./shell-memory.js";
|
|
10
12
|
import { useShellPlacement } from "./shell-placement.js";
|
|
11
13
|
import { cn } from "./cn.js";
|
|
12
14
|
export const ORG_ROLE_LABEL = {
|
|
@@ -39,6 +41,13 @@ export function OrgMark({ org, className, }) {
|
|
|
39
41
|
export function OrgSwitcher({ organizations, activeId, onSelect, createHref, onCreate, className, }) {
|
|
40
42
|
const placement = useShellPlacement();
|
|
41
43
|
const active = organizations.find((org) => org.id === activeId);
|
|
44
|
+
// Retenue pour la première image du prochain produit ouvert sous la même origine (ADR 0020).
|
|
45
|
+
const activeName = active?.name;
|
|
46
|
+
const activeLogo = active?.logo;
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (activeName !== undefined)
|
|
49
|
+
rememberShellOrganization({ name: activeName, logo: activeLogo });
|
|
50
|
+
}, [activeName, activeLogo]);
|
|
42
51
|
if (!active)
|
|
43
52
|
return null;
|
|
44
53
|
// En haut de la barre des produits : la marque seule dans 40 px, le nom quand la barre s'élargit.
|
package/dist/product-rail.d.ts
CHANGED
|
@@ -2,6 +2,20 @@ import { type ReactNode } from "react";
|
|
|
2
2
|
import { type HgdcCatalogProduct } from "./products.ts";
|
|
3
3
|
/** Délai d'intention avant d'élargir la barre au survol : un passage de souris ne l'ouvre pas. */
|
|
4
4
|
export declare const PRODUCT_RAIL_OPEN_DELAY = 150;
|
|
5
|
+
/**
|
|
6
|
+
* La place de l'organisation tant que le produit ne l'a pas (ses données arrivent) : la marque de
|
|
7
|
+
* la dernière organisation affichée sous cette origine, au pixel près de celle d'`OrgSwitcher`
|
|
8
|
+
* (initiales ou logo, posés sur `<html>` par `sidebarInitScript` avant la première peinture),
|
|
9
|
+
* sinon un carré de squelette de la même taille. Rien ne bouge quand la vraie arrive.
|
|
10
|
+
*/
|
|
11
|
+
export declare function OrgPlaceholder(): React.JSX.Element;
|
|
12
|
+
/**
|
|
13
|
+
* La barre des produits du cadre de chargement (`FamilyLoaderRoot`, hors de la coque) : la même
|
|
14
|
+
* colonne que `ProductRail` repliée, `OrgPlaceholder` en haut, les mêmes pastilles, sans liens. Le
|
|
15
|
+
* produit courant est marqué par le CSS (`<html data-hgdc-product>`, posé par
|
|
16
|
+
* `sidebarInitScript`) : au rendu serveur, le kit ne connaît pas l'adresse.
|
|
17
|
+
*/
|
|
18
|
+
export declare function ProductRailFrame(): React.JSX.Element;
|
|
5
19
|
/**
|
|
6
20
|
* La barre des produits de la famille, tout à gauche de la coque sur ordinateur : organisation en
|
|
7
21
|
* haut, Account, puis les pastilles des produits. 56 px de large ; au survol (après un court délai
|
|
@@ -14,7 +28,10 @@ export declare const PRODUCT_RAIL_OPEN_DELAY = 150;
|
|
|
14
28
|
export declare function ProductRail({ current, organization, accountHref, catalog, onExpandedChange, }: {
|
|
15
29
|
/** Le mot du produit affiché (« host ») : marqué en teinte. */
|
|
16
30
|
current?: string | undefined;
|
|
17
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* `OrgSwitcher`, rendu compact. Absent (`undefined` : les données du produit arrivent), sa
|
|
33
|
+
* place est tenue par `OrgPlaceholder`. `null` : pas d'organisation, rien en haut.
|
|
34
|
+
*/
|
|
18
35
|
organization?: ReactNode;
|
|
19
36
|
/** Adresse d'Account (un Account local en développement). */
|
|
20
37
|
accountHref?: string | undefined;
|
package/dist/product-rail.js
CHANGED
|
@@ -1,16 +1,54 @@
|
|
|
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";
|
|
6
|
+
import { markProductSwitch, takeProductSwitch } from "./shell-memory.js";
|
|
5
7
|
import { ShellPlacementContext, useLeaveOnClick } from "./shell-placement.js";
|
|
6
8
|
import { cn } from "./cn.js";
|
|
7
9
|
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
10
|
+
const noSubscription = () => () => { };
|
|
11
|
+
/** `location.origin` après l'hydratation, `undefined` au rendu serveur (liens absolus d'abord). */
|
|
12
|
+
function usePageOrigin() {
|
|
13
|
+
return useSyncExternalStore(noSubscription, () => window.location.origin, () => undefined);
|
|
14
|
+
}
|
|
8
15
|
/** Délai d'intention avant d'élargir la barre au survol : un passage de souris ne l'ouvre pas. */
|
|
9
16
|
export const PRODUCT_RAIL_OPEN_DELAY = 150;
|
|
17
|
+
/** Boîte d'une entrée de la barre : la même pour un lien et pour sa place dans le cadre de chargement. */
|
|
18
|
+
const PRODUCT_ITEM = "flex h-10 items-center gap-3 rounded-xl px-2 text-shell-muted coarse:h-11";
|
|
19
|
+
/** Colonne de la barre (56 px) et son contenu : les mêmes pour la barre et pour le cadre de chargement. */
|
|
20
|
+
const RAIL_COLUMN = "hidden w-14 shrink-0 overflow-hidden lg:block";
|
|
21
|
+
const RAIL_INNER = "group/rail flex h-full w-full flex-col gap-2 overflow-hidden bg-shell-rail px-2 py-3 text-shell-foreground";
|
|
22
|
+
const RAIL_LIST = "grid min-h-0 gap-1 overflow-x-hidden overflow-y-auto";
|
|
23
|
+
function ProductLabel({ entry }) {
|
|
24
|
+
return (_jsxs(_Fragment, { 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] })] }));
|
|
25
|
+
}
|
|
10
26
|
function ProductLink({ entry }) {
|
|
11
27
|
// Chaque produit s'ouvre en document complet : la coque montre le H dès le clic.
|
|
12
28
|
const leaveOnClick = useLeaveOnClick();
|
|
13
|
-
return (_jsx("li", { children:
|
|
29
|
+
return (_jsx("li", { children: _jsx("a", { href: entry.href, onClick: leaveOnClick, ...(entry.sameOrigin ? { [PRODUCT_PRERENDER_ATTRIBUTE]: "" } : {}), "aria-current": entry.current ? "page" : undefined, className: cn(PRODUCT_ITEM, "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(ProductLabel, { entry: entry }) }) }));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* La place de l'organisation tant que le produit ne l'a pas (ses données arrivent) : la marque de
|
|
33
|
+
* la dernière organisation affichée sous cette origine, au pixel près de celle d'`OrgSwitcher`
|
|
34
|
+
* (initiales ou logo, posés sur `<html>` par `sidebarInitScript` avant la première peinture),
|
|
35
|
+
* sinon un carré de squelette de la même taille. Rien ne bouge quand la vraie arrive.
|
|
36
|
+
*/
|
|
37
|
+
export function OrgPlaceholder() {
|
|
38
|
+
return (_jsx("div", { "data-slot": "org-placeholder", "aria-hidden": true, className: "flex h-10 w-full shrink-0 items-center px-1 coarse:h-11", children: _jsx("span", { "data-slot": "org-placeholder-mark", className: "grid size-8 shrink-0 place-items-center rounded-lg text-xs font-semibold uppercase" }) }));
|
|
39
|
+
}
|
|
40
|
+
function RailDivider() {
|
|
41
|
+
return _jsx("div", { "aria-hidden": true, className: "mx-2 h-px shrink-0 bg-foreground/8" });
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* La barre des produits du cadre de chargement (`FamilyLoaderRoot`, hors de la coque) : la même
|
|
45
|
+
* colonne que `ProductRail` repliée, `OrgPlaceholder` en haut, les mêmes pastilles, sans liens. Le
|
|
46
|
+
* produit courant est marqué par le CSS (`<html data-hgdc-product>`, posé par
|
|
47
|
+
* `sidebarInitScript`) : au rendu serveur, le kit ne connaît pas l'adresse.
|
|
48
|
+
*/
|
|
49
|
+
export function ProductRailFrame() {
|
|
50
|
+
const { products, account } = productRailEntries();
|
|
51
|
+
return (_jsx("div", { "data-slot": "shell-frame-rail", className: RAIL_COLUMN, children: _jsxs("div", { className: RAIL_INNER, children: [_jsx(OrgPlaceholder, {}), _jsx(RailDivider, {}), _jsx("ul", { className: RAIL_LIST, children: [account, ...products].map((entry) => (_jsx("li", { children: _jsx("span", { "data-product": entry.badge, className: PRODUCT_ITEM, children: _jsx(ProductLabel, { entry: entry }) }) }, entry.badge))) })] }) }));
|
|
14
52
|
}
|
|
15
53
|
/**
|
|
16
54
|
* La barre des produits de la famille, tout à gauche de la coque sur ordinateur : organisation en
|
|
@@ -35,7 +73,8 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
|
|
|
35
73
|
onExpandedChange?.(expanded);
|
|
36
74
|
}, [expanded, onExpandedChange]);
|
|
37
75
|
const ref = useRef(null);
|
|
38
|
-
const
|
|
76
|
+
const pageOrigin = usePageOrigin();
|
|
77
|
+
const { products, account } = productRailEntries({ current, accountHref, catalog, pageOrigin });
|
|
39
78
|
// Écouteurs natifs : la barre est un repère `nav`, pas un contrôle ; c'est son survol, le focus
|
|
40
79
|
// de ses liens et les menus qu'elle ouvre qui l'élargissent.
|
|
41
80
|
useEffect(() => {
|
|
@@ -43,17 +82,21 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
|
|
|
43
82
|
if (!rail)
|
|
44
83
|
return;
|
|
45
84
|
let timer;
|
|
85
|
+
// Arrivée depuis la barre d'un autre produit, la souris restée dessus : la barre ne s'élargit
|
|
86
|
+
// pas sous elle, il faut d'abord en sortir (ADR 0020).
|
|
87
|
+
let arrivedUnderPointer = takeProductSwitch();
|
|
46
88
|
const cancel = () => {
|
|
47
89
|
window.clearTimeout(timer);
|
|
48
90
|
timer = undefined;
|
|
49
91
|
};
|
|
50
92
|
const onEnter = (event) => {
|
|
51
|
-
if (event.pointerType !== "mouse")
|
|
93
|
+
if (event.pointerType !== "mouse" || arrivedUnderPointer)
|
|
52
94
|
return;
|
|
53
95
|
cancel();
|
|
54
96
|
timer = window.setTimeout(() => setHovered(true), PRODUCT_RAIL_OPEN_DELAY);
|
|
55
97
|
};
|
|
56
98
|
const onLeave = () => {
|
|
99
|
+
arrivedUnderPointer = false;
|
|
57
100
|
cancel();
|
|
58
101
|
setHovered(false);
|
|
59
102
|
};
|
|
@@ -74,6 +117,21 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
|
|
|
74
117
|
if (!(event.relatedTarget instanceof Node) || !rail.contains(event.relatedTarget))
|
|
75
118
|
setFocused(false);
|
|
76
119
|
};
|
|
120
|
+
// Un produit choisi : la barre se replie comme un menu qui se ferme, pendant que l'autre produit
|
|
121
|
+
// s'ouvre. La page d'arrivée la montre repliée : rien ne bouge au passage (ADR 0020).
|
|
122
|
+
const onClick = (event) => {
|
|
123
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
124
|
+
return;
|
|
125
|
+
if (!(event.target instanceof Element) || !event.target.closest("a[href]"))
|
|
126
|
+
return;
|
|
127
|
+
// Repliée d'un coup, sans transition (`data-hgdc-switching`) : l'image de départ de la
|
|
128
|
+
// transition de vue montre déjà la barre telle que la page d'arrivée la peint.
|
|
129
|
+
document.documentElement.dataset.hgdcSwitching = "";
|
|
130
|
+
markProductSwitch();
|
|
131
|
+
cancel();
|
|
132
|
+
setHovered(false);
|
|
133
|
+
setFocused(false);
|
|
134
|
+
};
|
|
77
135
|
const onKey = (event) => {
|
|
78
136
|
if (event.key !== "Escape")
|
|
79
137
|
return;
|
|
@@ -94,6 +152,7 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
|
|
|
94
152
|
rail.addEventListener("focusin", onFocusIn);
|
|
95
153
|
rail.addEventListener("focusout", onFocusOut);
|
|
96
154
|
rail.addEventListener("keydown", onKey);
|
|
155
|
+
rail.addEventListener("click", onClick);
|
|
97
156
|
return () => {
|
|
98
157
|
cancel();
|
|
99
158
|
observer.disconnect();
|
|
@@ -104,12 +163,13 @@ export function ProductRail({ current, organization, accountHref, catalog, onExp
|
|
|
104
163
|
rail.removeEventListener("focusin", onFocusIn);
|
|
105
164
|
rail.removeEventListener("focusout", onFocusOut);
|
|
106
165
|
rail.removeEventListener("keydown", onKey);
|
|
166
|
+
rail.removeEventListener("click", onClick);
|
|
107
167
|
};
|
|
108
168
|
}, []);
|
|
109
|
-
return (_jsx("div", { "data-slot": "product-rail", "data-expanded": expanded ? "" : undefined, className: cn(
|
|
169
|
+
return (_jsx("div", { "data-slot": "product-rail", "data-expanded": expanded ? "" : undefined, className: cn(RAIL_COLUMN,
|
|
110
170
|
// Même durée et même courbe que la barre latérale qui se plie : la somme des deux largeurs
|
|
111
171
|
// reste constante (56 + 256 = 248 + 64), la page ne bouge pas.
|
|
112
|
-
"transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none", "data-expanded:w-62 folded:data-expanded:w-55"), children: _jsxs("nav", { ref: ref, "aria-label": "Produits", "data-expanded": expanded ? "" : undefined, className:
|
|
172
|
+
"transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none", "data-expanded:w-62 folded:data-expanded:w-55"), children: _jsxs("nav", { ref: ref, "aria-label": "Produits", "data-expanded": expanded ? "" : undefined, className: RAIL_INNER, children: [organization === undefined ? (_jsxs(_Fragment, { children: [_jsx(OrgPlaceholder, {}), _jsx(RailDivider, {})] })) : organization ? (_jsxs(_Fragment, { children: [_jsx(ShellPlacementContext, { value: "product-rail", children: organization }), _jsx(RailDivider, {})] })) : null, _jsxs("ul", { className: RAIL_LIST, children: [_jsx(ProductLink, { entry: account }), products.map((entry) => (_jsx(ProductLink, { entry: entry }, entry.badge)))] })] }) }));
|
|
113
173
|
}
|
|
114
174
|
function focusVisible(target) {
|
|
115
175
|
if (!(target instanceof Element))
|
package/dist/product-switch.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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;
|