@hgdc/ui 0.47.0 → 0.48.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.
@@ -1,131 +1,26 @@
1
- import { type IconSvgElement } from "@hugeicons/react";
2
1
  import { type ReactNode } from "react";
2
+ import type { ProductView } from "./addresses.ts";
3
3
  import type { FamilyProduct } from "./family-svg.ts";
4
- import { type ProductView } from "./addresses.ts";
4
+ import { type AppShellAssistant } from "./shell-assistant.tsx";
5
+ import { type AppShellLinks } from "./shell-links.tsx";
6
+ export { sidebarInitScript, toggleSidebar, useSidebarRail } from "./sidebar-rail.ts";
7
+ export { PRODUCT_LEAVE_DELAY_MS } from "./use-product-leave.ts";
8
+ export { useAppShell } from "./shell-context.ts";
9
+ export { SidebarNav, SidebarNavBack, SidebarNavItem } from "./sidebar-nav.tsx";
10
+ export { SidebarNavGroup } from "./sidebar-nav-group.tsx";
11
+ export type { AppShellLinks, ShellLink } from "./shell-links.tsx";
12
+ export type { AppShellAssistant } from "./shell-assistant.tsx";
5
13
  /**
6
- * À poser dans le `<head>`, avec `themeInitScript` : avant la première peinture, il remet le
7
- * rail (la variante CSS `rail:` le lit, rien ne saute au chargement) et la mémoire de la coque
8
- * partagée sous la même origine (`shell-memory` : produit de l'adresse, dernière organisation,
9
- * hauteur de la navigation), que le cadre du chargeur et `OrgPlaceholder` lisent (ADR 0020).
10
- */
11
- export declare const sidebarInitScript: string;
12
- /** La barre latérale est-elle réduite en rail ? L'état vit sur `<html data-sidebar="rail">`. */
13
- export declare function useSidebarRail(): boolean;
14
- export declare function toggleSidebar(): void;
15
- /** Pour fermer le tiroir mobile depuis un élément de la barre (un sélecteur, un bouton). */
16
- export declare function useAppShell(): {
17
- closeMobileNav: () => void;
18
- };
19
- /**
20
- * La navigation du produit : 4 à 7 entrées, à plat. Une navigation imbriquée (l'espace d'une page,
21
- * d'un projet) commence par `SidebarNavBack` et range ses entrées dans des `SidebarNavGroup`.
22
- */
23
- export declare function SidebarNav({ label, className, children, }: {
24
- /** Nom de la navigation pour les lecteurs d'écran : « Projet », « Organisation ». */
25
- label: string;
26
- className?: string;
27
- children: ReactNode;
28
- }): React.JSX.Element;
29
- /**
30
- * Une entrée de la barre. « Vous êtes ici » par `active` (ou `data-status="active"`, que pose le
31
- * `Link` de TanStack Router) : teinte de la coque, jamais l'accent. En rail, l'icône seule, et le
32
- * nom en infobulle.
33
- */
34
- export declare function SidebarNavItem({ icon, label, active, href, render, }: {
35
- icon: IconSvgElement;
36
- label: string;
37
- active?: boolean;
38
- href?: string;
39
- /** Lien du routeur : `render={<Link to="…" />}`. */
40
- render?: React.ReactElement;
41
- }): React.JSX.Element;
42
- /**
43
- * Retour au niveau du dessus, en tête d'une navigation imbriquée : « Retour aux pages » quand la
44
- * barre montre l'espace d'une page, d'un projet. Premier enfant de `SidebarNav`, au-dessus des
45
- * groupes. Une pastille teintée, flèche à gauche, jamais « vous êtes ici ». En rail, la flèche seule
46
- * et le texte en infobulle.
47
- */
48
- export declare function SidebarNavBack({ label, href, render, }: {
49
- /** « Retour aux pages » */
50
- label: string;
51
- href?: string;
52
- /** Lien du routeur : `render={<Link to="…" />}`. */
53
- render?: React.ReactElement;
54
- }): React.JSX.Element;
55
- /**
56
- * Un groupe titré de la barre : « Suivi », « Réglages ». Ses `SidebarNavItem` en enfants. Le titre
57
- * nomme la liste pour les lecteurs d'écran ; en rail, il cède la place à un trait.
14
+ * La coque commune des consoles HGDC, celle de Host. Sur ordinateur, de gauche à droite : la barre
15
+ * des produits de la famille (organisation, Account, pastilles ; élargie au survol à la place
16
+ * de la barre latérale, qui se plie le temps du survol), la barre latérale du produit (sa marque, sa navigation, le compte en bas),
17
+ * réductible en rail (`Ctrl+B`, mémorisé), et la page sur une carte. Sur téléphone : une barre en
18
+ * haut, le menu dans un tiroir qui monte du bas et se referme quand on choisit une page ; les
19
+ * produits restent dans le menu de l'avatar.
58
20
  *
59
- * Avec `collapsible`, le titre devient une entrée à flèche qui déroule ses rubriques : fermé par
60
- * défaut, ouvert dès qu'une de ses rubriques est la page ouverte, puis tel que la personne l'a
61
- * laissé (mémorisé par navigateur). En rail, ses rubriques restent visibles, sous un trait.
62
- */
63
- export declare function SidebarNavGroup(props: {
64
- label: string;
65
- children: ReactNode;
66
- /** Le titre déroule ou replie le groupe. */
67
- collapsible?: false;
68
- }): React.JSX.Element;
69
- export declare function SidebarNavGroup(props: {
70
- label: string;
71
- children: ReactNode;
72
- collapsible: true;
73
- /** Icône de l'entrée qui déroule le groupe, comme une rubrique. */
74
- icon?: IconSvgElement;
75
- /**
76
- * Une rubrique du groupe est la page ouverte : le groupe s'ouvre, dès le rendu serveur. Sans cette
77
- * valeur, la coque le lit dans les liens (`aria-current="page"`, `data-status="active"`) après
78
- * l'hydratation.
79
- */
80
- active?: boolean;
81
- /** Ouvert tant que la personne n'a rien choisi. Fermé par défaut. */
82
- defaultOpen?: boolean;
83
- /** Clé de mémorisation, par défaut le titre, propre au produit de la coque : à donner quand deux groupes portent le même. */
84
- storageKey?: string;
85
- }): React.JSX.Element;
86
- /**
87
- * Un accès commun de la barre : une adresse, ou un lien du routeur (`{ render: <Link to="/settings" /> }`,
88
- * dont le `data-status="active"` marque la page ouverte), ou une adresse avec `active`.
89
- */
90
- export type ShellLink = string | {
91
- href?: string;
92
- render?: React.ReactElement;
93
- active?: boolean;
94
- };
95
- /** Les trois accès que la coque pose juste au-dessus du compte, toujours dans cet ordre. */
96
- export type AppShellLinks = {
97
- /** Documentation du produit. */
98
- documentation?: ShellLink;
99
- /** Réglages du produit ou de l'organisation. */
100
- settings?: ShellLink;
101
- /** État du service : la page de statut de la coque (`statusPage`) par défaut, `false` pour le retirer. */
102
- status?: ShellLink | false;
103
- };
104
- /**
105
- * L'assistant IA du produit. La coque pose son bouton juste à côté de la recherche et l'ouvre dans
106
- * une colonne à droite de la page, sur le fond de l'application : la page se rétrécit et se
107
- * réorganise, jamais recouverte. Sous 1280 px, là où les deux ne tiennent plus côte à côte, une
108
- * vue dédiée plein écran avec « Retour à la page ». La page reste montée dans les deux cas, et le
109
- * panneau, une fois ouvert, aussi : rien ne se perd en fermant ou en changeant de largeur.
110
- */
111
- export type AppShellAssistant = {
112
- open: boolean;
113
- onOpenChange: (open: boolean) => void;
114
- /**
115
- * Le corps de l'assistant : conversation et saisie (`@hgdc/ui/assistant-chat`). La coque pose le
116
- * titre, la fermeture et le retour à la page ; le panneau occupe la hauteur restante et défile.
117
- */
118
- panel: ReactNode;
119
- /** Nom du bouton et titre du panneau : « Assistant » par défaut. */
120
- label?: string;
121
- /** Lettre du raccourci `Ctrl` (`⌘`) + lettre qui ouvre et ferme l'assistant (`"j"`). Aucun par défaut. */
122
- shortcut?: string;
123
- };
124
- /**
125
- * Délai entre le clic vers un autre produit et le H : une page prérendue s'affiche avant (une ou
126
- * deux images), le H n'apparaît alors jamais ; une page à charger le montre aussitôt après.
21
+ * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
22
+ * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
127
23
  */
128
- export declare const PRODUCT_LEAVE_DELAY_MS = 100;
129
24
  export declare function AppShell({ product, organization, productRail: productRailOption, view: viewOption, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, }: {
130
25
  /** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
131
26
  product: FamilyProduct;
package/dist/app-shell.js CHANGED
@@ -1,379 +1,33 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import Activity01Icon from "@hugeicons/core-free-icons/Activity01Icon";
3
- import AiMagicIcon from "@hugeicons/core-free-icons/AiMagicIcon";
4
- import ArrowLeft01Icon from "@hugeicons/core-free-icons/ArrowLeft01Icon";
5
- import ArrowRight01Icon from "@hugeicons/core-free-icons/ArrowRight01Icon";
6
- import BookOpen01Icon from "@hugeicons/core-free-icons/BookOpen01Icon";
7
- import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
8
- import LinkSquare02Icon from "@hugeicons/core-free-icons/LinkSquare02Icon";
9
2
  import Menu01Icon from "@hugeicons/core-free-icons/Menu01Icon";
10
- import PanelLeftCloseIcon from "@hugeicons/core-free-icons/PanelLeftCloseIcon";
11
- import PanelLeftOpenIcon from "@hugeicons/core-free-icons/PanelLeftOpenIcon";
12
- import Settings01Icon from "@hugeicons/core-free-icons/Settings01Icon";
13
3
  import { HugeiconsIcon } from "@hugeicons/react";
14
- import { useRender } from "@base-ui/react/use-render";
15
- import { createContext, Suspense, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
4
+ import { Suspense, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
16
5
  import { CommandMenuButton } from "./command-menu.js";
17
6
  import { Drawer, DrawerContent, DrawerTitle } from "./drawer.js";
18
7
  import { FamilyLoaderHost, FamilyLoaderRootContext } from "./family-loader.js";
19
- import { isApplePlatform, Kbd, KbdGroup } from "./kbd.js";
20
- import { CONTENT_LOADING_LAYER, ContentLoadingContext, PAGE_PENDING_MIN_MS, PAGE_PENDING_MS, PagePending, } from "./page-pending.js";
21
- import { soloHref } from "./addresses.js";
8
+ import { CONTENT_LOADING_LAYER, ContentLoadingContext, PagePending } from "./page-pending.js";
22
9
  import { ProductBadge } from "./product-badge.js";
23
10
  import { productBarShown, useProductBarPreference, useProductBarSync } from "./product-bar.js";
24
- import { otherViewHref, otherViewLabel } from "./products.js";
25
11
  import { ProductRail } from "./product-rail.js";
26
12
  import { useProductSwitch } from "./product-switch.js";
27
- import { ProductRailShownContext, ShellLeaveContext, StatusPageContext } from "./shell-placement.js";
28
- import { familyProductName, SIDEBAR_BRAND, SIDEBAR_BRAND_NAME, SIDEBAR_HEAD } from "./shell-frame.js";
29
- import { rememberShellNavHeight, shellBootScript } from "./shell-memory.js";
30
- import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
31
13
  import { cn } from "./cn.js";
32
14
  import { ServiceStatus, statusPageUrlFor } from "./service-status.js";
15
+ import { AssistantButton, AssistantPanel, ASSISTANT_SIDE_BY_SIDE, useAssistantShortcut, } from "./shell-assistant.js";
16
+ import { ShellContext } from "./shell-context.js";
17
+ import { familyProductName } from "./shell-frame.js";
18
+ import { OtherViewLink, ShellCommonLinks } from "./shell-links.js";
19
+ import { rememberShellNavHeight } from "./shell-memory.js";
20
+ import { ProductRailShownContext, ShellLeaveContext, StatusPageContext } from "./shell-placement.js";
21
+ import { SidebarHead } from "./sidebar-head.js";
22
+ import { useToggleShortcut } from "./sidebar-rail.js";
23
+ import { useContentLoading, useHydrated, useIsomorphicLayoutEffect } from "./use-content-loading.js";
24
+ import { useProductLeave } from "./use-product-leave.js";
33
25
  import { useMediaQuery } from "./use-media-query.js";
34
- // ── Rail : la barre latérale réduite aux icônes, mémorisée par navigateur ──────────────
35
- const RAIL_KEY = "sidebar";
36
- const RAIL_EVENT = "sidebarchange";
37
- /**
38
- * À poser dans le `<head>`, avec `themeInitScript` : avant la première peinture, il remet le
39
- * rail (la variante CSS `rail:` le lit, rien ne saute au chargement) et la mémoire de la coque
40
- * partagée sous la même origine (`shell-memory` : produit de l'adresse, dernière organisation,
41
- * hauteur de la navigation), que le cadre du chargeur et `OrgPlaceholder` lisent (ADR 0020).
42
- */
43
- export const sidebarInitScript = shellBootScript();
44
- function subscribeRail(onChange) {
45
- window.addEventListener(RAIL_EVENT, onChange);
46
- return () => window.removeEventListener(RAIL_EVENT, onChange);
47
- }
48
- /** La barre latérale est-elle réduite en rail ? L'état vit sur `<html data-sidebar="rail">`. */
49
- export function useSidebarRail() {
50
- return useSyncExternalStore(subscribeRail, () => document.documentElement.dataset.sidebar === "rail", () => false);
51
- }
52
- export function toggleSidebar() {
53
- const root = document.documentElement;
54
- const rail = root.dataset.sidebar !== "rail";
55
- if (rail)
56
- root.dataset.sidebar = "rail";
57
- else
58
- delete root.dataset.sidebar;
59
- try {
60
- if (rail)
61
- localStorage.setItem(RAIL_KEY, "rail");
62
- else
63
- localStorage.removeItem(RAIL_KEY);
64
- }
65
- catch {
66
- // Stockage bloqué : le choix vaut pour cette page seulement.
67
- }
68
- window.dispatchEvent(new Event(RAIL_EVENT));
69
- }
70
- /** `Ctrl+B` (`⌘B`) réduit ou déplie la barre latérale, hors des champs de saisie. */
71
- function useToggleShortcut() {
72
- useEffect(() => {
73
- const onKey = (event) => {
74
- if (event.key.toLowerCase() !== "b" || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey)
75
- return;
76
- const target = event.target;
77
- if (target instanceof Element && target.closest("input, textarea, select, [contenteditable=true]"))
78
- return;
79
- event.preventDefault();
80
- toggleSidebar();
81
- };
82
- document.addEventListener("keydown", onKey);
83
- return () => document.removeEventListener("keydown", onKey);
84
- }, []);
85
- }
86
- // ── Navigation ──────────────────────────────────────────────────────────────────────
87
- const ShellContext = createContext({
88
- closeMobileNav: () => { },
89
- });
90
- /** Pour fermer le tiroir mobile depuis un élément de la barre (un sélecteur, un bouton). */
91
- export function useAppShell() {
92
- return useContext(ShellContext);
93
- }
94
- /**
95
- * La navigation du produit : 4 à 7 entrées, à plat. Une navigation imbriquée (l'espace d'une page,
96
- * d'un projet) commence par `SidebarNavBack` et range ses entrées dans des `SidebarNavGroup`.
97
- */
98
- export function SidebarNav({ label, className, children, }) {
99
- return (_jsx("nav", { "aria-label": label, className: cn("min-h-0 flex-1 overflow-y-auto px-3 py-2", className), children: _jsx("ul", { className: "grid gap-0.5", children: children }) }));
100
- }
101
- /**
102
- * Une entrée de la barre. « Vous êtes ici » par `active` (ou `data-status="active"`, que pose le
103
- * `Link` de TanStack Router) : teinte de la coque, jamais l'accent. En rail, l'icône seule, et le
104
- * nom en infobulle.
105
- */
106
- export function SidebarNavItem({ icon, label, active, href, render, }) {
107
- const rail = useSidebarRail();
108
- const { closeMobileNav } = useAppShell();
109
- const link = useRender({
110
- defaultTagName: "a",
111
- render,
112
- props: {
113
- href,
114
- "aria-current": active ? "page" : undefined,
115
- onClick: closeMobileNav,
116
- className: cn("flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-[current=page]:bg-muted aria-[current=page]:text-foreground data-[status=active]:bg-muted data-[status=active]:text-foreground", "min-h-11 lg:min-h-9 coarse:min-h-11 lg:gap-2.5 lg:py-2 lg:text-shell-muted lg:hover:bg-shell-hover lg:hover:text-shell-foreground", "lg:aria-[current=page]:bg-shell-active lg:aria-[current=page]:text-shell-foreground lg:data-[status=active]:bg-shell-active lg:data-[status=active]:text-shell-foreground", "lg:rail:justify-center lg:rail:px-0"),
117
- children: (_jsxs(_Fragment, { children: [_jsx(HugeiconsIcon, { icon: icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-5 shrink-0 lg:size-[1.125rem]" }), _jsx("span", { className: "truncate lg:rail:sr-only", children: label })] })),
118
- },
119
- });
120
- return (_jsx("li", { children: _jsxs(Tooltip, { disabled: !rail, children: [_jsx(TooltipTrigger, { render: link }), _jsx(TooltipContent, { side: "right", children: label })] }) }));
121
- }
122
- /**
123
- * Retour au niveau du dessus, en tête d'une navigation imbriquée : « Retour aux pages » quand la
124
- * barre montre l'espace d'une page, d'un projet. Premier enfant de `SidebarNav`, au-dessus des
125
- * groupes. Une pastille teintée, flèche à gauche, jamais « vous êtes ici ». En rail, la flèche seule
126
- * et le texte en infobulle.
127
- */
128
- export function SidebarNavBack({ label, href, render, }) {
129
- const rail = useSidebarRail();
130
- const { closeMobileNav } = useAppShell();
131
- const link = useRender({
132
- defaultTagName: "a",
133
- render,
134
- props: {
135
- href,
136
- onClick: closeMobileNav,
137
- "data-slot": "sidebar-nav-back",
138
- className: cn(
139
- // Jamais marqué actif : le lien du routeur pose `data-status="active"` sur un parent ouvert.
140
- "group/back flex items-center gap-2 rounded-xl bg-muted/70 px-3 py-2.5 text-sm font-medium whitespace-nowrap text-foreground outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50", "min-h-11 lg:min-h-9 coarse:min-h-11 lg:py-2 lg:bg-shell-hover lg:text-shell-foreground lg:hover:bg-shell-active", "lg:rail:justify-center lg:rail:px-0"),
141
- children: (_jsxs(_Fragment, { children: [_jsx(HugeiconsIcon, { icon: ArrowLeft01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground transition-transform duration-(--duration-fast) ease-out group-hover/back:-translate-x-0.5 motion-reduce:transition-none lg:size-4 lg:text-shell-muted" }), _jsx("span", { className: "truncate lg:rail:sr-only", children: label })] })),
142
- },
143
- });
144
- return (_jsx("li", { className: "pb-2", children: _jsxs(Tooltip, { disabled: !rail, children: [_jsx(TooltipTrigger, { render: link }), _jsx(TooltipContent, { side: "right", children: label })] }) }));
145
- }
146
- // ── Groupes repliables : ouverts ou fermés par la personne, mémorisés par navigateur ──────
147
- const GROUP_KEY_PREFIX = "sidebar-group:";
148
- const GROUP_EVENT = "sidebargroupchange";
149
- function subscribeGroups(onChange) {
150
- window.addEventListener(GROUP_EVENT, onChange);
151
- window.addEventListener("storage", onChange);
152
- return () => {
153
- window.removeEventListener(GROUP_EVENT, onChange);
154
- window.removeEventListener("storage", onChange);
155
- };
156
- }
157
- function readGroup(key) {
158
- try {
159
- const value = localStorage.getItem(GROUP_KEY_PREFIX + key);
160
- return value === "open" ? true : value === "closed" ? false : null;
161
- }
162
- catch {
163
- return null;
164
- }
165
- }
166
- function writeGroup(key, open) {
167
- try {
168
- localStorage.setItem(GROUP_KEY_PREFIX + key, open ? "open" : "closed");
169
- }
170
- catch {
171
- // Stockage bloqué : le choix vaut pour cette page seulement.
172
- }
173
- window.dispatchEvent(new Event(GROUP_EVENT));
174
- }
175
- /** Une entrée de la liste est-elle la page ouverte ? Suit les attributs que pose le routeur. */
176
- function useActiveDescendant(ref) {
177
- const [active, setActive] = useState(false);
178
- useLayoutEffect(() => {
179
- const element = ref.current;
180
- if (!element)
181
- return;
182
- const read = () => setActive(element.querySelector('[aria-current="page"], [data-status="active"]') !== null);
183
- read();
184
- const observer = new MutationObserver(read);
185
- observer.observe(element, {
186
- subtree: true,
187
- childList: true,
188
- attributes: true,
189
- attributeFilter: ["aria-current", "data-status"],
190
- });
191
- return () => observer.disconnect();
192
- }, [ref]);
193
- return active;
194
- }
195
- export function SidebarNavGroup(props) {
196
- if (props.collapsible)
197
- return _jsx(CollapsibleNavGroup, { ...props });
198
- return _jsx(TitledNavGroup, { label: props.label, children: props.children });
199
- }
200
- function CollapsibleNavGroup({ label, children, icon, active, defaultOpen = false, storageKey, }) {
201
- // Propre au produit : sous une origine partagée (`app.hgdc.ch/<produit>`), `localStorage` est
202
- // commun à tous. Le thème et la barre réduite le sont volontairement (`theme`, `sidebar`), un
203
- // groupe « Réglages » de Mail et de Host ne l'est pas (ADR 0020).
204
- const { product } = useContext(ShellContext);
205
- const key = product ? `${product}:${storageKey ?? label}` : (storageKey ?? label);
206
- const buttonId = useId();
207
- const listId = useId();
208
- const listRef = useRef(null);
209
- const stored = useSyncExternalStore(subscribeGroups, () => readGroup(key), () => null);
210
- const activeInDom = useActiveDescendant(listRef);
211
- const current = active ?? activeInDom;
212
- // Refermé par la personne alors qu'une de ses rubriques est ouverte : il le reste jusqu'à ce
213
- // qu'elle en sorte. La navigation suivante vers une rubrique du groupe le rouvre.
214
- const [dismissed, setDismissed] = useState(false);
215
- const [wasCurrent, setWasCurrent] = useState(current);
216
- if (wasCurrent !== current) {
217
- setWasCurrent(current);
218
- setDismissed(false);
219
- }
220
- const open = current ? !dismissed : (stored ?? defaultOpen);
221
- // Pas d'animation au premier rendu : l'état lu dans le navigateur s'applique sans glisser.
222
- const [ready, setReady] = useState(false);
223
- useEffect(() => {
224
- const frame = requestAnimationFrame(() => setReady(true));
225
- return () => cancelAnimationFrame(frame);
226
- }, []);
227
- const toggle = () => {
228
- const next = !open;
229
- if (current)
230
- setDismissed(!next);
231
- writeGroup(key, next);
232
- };
233
- return (_jsxs("li", { "data-slot": "sidebar-nav-group", "data-collapsible": "", "data-open": open ? "" : undefined, className: "pt-1 first:pt-0", children: [_jsx("span", { "aria-hidden": true, className: "mx-auto mt-3 mb-2 hidden h-px w-6 bg-border lg:rail:block" }), _jsxs("button", { type: "button", id: buttonId, "aria-expanded": open, "aria-controls": listId, onClick: toggle, className: cn("group/disclosure flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium whitespace-nowrap text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "min-h-11 lg:min-h-9 coarse:min-h-11 lg:gap-2.5 lg:py-2 lg:text-shell-muted lg:hover:bg-shell-hover lg:hover:text-shell-foreground",
234
- // Une rubrique du groupe est ouverte et le groupe est replié : l'entrée le dit, sans fond.
235
- current && !open && "text-foreground lg:text-shell-foreground", "lg:rail:hidden"), children: [icon ? (_jsx(HugeiconsIcon, { icon: icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-5 shrink-0 lg:size-[1.125rem]" })) : null, _jsx("span", { className: "truncate", children: label }), _jsx(HugeiconsIcon, { icon: ArrowRight01Icon, strokeWidth: 2, "aria-hidden": true, className: cn("ml-auto size-4 shrink-0 opacity-70 transition-transform duration-(--duration-base) ease-ios motion-reduce:transition-none lg:size-3.5", open && "rotate-90") })] }), _jsx("div", { "data-slot": "sidebar-nav-group-panel", className: cn("grid", open ? "visible grid-rows-[1fr]" : "invisible grid-rows-[0fr]", ready &&
236
- "transition-[grid-template-rows,visibility] duration-(--duration-base) ease-ios motion-reduce:transition-none",
237
- // En rail, les rubriques restent là, sous le trait : seules leurs icônes se voient.
238
- "lg:rail:visible lg:rail:grid-rows-[1fr]"), children: _jsx("ul", { ref: listRef, id: listId, "aria-labelledby": buttonId, className: cn("grid min-h-0 gap-0.5 overflow-hidden",
239
- // Rubriques en retrait, sous un filet aligné sur l'icône du titre.
240
- icon ? "ml-[1.375rem] border-l border-border pl-2 lg:ml-5" : "pl-2", "pt-0.5 lg:rail:ml-0 lg:rail:border-l-0 lg:rail:pt-0 lg:rail:pl-0"), children: children }) })] }));
241
- }
242
- function TitledNavGroup({ label, children }) {
243
- const id = useId();
244
- return (_jsxs("li", { "data-slot": "sidebar-nav-group", className: "pt-4 first:pt-0", children: [_jsx("p", { id: id, className: "px-3 pb-1.5 text-xs font-medium text-muted-foreground lg:text-shell-muted lg:rail:sr-only", children: label }), _jsx("span", { "aria-hidden": true, className: "mx-auto mb-2 hidden h-px w-6 bg-border lg:rail:block" }), _jsx("ul", { "aria-labelledby": id, className: "grid gap-0.5", children: children })] }));
245
- }
246
- const COMMON_LINKS = [
247
- { key: "documentation", label: "Documentation", icon: BookOpen01Icon },
248
- { key: "settings", label: "Réglages", icon: Settings01Icon },
249
- { key: "status", label: "État du service", icon: Activity01Icon },
250
- ];
251
- function ShellCommonLinks({ links, statusPageUrl, otherView, }) {
252
- const resolved = {
253
- documentation: links.documentation,
254
- settings: links.settings,
255
- status: links.status === false ? undefined : (links.status ?? statusPageUrl ?? undefined),
256
- };
257
- const items = COMMON_LINKS.flatMap(({ key, label, icon }) => {
258
- const link = resolved[key];
259
- if (link === undefined)
260
- return [];
261
- const props = typeof link === "string" ? { href: link } : link;
262
- return [
263
- _jsx(SidebarNavItem, { icon: icon, label: label, ...(props.href !== undefined ? { href: props.href } : {}), ...(props.render !== undefined ? { render: props.render } : {}), ...(props.active !== undefined ? { active: props.active } : {}) }, key),
264
- ];
265
- });
266
- if (!items.length)
267
- return null;
268
- return (_jsx("nav", { "aria-label": "Acc\u00E8s communs", children: _jsxs("ul", { "data-slot": "app-shell-links", className: "grid gap-0.5", children: [items, otherView] }) }));
269
- }
270
- /**
271
- * « Ouvrir seul » (vers `<mot>.hgdc.ch`) ou « Ouvrir dans app.hgdc.ch », discret, sous les accès communs.
272
- * Le chemin courant n'est lu qu'au clic : le lien rendu par le serveur mène à l'accueil.
273
- */
274
- function OtherViewLink({ product, view }) {
275
- const href = otherViewHref(product, view);
276
- if (!href)
277
- return null;
278
- const onClick = (event) => {
279
- const path = soloHref(`/${product}`, `${window.location.pathname}${window.location.search}`);
280
- event.currentTarget.href = otherViewHref(product, view, path) ?? href;
281
- };
282
- return (_jsx(SidebarNavItem, { icon: LinkSquare02Icon, label: otherViewLabel(view), render: _jsx("a", { href: href, onClick: onClick }) }));
283
- }
284
- /** Largeur à partir de laquelle la page et l'assistant tiennent côte à côte (`xl`). */
285
- const ASSISTANT_SIDE_BY_SIDE = "(min-width: 80rem)";
286
- const FOCUSABLE = 'textarea, input, [contenteditable="true"], [autofocus]';
287
- function AssistantButton({ assistant, panelId, buttonRef, }) {
288
- const label = assistant.label ?? "Assistant";
289
- const letter = assistant.shortcut?.toUpperCase();
290
- return (_jsxs(Tooltip, { children: [_jsxs(TooltipTrigger, { render: _jsx("button", { ref: buttonRef, type: "button", "data-slot": "app-shell-assistant-button", "aria-expanded": assistant.open, "aria-controls": panelId, ...(letter ? { "aria-keyshortcuts": `Control+${letter} Meta+${letter}` } : {}), onClick: () => assistant.onOpenChange(!assistant.open), className: cn("touch-target flex h-9 shrink-0 items-center gap-2 rounded-xl px-3 text-sm font-medium text-muted-foreground outline-none", "transition-colors duration-(--duration-fast) ease-out hover:bg-foreground/[0.05] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-expanded:bg-foreground/[0.08] aria-expanded:text-foreground", "max-lg:w-9 max-lg:justify-center max-lg:px-0") }), children: [_jsx(HugeiconsIcon, { icon: AiMagicIcon, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem] shrink-0" }), _jsx("span", { className: "max-lg:sr-only", children: label })] }), _jsx(TooltipContent, { children: _jsxs("span", { className: "flex items-center gap-2", children: [assistant.open ? `Fermer ${label.toLocaleLowerCase("fr-CH")}` : label, letter ? (_jsxs(KbdGroup, { children: [_jsx(Kbd, { children: isApplePlatform() ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: letter })] })) : null] }) })] }));
291
- }
292
- /** `Ctrl` (`⌘`) + la lettre de l'assistant : l'ouvre ou le ferme, où que soit le focus. */
293
- function useAssistantShortcut(assistant) {
294
- const latest = useRef(assistant);
295
- useEffect(() => {
296
- latest.current = assistant;
297
- });
298
- const letter = assistant?.shortcut?.toLowerCase();
299
- useEffect(() => {
300
- if (!letter)
301
- return undefined;
302
- const onKey = (event) => {
303
- if (event.key.toLowerCase() !== letter || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey)
304
- return;
305
- const current = latest.current;
306
- if (!current)
307
- return;
308
- event.preventDefault();
309
- current.onOpenChange(!current.open);
310
- };
311
- document.addEventListener("keydown", onKey);
312
- return () => document.removeEventListener("keydown", onKey);
313
- }, [letter]);
314
- }
315
- function AssistantPanel({ assistant, panelId, wide, openAtShellMount, buttonRef, }) {
316
- const { open, onOpenChange } = assistant;
317
- const label = assistant.label ?? "Assistant";
318
- const titleId = useId();
319
- const panelRef = useRef(null);
320
- // Ouvert dès le premier rendu de la coque (état restauré) : le focus reste où le navigateur le met.
321
- const wasOpen = useRef(openAtShellMount);
322
- // Ouvert : le focus entre dans le panneau (sa saisie, sinon le panneau). Fermé : il revient au
323
- // bouton, s'il était dans le panneau ou perdu sur la page.
324
- useEffect(() => {
325
- const panel = panelRef.current;
326
- if (open && !wasOpen.current && panel && !panel.contains(document.activeElement)) {
327
- const target = panel.querySelector(FOCUSABLE) ?? panel;
328
- target.focus({ preventScroll: true });
329
- }
330
- if (!open && wasOpen.current) {
331
- const active = document.activeElement;
332
- if (!active || active === document.body || panel?.contains(active))
333
- buttonRef.current?.focus();
334
- }
335
- wasOpen.current = open;
336
- }, [open, buttonRef]);
337
- // Échap, le focus dans le panneau : il se ferme (sauf si un élément du panneau l'a déjà traité).
338
- useEffect(() => {
339
- const panel = panelRef.current;
340
- if (!open || !panel)
341
- return undefined;
342
- const onKey = (event) => {
343
- if (event.key !== "Escape" || event.defaultPrevented)
344
- return;
345
- event.preventDefault();
346
- onOpenChange(false);
347
- };
348
- panel.addEventListener("keydown", onKey);
349
- return () => panel.removeEventListener("keydown", onKey);
350
- }, [open, onOpenChange]);
351
- const close = () => onOpenChange(false);
352
- return (_jsx("section", { ref: panelRef, id: panelId, tabIndex: -1, "data-slot": "app-shell-assistant", "data-open": open ? "" : undefined, "aria-labelledby": titleId, inert: !open || undefined, ...(open && !wide ? { role: "dialog", "aria-modal": true } : {}), className: cn("outline-none",
353
- // Vue dédiée, sous 1280 px : plein écran, la page reste montée dessous.
354
- "hidden data-open:max-xl:fixed data-open:max-xl:inset-0 data-open:max-xl:z-40 data-open:max-xl:flex data-open:max-xl:flex-col data-open:max-xl:bg-background", "data-open:max-xl:pt-[env(safe-area-inset-top,0px)] data-open:max-xl:soft-in motion-reduce:animate-none",
355
- // Côte à côte : une colonne de la grille, qui s'ouvre en largeur ; la page se réorganise.
356
- "xl:invisible xl:flex xl:w-0 xl:flex-col xl:overflow-hidden xl:transition-[width,visibility] xl:duration-(--duration-slow) xl:ease-ios motion-reduce:transition-none", "xl:data-open:visible xl:data-open:w-[26rem] 2xl:data-open:w-[28rem]"), children: _jsxs("div", { className: "flex min-h-0 flex-1 flex-col xl:w-[26rem] xl:py-2 xl:pr-2 2xl:w-[28rem]", children: [_jsxs("div", { className: "flex min-h-14 shrink-0 items-center gap-1 border-b border-foreground/5 px-2 sm:px-4 xl:border-0 xl:pr-1 xl:pl-3", children: [_jsxs("button", { type: "button", onClick: close, className: "touch-target flex h-9 shrink-0 items-center gap-1.5 rounded-xl pr-3 pl-2 text-sm font-medium text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 xl:hidden", children: [_jsx(HugeiconsIcon, { icon: ArrowLeft01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4" }), "Retour \u00E0 la page"] }), _jsxs("h2", { id: titleId, className: "flex min-w-0 flex-1 items-center gap-2 truncate font-heading text-[0.9375rem] font-semibold max-xl:justify-end max-xl:pr-2", children: [_jsx(HugeiconsIcon, { icon: AiMagicIcon, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem] shrink-0" }), label] }), _jsx("button", { type: "button", onClick: close, "aria-label": `Fermer ${label.toLocaleLowerCase("fr-CH")}`, className: "touch-target grid size-9 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-shell-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 max-xl:hidden", children: _jsx(HugeiconsIcon, { icon: Cancel01Icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem]" }) })] }), _jsx("div", { "data-slot": "app-shell-assistant-body", className: "flex min-h-0 flex-1 flex-col overflow-y-auto", children: assistant.panel })] }) }));
357
- }
358
- // ── Coque ─────────────────────────────────────────────────────────────────────────────
359
- // Les noms de Hugeicons disent l'état du panneau, pas le geste : « Open » dessine la flèche vers la
360
- // gauche (réduire), « Close » vers la droite (déplier).
361
- const COLLAPSE_ICON = PanelLeftOpenIcon;
362
- const EXPAND_ICON = PanelLeftCloseIcon;
363
- /** Même boîte qu'une entrée de la barre en rail : 40 × 36 px, icône de 18 px centrée. */
364
- const TOGGLE = "touch-target relative grid h-9 w-10 shrink-0 place-items-center rounded-xl text-shell-muted outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-shell-hover hover:text-shell-foreground focus-visible:ring-2 focus-visible:ring-ring/50";
365
- function ShortcutHint({ label }) {
366
- return (_jsxs("span", { className: "flex items-center gap-2", children: [label, _jsxs(KbdGroup, { children: [_jsx(Kbd, { children: isApplePlatform() ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: "B" })] })] }));
367
- }
368
- /**
369
- * Haut de la barre latérale. Dépliée : la marque du produit, et le bouton « Réduire » à droite.
370
- * En rail : la marque seule, qui devient le bouton « Déplier » au survol de la barre ou au focus,
371
- * à la même place : le bouton ne descend jamais sous le logo. Les deux états sont dans la page et
372
- * choisis par la variante CSS `rail:`, donc justes dès la première peinture.
373
- */
374
- function SidebarHead({ product }) {
375
- 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" }) })] })] }));
376
- }
26
+ export { sidebarInitScript, toggleSidebar, useSidebarRail } from "./sidebar-rail.js";
27
+ export { PRODUCT_LEAVE_DELAY_MS } from "./use-product-leave.js";
28
+ export { useAppShell } from "./shell-context.js";
29
+ export { SidebarNav, SidebarNavBack, SidebarNavItem } from "./sidebar-nav.js";
30
+ export { SidebarNavGroup } from "./sidebar-nav-group.js";
377
31
  /**
378
32
  * La coque commune des consoles HGDC, celle de Host. Sur ordinateur, de gauche à droite : la barre
379
33
  * des produits de la famille (organisation, Account, pastilles ; élargie au survol à la place
@@ -385,51 +39,6 @@ function SidebarHead({ product }) {
385
39
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
386
40
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
387
41
  */
388
- const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
389
- /** Faux au rendu serveur et à l'hydratation, vrai avant la première image qui suit. */
390
- function useHydrated() {
391
- const [hydrated, setHydrated] = useState(false);
392
- useIsomorphicLayoutEffect(() => setHydrated(true), []);
393
- return hydrated;
394
- }
395
- /**
396
- * Le chargement de la zone de contenu : `navigating` après 50 ms, `pending` aussitôt, puis au
397
- * moins 300 ms. Effets de mise en page : une page qui passe en attente est couverte avant d'être
398
- * peinte, jamais une image vide entre les deux.
399
- */
400
- function useContentLoading({ navigating, pending }) {
401
- const [navigatingLong, setNavigatingLong] = useState(false);
402
- useEffect(() => {
403
- if (!navigating)
404
- return undefined;
405
- const timer = window.setTimeout(() => setNavigatingLong(true), PAGE_PENDING_MS);
406
- return () => {
407
- window.clearTimeout(timer);
408
- setNavigatingLong(false);
409
- };
410
- }, [navigating]);
411
- const active = pending || (navigating && navigatingLong);
412
- const [visible, setVisible] = useState(false);
413
- const shownAt = useRef(0);
414
- useIsomorphicLayoutEffect(() => {
415
- if (active && !visible) {
416
- shownAt.current = performance.now();
417
- setVisible(true);
418
- return undefined;
419
- }
420
- if (!active && visible) {
421
- const timer = window.setTimeout(() => setVisible(false), Math.max(0, shownAt.current + PAGE_PENDING_MIN_MS - performance.now()));
422
- return () => window.clearTimeout(timer);
423
- }
424
- return undefined;
425
- }, [active, visible]);
426
- return visible;
427
- }
428
- /**
429
- * Délai entre le clic vers un autre produit et le H : une page prérendue s'affiche avant (une ou
430
- * deux images), le H n'apparaît alors jamais ; une page à charger le montre aussitôt après.
431
- */
432
- export const PRODUCT_LEAVE_DELAY_MS = 100;
433
42
  export function AppShell({ product, organization, productRail: productRailOption, view: viewOption, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, }) {
434
43
  const [menuOpen, setMenuOpen] = useState(false);
435
44
  const preference = useProductBarPreference();
@@ -445,34 +54,12 @@ export function AppShell({ product, organization, productRail: productRailOption
445
54
  // qui attend (`PagePending`, aussitôt : le routeur a déjà attendu), ou le départ vers un autre
446
55
  // produit (aussitôt). Une fois montré, au moins 300 ms. Le H est celui du document, en position
447
56
  // fixe : il ne bouge pas d'un état à l'autre et n'est jamais démonté.
448
- const [leaving, setLeaving] = useState(false);
449
57
  const [pagePending, setPagePending] = useState(0);
450
58
  const registerPagePending = useCallback(() => {
451
59
  setPagePending((count) => count + 1);
452
60
  return () => setPagePending((count) => count - 1);
453
61
  }, []);
454
- // Après `PRODUCT_LEAVE_DELAY_MS` seulement : une page prérendue au survol s'affiche avant, sans
455
- // que le H apparaisse (ADR 0020). Sinon le H couvre le contenu jusqu'au départ de la page.
456
- const leaveTimer = useRef(undefined);
457
- const leave = useCallback(() => {
458
- window.clearTimeout(leaveTimer.current);
459
- leaveTimer.current = window.setTimeout(() => setLeaving(true), PRODUCT_LEAVE_DELAY_MS);
460
- }, []);
461
- useEffect(() => {
462
- // Retour arrière depuis l'autre produit (cache de page) : la page revient telle quelle.
463
- const onPageShow = (event) => {
464
- if (!event.persisted)
465
- return;
466
- window.clearTimeout(leaveTimer.current);
467
- setLeaving(false);
468
- delete document.documentElement.dataset.hgdcSwitching;
469
- };
470
- window.addEventListener("pageshow", onPageShow);
471
- return () => {
472
- window.removeEventListener("pageshow", onPageShow);
473
- window.clearTimeout(leaveTimer.current);
474
- };
475
- }, []);
62
+ const { leaving, leave } = useProductLeave();
476
63
  const statusPageUrl = statusPage === false ? null : (statusPage ?? statusPageUrlFor(product));
477
64
  const loading = useContentLoading({ navigating, pending: pagePending > 0 || leaving });
478
65
  // Avant l'hydratation, le CSS montre le calque et le H dès qu'une page attend (`:has`) ; ensuite