@hgdc/ui 0.63.1 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,8 +70,8 @@ Entre deux produits servis sous la même origine, la coque fait comme une seule
70
70
 
71
71
  Pendant le passage (0.39) :
72
72
 
73
- - **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.
74
- - **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.
73
+ - **Barre latérale, organisation** : 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.
74
+ - **Barre latérale, contenu** : le nom du module 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 : organisation retenue, Manager, retour aux modules, nom du module de l'adresse, navigation en squelette à la hauteur retenue pour ce module, barre du haut.
75
75
  - **H** : un seul nom de transition (`hgdc-loader`), sans fondu, calé sur l'horloge : celui de l'arrivée reprend à la même phase.
76
76
  - **Contenu** : fondu de 150 ms.
77
77
 
@@ -112,7 +112,7 @@ Couleurs pleines ; pour éclaircir, mélanger au blanc `color-mix(in oklab, …)
112
112
  - produit : `"build": "hgdc-brand sync mail && vite build"` (et en tête de `dev`), les six fichiers de `family/app/<mot>/` dans `public/`, listés dans le `.gitignore` ; `--email-as <fichier>` y ajoute l'icône d'e-mail du produit sous un ancien nom ;
113
113
  - site hgdc.ch : `hgdc-brand emails public/email`, les `<mot>.png` servis à `EMAIL_ICON_BASE`.
114
114
 
115
- Sur ordinateur, la coque ouvre sur la barre des produits (ADR 0011) : organisation en haut, Manager en tête puis pastilles des produits ouverts, chacun par son entrée `/auth/hgdc?next=/`, le produit courant en teinte. Au survol (150 ms d'intention) ou au focus clavier, elle s'élargit par-dessus la page et écrit les noms. `productRail={false}` la coupe ; `accountHref` pointe vers un Manager local. La barre latérale porte la marque du produit et se réduit en rail (`Ctrl+B`, mémorisé) : poser `sidebarInitScript` dans le `<head>`. En rail, la pastille du produit devient le bouton « Déplier » au survol. Le kit ne connaît ni routeur ni authentification : liens par `render={<Link />}`, actions par callbacks. Rôles d'organisation : `owner`, `admin`, `member` (Propriétaire, Admin, Membre).
115
+ Une seule barre latérale (ADR 0024) : organisation et bouton Réduire en haut, lien Manager, puis les onglets du module ouvert sous « Retour aux modules ». Le retour affiche la liste des modules, sans recharger la page ; « Personnaliser la barre » permet de les ranger, masquer et ajouter (`localStorage`, `hgdc-shell:modules`). Réglages reste au-dessus du compte ; Documentation, État du service et l'autre vue sont dans `AccountMenu`. `productRail={false}` ou une vue seule retire cette barre : marque du produit en haut, organisation dessous, les trois accès au-dessus du compte, « Produits » dans le menu du compte. `accountHref` pointe vers un Manager local. La barre se réduit en rail (`Ctrl+B`, mémorisé) : poser `sidebarInitScript` dans le `<head>`. Le kit ne connaît ni routeur ni authentification : liens par `render={<Link />}`, actions par callbacks. Rôles d'organisation : `owner`, `admin`, `member` (Propriétaire, Admin, Membre).
116
116
 
117
117
  ## Contenu et données
118
118
 
@@ -27,11 +27,11 @@ export type AccountUser = {
27
27
  * l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
28
28
  * `ThemeProvider`.
29
29
  *
30
- * Documentation, Réglages et État du service ne sont pas ici : la coque les pose au-dessus du
31
- * compte (`links` d'`AppShell`). L'aide des raccourcis garde sa touche `?`, sans entrée de menu.
30
+ * Dans la barre de Basalte, ce menu porte aussi Documentation, État du service et l'autre vue
31
+ * (`links` d'`AppShell`), et plus la liste des produits : la barre la montre. L'aide des raccourcis
32
+ * garde sa touche `?`, sans entrée de menu.
32
33
  *
33
- * « Produits » (masquée au-delà de `lg` dans une coque qui affiche la barre des produits, où ils
34
- * sont déjà ; gardée sur téléphone) liste par défaut Account et les produits ouverts du catalogue
34
+ * Dans une vue seule, « Produits » liste Manager et les modules ouverts du catalogue
35
35
  * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
36
36
  * liste ; `products={[]}` la retire.
37
37
  */
@@ -1,5 +1,8 @@
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 BookOpen01Icon from "@hugeicons/core-free-icons/BookOpen01Icon";
2
4
  import DashboardSquare01Icon from "@hugeicons/core-free-icons/DashboardSquare01Icon";
5
+ import LinkSquare02Icon from "@hugeicons/core-free-icons/LinkSquare02Icon";
3
6
  import Logout01Icon from "@hugeicons/core-free-icons/Logout01Icon";
4
7
  import SecurityLockIcon from "@hugeicons/core-free-icons/SecurityLockIcon";
5
8
  import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
@@ -13,7 +16,7 @@ import { MENU_LINK_ITEM } from "./org-switcher.js";
13
16
  import { ProductBadge } from "./product-badge.js";
14
17
  import { productMenuEntries } from "./products.js";
15
18
  import { forgetShellOrganization } from "./shell-memory.js";
16
- import { useLeaveOnClick, useProductRailShown } from "./shell-placement.js";
19
+ import { useLeaveOnClick, useModuleBarShown, useShellMenuLinks } from "./shell-placement.js";
17
20
  import { ThemeSwitch } from "./theme-switch.js";
18
21
  /** L'avatar du compte : la photo, sinon les initiales sur la teinte stable de l'adresse. */
19
22
  function AccountAvatar({ user, size }) {
@@ -27,24 +30,39 @@ function AccountAvatar({ user, size }) {
27
30
  * l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
28
31
  * `ThemeProvider`.
29
32
  *
30
- * Documentation, Réglages et État du service ne sont pas ici : la coque les pose au-dessus du
31
- * compte (`links` d'`AppShell`). L'aide des raccourcis garde sa touche `?`, sans entrée de menu.
33
+ * Dans la barre de Basalte, ce menu porte aussi Documentation, État du service et l'autre vue
34
+ * (`links` d'`AppShell`), et plus la liste des produits : la barre la montre. L'aide des raccourcis
35
+ * garde sa touche `?`, sans entrée de menu.
32
36
  *
33
- * « Produits » (masquée au-delà de `lg` dans une coque qui affiche la barre des produits, où ils
34
- * sont déjà ; gardée sur téléphone) liste par défaut Account et les produits ouverts du catalogue
37
+ * Dans une vue seule, « Produits » liste Manager et les modules ouverts du catalogue
35
38
  * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
36
39
  * liste ; `products={[]}` la retire.
37
40
  */
38
41
  export function AccountMenu({ user, onProfile, onSecurity, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant = "row", }) {
39
- const products = productsProp ?? productMenuEntries({ current: currentProduct, accountHref });
40
- const railShown = useProductRailShown();
42
+ const moduleBar = useModuleBarShown();
43
+ // La barre de Basalte montre déjà Manager et les modules, au téléphone comme à l'ordinateur.
44
+ const products = moduleBar ? [] : (productsProp ?? productMenuEntries({ current: currentProduct, accountHref }));
45
+ const menuLinks = useShellMenuLinks();
41
46
  const leaveOnClick = useLeaveOnClick();
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: () => {
47
+ 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, _jsx(AccountMenuLinks, { links: menuLinks }), products.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Produits" }), products.map((product) => product.current ? (_jsxs(DropdownMenuItem, { disabled: true, className: "data-disabled:opacity-100", children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Produit ouvert" })] }, product.name)) : (_jsxs(MenuPrimitive.LinkItem, { href: product.href, 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
48
  // La marque de l'organisation retenue pour la coque ne survit pas à la session.
44
49
  forgetShellOrganization();
45
50
  onSignOut();
46
51
  }, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
47
52
  }
53
+ /** Documentation, État du service et l'autre vue, dans le menu du compte de la barre de Basalte. */
54
+ function AccountMenuLinks({ links }) {
55
+ const documentation = links.documentation;
56
+ const status = links.status;
57
+ const { otherView } = links;
58
+ if (documentation === undefined && status === undefined && otherView === undefined)
59
+ return null;
60
+ return (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [documentation !== undefined ? (_jsx(ShellLinkItem, { link: documentation, icon: BookOpen01Icon, label: "Documentation" })) : null, status !== undefined ? _jsx(ShellLinkItem, { link: status, icon: Activity01Icon, label: "\u00C9tat du service" }) : null, otherView ? (_jsxs(MenuPrimitive.LinkItem, { href: otherView.href, onClick: otherView.onClick, className: MENU_LINK_ITEM, children: [_jsx(HugeiconsIcon, { icon: LinkSquare02Icon, "aria-hidden": true }), otherView.label] })) : null] })] }));
61
+ }
62
+ function ShellLinkItem({ link, icon, label, }) {
63
+ const props = typeof link === "string" ? { href: link } : link;
64
+ return (_jsxs(MenuPrimitive.LinkItem, { ...(props.href !== undefined ? { href: props.href } : {}), ...(props.render !== undefined ? { render: props.render } : {}), className: MENU_LINK_ITEM, children: [_jsx(HugeiconsIcon, { icon: icon, "aria-hidden": true }), label] }));
65
+ }
48
66
  function ProductMenuIcon({ product }) {
49
67
  if (product.badge)
50
68
  return _jsx(ProductBadge, { product: product.badge });
@@ -11,28 +11,27 @@ export { SidebarNavGroup } from "./sidebar-nav-group.tsx";
11
11
  export type { AppShellLinks, ShellLink } from "./shell-links.tsx";
12
12
  export type { AppShellAssistant } from "./shell-assistant.tsx";
13
13
  /**
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.
14
+ * La coque commune des consoles de Basalte. Sur ordinateur : la barre latérale (l'organisation,
15
+ * Manager, puis les onglets du module ouvert ou la liste des modules, le compte en bas), réductible
16
+ * en rail (`Ctrl+B`, mémorisé), et la page sur une carte. Sur téléphone : une barre en haut, la même
17
+ * navigation dans un tiroir qui monte du bas et se referme quand on choisit une page. Une vue seule
18
+ * (`<mot>.hgdc.ch`) garde la marque du produit à la place de la barre de Basalte.
20
19
  *
21
20
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
22
21
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
23
22
  */
24
23
  export declare function AppShell({ product, organization, productRail: productRailOption, view: viewOption, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, }: {
25
- /** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
24
+ /** Le produit affiché (`"host"`) : son nom au-dessus de ses onglets, « vous êtes ici » dans la liste des modules. */
26
25
  product: FamilyProduct;
27
26
  /**
28
- * `OrgSwitcher` : en haut de la barre des produits, en haut du tiroir sur téléphone. Absent
27
+ * `OrgSwitcher` : en haut de la barre latérale et du tiroir sur téléphone. Absent
29
28
  * pendant que les données du produit arrivent : la barre lui garde sa place, avec la marque de
30
29
  * la dernière organisation affichée (`OrgPlaceholder`, ADR 0020). `null` : aucune organisation.
31
30
  */
32
31
  organization?: ReactNode;
33
32
  /**
34
- * `false` : pas de barre des produits (un écran hors de la famille). L'organisation passe alors
35
- * sous la marque, dans la barre latérale. Absent : présente, ou, si `view` est donné, selon la vue
33
+ * `false` : pas de barre de Basalte (un écran hors de la console, une démo) : la marque du produit
34
+ * en haut, l'organisation dessous. Absent : présente, ou, si `view` est donné, selon la vue
36
35
  * et le réglage « Barre des produits » d'Account (ADR 0014), relu par la coque.
37
36
  */
38
37
  productRail?: boolean;
@@ -42,13 +41,13 @@ export declare function AppShell({ product, organization, productRail: productRa
42
41
  * Absent : un produit qui n'a qu'une vue, barre présente et pas de lien vers l'autre vue.
43
42
  */
44
43
  view?: ProductView | undefined;
45
- /** Adresse d'Account dans la barre des produits (un Account local en développement). */
44
+ /** Adresse de Manager dans la barre (un Manager local en développement). */
46
45
  accountHref?: string | undefined;
47
46
  /** `SidebarNav` et ses `SidebarNavItem`. */
48
47
  nav: ReactNode;
49
48
  /**
50
- * Documentation, Réglages et État du service, juste au-dessus du compte, dans cet ordre, chacun
51
- * optionnel (État du service suit `statusPage` par défaut).
49
+ * Réglages, juste au-dessus du compte ; Documentation et État du service (qui suit `statusPage` par
50
+ * défaut) dans le menu du compte. Dans une vue seule, les trois au-dessus du compte.
52
51
  */
53
52
  links?: AppShellLinks;
54
53
  /** `AccountMenu`, tout en bas de la barre latérale (et du tiroir sur téléphone). */
@@ -99,7 +98,7 @@ export declare function AppShell({ product, organization, productRail: productRa
99
98
  /**
100
99
  * Page de statut du produit, dont le bandeau d'état se pose en tête du contenu (ADR 0018) :
101
100
  * `https://status.hgdc.ch/<produit>` par défaut, une autre adresse (prévisualisation sur une page
102
- * de test), ou `false` pour ne rien lire. L'accès « État du service », au-dessus du compte, y mène.
101
+ * de test), ou `false` pour ne rien lire. L'accès « État du service » y mène.
103
102
  */
104
103
  statusPage?: string | false | undefined;
105
104
  }): React.JSX.Element;
package/dist/app-shell.js CHANGED
@@ -8,17 +8,17 @@ import { FamilyLoaderHost, FamilyLoaderRootContext } from "./family-loader.js";
8
8
  import { CONTENT_LOADING_LAYER, ContentLoadingContext, PagePending } from "./page-pending.js";
9
9
  import { ProductBadge } from "./product-badge.js";
10
10
  import { productBarShown, useProductBarPreference, useProductBarSync } from "./product-bar.js";
11
- import { ProductRail } from "./product-rail.js";
11
+ import { ShellNavigation } from "./module-bar.js";
12
12
  import { useProductSwitch } from "./product-switch.js";
13
13
  import { cn } from "./cn.js";
14
14
  import { ServiceStatus, statusPageUrlFor } from "./service-status.js";
15
15
  import { AssistantButton, AssistantPanel, ASSISTANT_SIDE_BY_SIDE, useAssistantShortcut, } from "./shell-assistant.js";
16
16
  import { ShellContext } from "./shell-context.js";
17
17
  import { familyProductName } from "./shell-frame.js";
18
- import { OtherViewLink, ShellCommonLinks } from "./shell-links.js";
18
+ import { OtherViewLink, otherViewMenuLink, ShellCommonLinks } from "./shell-links.js";
19
19
  import { rememberLastProduct, rememberShellNavHeight } from "./shell-memory.js";
20
- import { ProductRailShownContext, ShellLeaveContext, StatusPageContext } from "./shell-placement.js";
21
- import { SidebarHead } from "./sidebar-head.js";
20
+ import { ModuleBarShownContext, ShellLeaveContext, ShellMenuLinksContext, StatusPageContext, } from "./shell-placement.js";
21
+ import { ShellOrganizationHead, SidebarHead } from "./sidebar-head.js";
22
22
  import { useToggleShortcut } from "./sidebar-rail.js";
23
23
  import { useContentLoading, useHydrated, useIsomorphicLayoutEffect } from "./use-content-loading.js";
24
24
  import { useProductLeave } from "./use-product-leave.js";
@@ -29,12 +29,11 @@ export { useAppShell } from "./shell-context.js";
29
29
  export { SidebarNav, SidebarNavBack, SidebarNavItem } from "./sidebar-nav.js";
30
30
  export { SidebarNavGroup } from "./sidebar-nav-group.js";
31
31
  /**
32
- * La coque commune des consoles HGDC, celle de Host. Sur ordinateur, de gauche à droite : la barre
33
- * des produits de la famille (organisation, Account, pastilles ; élargie au survol à la place
34
- * 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),
35
- * réductible en rail (`Ctrl+B`, mémorisé), et la page sur une carte. Sur téléphone : une barre en
36
- * haut, le menu dans un tiroir qui monte du bas et se referme quand on choisit une page ; les
37
- * produits restent dans le menu de l'avatar.
32
+ * La coque commune des consoles de Basalte. Sur ordinateur : la barre latérale (l'organisation,
33
+ * Manager, puis les onglets du module ouvert ou la liste des modules, le compte en bas), réductible
34
+ * en rail (`Ctrl+B`, mémorisé), et la page sur une carte. Sur téléphone : une barre en haut, la même
35
+ * navigation dans un tiroir qui monte du bas et se referme quand on choisit une page. Une vue seule
36
+ * (`<mot>.hgdc.ch`) garde la marque du produit à la place de la barre de Basalte.
38
37
  *
39
38
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
40
39
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
@@ -45,8 +44,7 @@ export function AppShell({ product, organization, productRail: productRailOption
45
44
  // Relu chez Account seulement par un produit passé aux deux vues (il donne `view`, ADR 0014).
46
45
  useProductBarSync(productRailOption === undefined && viewOption !== undefined);
47
46
  const productRail = productRailOption ?? (viewOption === undefined || productBarShown(viewOption, preference));
48
- // Barre des produits élargie : la barre latérale se plie le temps du survol (ADR 0011).
49
- const [railPeek, setRailPeek] = useState(false);
47
+ const [navLevel, setNavLevel] = useState("tabs");
50
48
  useToggleShortcut();
51
49
  // Relecture des organisations après un passage depuis la barre, préchargement des autres produits (ADR 0016).
52
50
  useProductSwitch(productRail);
@@ -77,7 +75,7 @@ export function AppShell({ product, organization, productRail: productRailOption
77
75
  }, [product]);
78
76
  const navHeight = useRef(0);
79
77
  useEffect(() => {
80
- const list = sidebarRef.current?.querySelector(":scope > nav > ul");
78
+ const list = sidebarRef.current?.querySelector(":scope > nav > ul, [data-slot=shell-navigation] > nav:not([data-slot]) > ul");
81
79
  if (!(list instanceof HTMLElement) || list.offsetHeight === navHeight.current)
82
80
  return;
83
81
  navHeight.current = list.offsetHeight;
@@ -100,6 +98,16 @@ export function AppShell({ product, organization, productRail: productRailOption
100
98
  const assistantPanelId = useId();
101
99
  const assistantButtonRef = useRef(null);
102
100
  useAssistantShortcut(assistant);
103
- const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, _jsx(ShellCommonLinks, { links: links ?? {}, statusPageUrl: statusPageUrl, otherView: viewOption === undefined ? null : _jsx(OtherViewLink, { product: product, view: viewOption }) }), account ?? sidebarFooter] }));
104
- 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 })] }) })] }) }) }) }) }));
101
+ const otherView = viewOption === undefined ? null : _jsx(OtherViewLink, { product: product, view: viewOption });
102
+ // Dans la barre de Basalte, seul Réglages reste au-dessus du compte : le reste passe dans son menu.
103
+ const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, _jsx(ShellCommonLinks, { links: productRail ? { ...(links?.settings ? { settings: links.settings } : {}), status: false } : (links ?? {}), statusPageUrl: statusPageUrl, otherView: productRail ? null : otherView }), account ?? sidebarFooter] }));
104
+ const menuLinks = productRail
105
+ ? {
106
+ documentation: links?.documentation,
107
+ status: links?.status === false ? undefined : (links?.status ?? statusPageUrl ?? undefined),
108
+ otherView: viewOption === undefined ? undefined : otherViewMenuLink(product, viewOption),
109
+ }
110
+ : {};
111
+ const navigation = productRail ? (_jsx(ShellNavigation, { product: product, nav: nav, accountHref: accountHref, level: navLevel, onLevelChange: setNavLevel })) : (nav);
112
+ return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false), product }, children: _jsx(ModuleBarShownContext, { value: productRail, children: _jsx(ShellMenuLinksContext, { value: menuLinks, children: _jsx(ShellLeaveContext, { value: leave, children: _jsx(StatusPageContext, { value: statusPageUrl, children: _jsxs("div", { "data-slot": "app-shell", "data-module-bar": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, "data-assistant": assistantOpen ? "" : undefined, className: "min-h-dvh lg:grid lg:h-dvh lg:grid-cols-[auto_minmax(0,1fr)_auto] lg:bg-shell", 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" }), _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: [productRail ? (_jsx(ShellOrganizationHead, { organization: organization })) : (_jsxs(_Fragment, { children: [_jsx(SidebarHead, { product: product }), organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null] })), navigation, _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, navigation, _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 })] }) })] }) }) }) }) }) }));
105
113
  }
package/dist/email.js CHANGED
@@ -81,7 +81,7 @@ export function EmailLayout({ product: ref, preview, children, name, iconSrc, ac
81
81
  border: `1px solid ${l.border}`,
82
82
  borderRadius: 16,
83
83
  padding: "32px 28px",
84
- }, children: children }), _jsxs(Section, { style: { padding: "20px 4px 0" }, children: [footer ? (_jsx(Text, { className: "hg-muted", style: { ...muted, margin: "0 0 12px" }, children: footer })) : null, _jsxs(Text, { className: "hg-muted", style: muted, children: [_jsx(Img, { src: emailIconUrl("mail"), width: 16, height: 16, alt: "", style: { display: "inline-block", verticalAlign: "middle", borderRadius: 8, marginRight: 6 } }), _jsxs("span", { style: { verticalAlign: "middle" }, children: ["Envoy\u00E9 avec", " ", _jsx(Link, { href: MAIL_HREF, className: "hg-muted", style: { color: l.muted, textDecoration: "underline" }, children: "Mail de la H Collection" })] })] }), _jsx(Text, { className: "hg-muted", style: { ...muted, margin: "4px 0 0" }, children: LEGAL })] })] }) }) })] }));
84
+ }, children: children }), _jsxs(Section, { style: { padding: "20px 4px 0" }, children: [footer ? (_jsx(Text, { className: "hg-muted", style: { ...muted, margin: "0 0 12px" }, children: footer })) : null, _jsxs(Text, { className: "hg-muted", style: muted, children: [_jsx(Img, { src: emailIconUrl("mail"), width: 16, height: 16, alt: "", style: { display: "inline-block", verticalAlign: "middle", borderRadius: 8, marginRight: 6 } }), _jsxs("span", { style: { verticalAlign: "middle" }, children: ["Envoy\u00E9 avec", " ", _jsx(Link, { href: MAIL_HREF, className: "hg-muted", style: { color: l.muted, textDecoration: "underline" }, children: "Basalte" })] })] }), _jsx(Text, { className: "hg-muted", style: { ...muted, margin: "4px 0 0" }, children: LEGAL })] })] }) }) })] }));
85
85
  }
86
86
  export function EmailHeading({ children }) {
87
87
  return (_jsx(Heading, { as: "h1", className: "hg-ink hg-heading", style: { color: EMAIL_LIGHT.ink, fontSize: 22, fontWeight: 600, lineHeight: "28px", margin: "0 0 12px" }, children: children }));
@@ -0,0 +1,9 @@
1
+ import { type ModuleWord } from "./modules.ts";
2
+ /**
3
+ * L'ordre et le choix des modules de la barre, propres à chaque personne. Sous `app.hgdc.ch`, toutes
4
+ * les consoles partagent la même origine, donc le même réglage.
5
+ */
6
+ export declare const MODULE_BAR_KEY = "hgdc-shell:modules";
7
+ export declare function saveModuleBar(order: ReadonlyArray<ModuleWord>): void;
8
+ /** L'ordre retenu ; celui par défaut au rendu serveur. */
9
+ export declare function useModuleBar(): ReadonlyArray<ModuleWord>;
@@ -0,0 +1,56 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { DEFAULT_MODULE_BAR, parseModuleBar } from "./modules.js";
3
+ /**
4
+ * L'ordre et le choix des modules de la barre, propres à chaque personne. Sous `app.hgdc.ch`, toutes
5
+ * les consoles partagent la même origine, donc le même réglage.
6
+ */
7
+ export const MODULE_BAR_KEY = "hgdc-shell:modules";
8
+ const EVENT = "hgdc-module-bar";
9
+ let cachedRaw;
10
+ let cachedOrder = DEFAULT_MODULE_BAR;
11
+ /** Même référence tant que le stockage ne change pas : `useSyncExternalStore` compare par identité. */
12
+ function read() {
13
+ let raw = null;
14
+ try {
15
+ raw = window.localStorage.getItem(MODULE_BAR_KEY);
16
+ }
17
+ catch {
18
+ return DEFAULT_MODULE_BAR;
19
+ }
20
+ if (raw === cachedRaw)
21
+ return cachedOrder;
22
+ cachedRaw = raw;
23
+ cachedOrder = decode(raw);
24
+ return cachedOrder;
25
+ }
26
+ function decode(raw) {
27
+ if (raw === null)
28
+ return DEFAULT_MODULE_BAR;
29
+ try {
30
+ return parseModuleBar(JSON.parse(raw)) ?? DEFAULT_MODULE_BAR;
31
+ }
32
+ catch {
33
+ return DEFAULT_MODULE_BAR;
34
+ }
35
+ }
36
+ function subscribe(onChange) {
37
+ window.addEventListener(EVENT, onChange);
38
+ window.addEventListener("storage", onChange);
39
+ return () => {
40
+ window.removeEventListener(EVENT, onChange);
41
+ window.removeEventListener("storage", onChange);
42
+ };
43
+ }
44
+ export function saveModuleBar(order) {
45
+ try {
46
+ window.localStorage.setItem(MODULE_BAR_KEY, JSON.stringify(order));
47
+ }
48
+ catch {
49
+ // Stockage bloqué : la barre reprend l'ordre par défaut au prochain chargement.
50
+ }
51
+ window.dispatchEvent(new Event(EVENT));
52
+ }
53
+ /** L'ordre retenu ; celui par défaut au rendu serveur. */
54
+ export function useModuleBar() {
55
+ return useSyncExternalStore(subscribe, read, () => DEFAULT_MODULE_BAR);
56
+ }
@@ -0,0 +1,16 @@
1
+ import { type ReactNode } from "react";
2
+ import type { FamilyProduct } from "./family-svg.ts";
3
+ /** Ce que montre le milieu de la barre : les onglets du module ouvert, la liste des modules, ou son réglage. */
4
+ export type ShellNavLevel = "tabs" | "modules" | "edit";
5
+ /**
6
+ * Le milieu de la barre de Basalte : Manager, un trait, puis l'un des trois niveaux. Dans un module,
7
+ * ses onglets (`nav`) sous « Retour aux modules » ; un clic y affiche la liste sans rien recharger ;
8
+ * un clic sur un autre module ouvre sa console, préchargée au survol sous la même origine.
9
+ */
10
+ export declare function ShellNavigation({ product, nav, accountHref, level, onLevelChange, }: {
11
+ product: FamilyProduct;
12
+ nav: ReactNode;
13
+ accountHref?: string | undefined;
14
+ level: ShellNavLevel;
15
+ onLevelChange: (level: ShellNavLevel) => void;
16
+ }): React.JSX.Element;
@@ -0,0 +1,91 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Add01Icon from "@hugeicons/core-free-icons/Add01Icon";
3
+ import ArrowDown01Icon from "@hugeicons/core-free-icons/ArrowDown01Icon";
4
+ import ArrowUp01Icon from "@hugeicons/core-free-icons/ArrowUp01Icon";
5
+ import DragDropVerticalIcon from "@hugeicons/core-free-icons/DragDropVerticalIcon";
6
+ import Edit02Icon from "@hugeicons/core-free-icons/Edit02Icon";
7
+ import MinusSignIcon from "@hugeicons/core-free-icons/MinusSignIcon";
8
+ import UserGroupIcon from "@hugeicons/core-free-icons/UserGroupIcon";
9
+ import { HugeiconsIcon } from "@hugeicons/react";
10
+ import { useState, useSyncExternalStore } from "react";
11
+ import { Button } from "./button.js";
12
+ import { cn } from "./cn.js";
13
+ import { saveModuleBar, useModuleBar } from "./module-bar-store.js";
14
+ import { hiddenModules, moduleByWord, moveModule, shownModules } from "./modules.js";
15
+ import { ProductBadge } from "./product-badge.js";
16
+ import { PRODUCT_PRERENDER_ATTRIBUTE } from "./product-switch.js";
17
+ import { shellProductEntries } from "./products.js";
18
+ import { familyProductName } from "./product-name.js";
19
+ import { useLeaveOnClick } from "./shell-placement.js";
20
+ import { SIDEBAR_ITEM, SidebarNavBack } from "./sidebar-nav.js";
21
+ import { useSidebarRail } from "./sidebar-rail.js";
22
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
23
+ const noSubscription = () => () => { };
24
+ /** `location.origin` après l'hydratation, `undefined` au rendu serveur (liens absolus d'abord). */
25
+ function usePageOrigin() {
26
+ return useSyncExternalStore(noSubscription, () => window.location.origin, () => undefined);
27
+ }
28
+ const GROUP_LABEL = "px-3 pt-3 pb-1.5 text-xs font-medium text-muted-foreground lg:text-shell-muted lg:rail:sr-only";
29
+ function ShellEntryLink({ entry, icon, onOpenCurrent, }) {
30
+ const rail = useSidebarRail();
31
+ const leaveOnClick = useLeaveOnClick();
32
+ const onClick = (event) => {
33
+ const plain = event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
34
+ if (entry.current && onOpenCurrent && plain) {
35
+ event.preventDefault();
36
+ onOpenCurrent();
37
+ return;
38
+ }
39
+ leaveOnClick(event);
40
+ };
41
+ return (_jsx("li", { children: _jsxs(Tooltip, { disabled: !rail, children: [_jsxs(TooltipTrigger, { render: _jsx("a", { href: entry.href, onClick: onClick, ...(entry.sameOrigin ? { [PRODUCT_PRERENDER_ATTRIBUTE]: "" } : {}), "aria-current": entry.current ? "page" : undefined, className: SIDEBAR_ITEM }), children: [icon ? (_jsx(HugeiconsIcon, { icon: icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-5 shrink-0 lg:size-[1.125rem]" })) : (_jsx(ProductBadge, { product: entry.badge, className: "size-5 shrink-0 lg:size-[1.125rem]" })), _jsx("span", { className: "min-w-0 flex-1 truncate lg:rail:sr-only", children: entry.name }), entry.hint ? _jsx("span", { className: "text-xs font-normal lg:rail:hidden", children: entry.hint }) : null] }), _jsx(TooltipContent, { side: "right", children: entry.name })] }) }));
42
+ }
43
+ function ModuleList({ entries, order, onOpenCurrent, onCustomize, }) {
44
+ const shown = order.flatMap((word) => {
45
+ const entry = entries.find((item) => item.badge === word);
46
+ return entry ? [entry] : [];
47
+ });
48
+ return (_jsxs("nav", { "data-slot": "shell-modules", "aria-label": "Modules", className: "min-h-0 flex-1 overflow-y-auto px-3 pb-2", children: [_jsx("p", { className: GROUP_LABEL, children: "Modules du projet" }), _jsx("ul", { className: "grid gap-0.5", children: shown.map((entry) => (_jsx(ShellEntryLink, { entry: entry, onOpenCurrent: onOpenCurrent }, entry.badge))) }), _jsxs("button", { type: "button", onClick: onCustomize, className: cn(SIDEBAR_ITEM, "mt-1 w-full text-left font-normal lg:rail:hidden"), children: [_jsx(HugeiconsIcon, { icon: Edit02Icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-5 shrink-0 lg:size-[1.125rem]" }), _jsx("span", { className: "truncate", children: "Personnaliser la barre" })] })] }));
49
+ }
50
+ /** Petit bouton d'une ligne du réglage : 44 px au doigt (`touch-target`), 24 px à la souris. */
51
+ function RowButton({ icon, label, disabled, onClick, }) {
52
+ return (_jsx("button", { type: "button", "aria-label": label, disabled: disabled, onClick: onClick, className: "touch-target grid size-6 shrink-0 place-items-center rounded-lg 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 disabled:pointer-events-none disabled:opacity-30 lg:text-shell-muted lg:hover:bg-shell-active lg:hover:text-shell-foreground", children: _jsx(HugeiconsIcon, { icon: icon, strokeWidth: 2, "aria-hidden": true, className: "size-3.5" }) }));
53
+ }
54
+ /**
55
+ * Le réglage de la barre : un brouillon montré tel quel, gardé seulement par « Valider ». Glisser à
56
+ * la souris ; les flèches servent au clavier et au doigt, le glisser natif n'y fonctionne pas.
57
+ */
58
+ function ModuleBarEditor({ order, onSave, onCancel, }) {
59
+ const [draft, setDraft] = useState(order);
60
+ const [dragged, setDragged] = useState(null);
61
+ const hidden = hiddenModules(draft);
62
+ return (_jsxs("div", { "data-slot": "shell-modules-editor", role: "group", "aria-label": "Personnaliser la barre", className: "min-h-0 flex-1 overflow-y-auto px-3 pb-2", children: [_jsx("p", { className: GROUP_LABEL, children: "Dans la barre" }), _jsx("ul", { className: "grid gap-0.5", children: draft.map((word, index) => {
63
+ const { name } = moduleByWord(word);
64
+ return (_jsxs("li", { draggable: true, onDragStart: (event) => {
65
+ event.dataTransfer.effectAllowed = "move";
66
+ setDragged(index);
67
+ }, onDragOver: (event) => {
68
+ if (dragged !== null)
69
+ event.preventDefault();
70
+ }, onDrop: (event) => {
71
+ event.preventDefault();
72
+ if (dragged !== null)
73
+ setDraft(moveModule(draft, dragged, index));
74
+ setDragged(null);
75
+ }, onDragEnd: () => setDragged(null), className: "flex min-h-11 cursor-grab items-center gap-1 rounded-xl bg-muted/70 pr-1 pl-1.5 text-sm font-medium lg:min-h-9 lg:bg-shell-hover coarse:min-h-11", children: [_jsx(HugeiconsIcon, { icon: DragDropVerticalIcon, "aria-hidden": true, className: "size-3.5 shrink-0 text-muted-foreground lg:text-shell-muted" }), _jsx(ProductBadge, { product: word, className: "size-[1.125rem] shrink-0" }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: name }), _jsx(RowButton, { icon: ArrowUp01Icon, label: `Monter ${name}`, disabled: index === 0, onClick: () => setDraft(moveModule(draft, index, index - 1)) }), _jsx(RowButton, { icon: ArrowDown01Icon, label: `Descendre ${name}`, disabled: index === draft.length - 1, onClick: () => setDraft(moveModule(draft, index, index + 1)) }), _jsx(RowButton, { icon: MinusSignIcon, label: `Masquer ${name}`, onClick: () => setDraft(draft.filter((other) => other !== word)) })] }, word));
76
+ }) }), _jsx("p", { className: GROUP_LABEL, children: "Masqu\u00E9s" }), hidden.length ? (_jsx("ul", { className: "grid gap-0.5", children: hidden.map(({ word, name, purpose }) => (_jsxs("li", { className: "flex min-h-11 items-center gap-2.5 rounded-xl pr-1 pl-3 text-sm font-medium lg:min-h-9 coarse:min-h-11", children: [_jsx(ProductBadge, { product: word, className: "size-[1.125rem] shrink-0 opacity-70" }), _jsxs("span", { className: "min-w-0 flex-1 py-1", children: [_jsx("span", { className: "block truncate", children: name }), _jsx("span", { className: "block truncate text-xs font-normal text-muted-foreground lg:text-shell-muted", children: purpose })] }), _jsx(RowButton, { icon: Add01Icon, label: `Ajouter ${name}`, onClick: () => setDraft([...draft, word]) })] }, word))) })) : (_jsx("p", { className: "px-3 py-1 text-xs text-muted-foreground lg:text-shell-muted", children: "Tous les modules sont dans la barre." })), _jsxs("div", { className: "flex gap-2 px-1 pt-3", children: [_jsx(Button, { size: "sm", onClick: () => onSave(draft), children: "Valider" }), _jsx(Button, { size: "sm", variant: "ghost", onClick: onCancel, children: "Annuler" })] })] }));
77
+ }
78
+ /**
79
+ * Le milieu de la barre de Basalte : Manager, un trait, puis l'un des trois niveaux. Dans un module,
80
+ * ses onglets (`nav`) sous « Retour aux modules » ; un clic y affiche la liste sans rien recharger ;
81
+ * un clic sur un autre module ouvre sa console, préchargée au survol sous la même origine.
82
+ */
83
+ export function ShellNavigation({ product, nav, accountHref, level, onLevelChange, }) {
84
+ const pageOrigin = usePageOrigin();
85
+ const order = useModuleBar();
86
+ const { products, account } = shellProductEntries({ current: product, accountHref, pageOrigin });
87
+ return (_jsxs("div", { "data-slot": "shell-navigation", className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { "data-slot": "shell-manager", className: "shrink-0 px-3 pt-1", children: [_jsx("ul", { children: _jsx(ShellEntryLink, { entry: account, icon: UserGroupIcon }) }), _jsx("div", { "aria-hidden": true, className: "mx-2 mt-2 h-px bg-foreground/8" })] }), level === "modules" ? (_jsx(ModuleList, { entries: products, order: shownModules(order, product), onOpenCurrent: () => onLevelChange("tabs"), onCustomize: () => onLevelChange("edit") })) : level === "edit" ? (_jsx(ModuleBarEditor, { order: order, onSave: (next) => {
88
+ saveModuleBar(next);
89
+ onLevelChange("modules");
90
+ }, onCancel: () => onLevelChange("modules") })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "shrink-0 px-3 pt-2", children: [_jsx("ul", { children: _jsx(SidebarNavBack, { label: "Retour aux modules", keepMenuOpen: true, render: _jsx("button", { type: "button", className: "w-full", onClick: () => onLevelChange("modules") }) }) }), _jsxs("div", { "data-slot": "shell-module-title", className: "flex min-h-9 items-center gap-2.5 px-3 text-sm font-semibold lg:text-shell-foreground lg:rail:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-[1.125rem] shrink-0" }), _jsx("span", { className: "truncate font-heading", children: familyProductName(product) })] })] }), nav] }))] }));
91
+ }
@@ -0,0 +1,31 @@
1
+ import type { FamilyProduct } from "./family-svg.ts";
2
+ /**
3
+ * Les modules de Basalte, un produit unique (organisation, projets, modules). Le mot technique
4
+ * (`host`) reste dans les adresses, les clés et le code ; l'interface n'affiche que le nom du module.
5
+ * Logique pure : la barre (`module-bar.tsx`) et son stockage (`module-bar-store.ts`) s'y appuient.
6
+ */
7
+ export type ModuleWord = Extract<FamilyProduct, "host" | "mail" | "status" | "analytics" | "id" | "docs" | "iassistant">;
8
+ export type BasalteModule = {
9
+ word: ModuleWord;
10
+ /** Nom affiché : « Déploiements ». */
11
+ name: string;
12
+ /** Ce que le module apporte, en quelques mots : montré à côté d'un module masqué. */
13
+ purpose: string;
14
+ };
15
+ /** Tous les modules, dans leur ordre par défaut. */
16
+ export declare const BASALTE_MODULES: ReadonlyArray<BasalteModule>;
17
+ /** Les modules montrés tant que la personne n'a rien choisi. */
18
+ export declare const DEFAULT_MODULE_BAR: ReadonlyArray<ModuleWord>;
19
+ export declare function isModuleWord(value: unknown): value is ModuleWord;
20
+ export declare function moduleByWord(word: ModuleWord): BasalteModule;
21
+ /** Ordre retenu (stockage, réseau) : les mots connus, sans doublon. `null` si ce n'est pas une liste. */
22
+ export declare function parseModuleBar(value: unknown): Array<ModuleWord> | null;
23
+ /**
24
+ * La liste à montrer : l'ordre de la personne, plus le module ouvert s'il n'y est pas (on ne perd
25
+ * jamais de vue où l'on est).
26
+ */
27
+ export declare function shownModules(order: ReadonlyArray<ModuleWord>, current?: string): Array<ModuleWord>;
28
+ /** Les modules absents de la liste, dans l'ordre par défaut. */
29
+ export declare function hiddenModules(shown: ReadonlyArray<ModuleWord>): Array<BasalteModule>;
30
+ /** Déplace l'entrée `from` à la place `to` ; hors limites, la liste reste telle quelle. */
31
+ export declare function moveModule<T>(list: ReadonlyArray<T>, from: number, to: number): Array<T>;
@@ -0,0 +1,47 @@
1
+ /** Tous les modules, dans leur ordre par défaut. */
2
+ export const BASALTE_MODULES = [
3
+ { word: "host", name: "Déploiements", purpose: "Mettre un site en ligne" },
4
+ { word: "mail", name: "E-mails", purpose: "Envoyer des e-mails" },
5
+ { word: "status", name: "Surveillance", purpose: "Surveiller un site" },
6
+ { word: "analytics", name: "Statistiques", purpose: "Compter les visites" },
7
+ { word: "id", name: "Connexion", purpose: "Connecter tes utilisateurs" },
8
+ { word: "docs", name: "Documentation", purpose: "Publier une documentation" },
9
+ { word: "iassistant", name: "Assistant IA", purpose: "Répondre aux visiteurs" },
10
+ ];
11
+ /** Les modules montrés tant que la personne n'a rien choisi. */
12
+ export const DEFAULT_MODULE_BAR = ["host", "mail", "status", "analytics"];
13
+ export function isModuleWord(value) {
14
+ return BASALTE_MODULES.some((item) => item.word === value);
15
+ }
16
+ export function moduleByWord(word) {
17
+ const found = BASALTE_MODULES.find((item) => item.word === word);
18
+ if (!found)
19
+ throw new Error(`@hgdc/ui/modules : module inconnu « ${word} »`);
20
+ return found;
21
+ }
22
+ /** Ordre retenu (stockage, réseau) : les mots connus, sans doublon. `null` si ce n'est pas une liste. */
23
+ export function parseModuleBar(value) {
24
+ if (!Array.isArray(value))
25
+ return null;
26
+ return [...new Set(value.filter(isModuleWord))];
27
+ }
28
+ /**
29
+ * La liste à montrer : l'ordre de la personne, plus le module ouvert s'il n'y est pas (on ne perd
30
+ * jamais de vue où l'on est).
31
+ */
32
+ export function shownModules(order, current) {
33
+ return isModuleWord(current) && !order.includes(current) ? [...order, current] : [...order];
34
+ }
35
+ /** Les modules absents de la liste, dans l'ordre par défaut. */
36
+ export function hiddenModules(shown) {
37
+ return BASALTE_MODULES.filter((item) => !shown.includes(item.word));
38
+ }
39
+ /** Déplace l'entrée `from` à la place `to` ; hors limites, la liste reste telle quelle. */
40
+ export function moveModule(list, from, to) {
41
+ if (from < 0 || from >= list.length || to < 0 || to >= list.length)
42
+ return [...list];
43
+ const next = [...list];
44
+ const [item] = next.splice(from, 1);
45
+ next.splice(to, 0, item);
46
+ return next;
47
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * La place de l'organisation tant que le produit ne l'a pas (ses données arrivent) : la marque de
3
+ * la dernière organisation affichée sous cette origine, au pixel près de celle d'`OrgSwitcher`
4
+ * (initiales ou logo, posés sur `<html>` par `sidebarInitScript` avant la première peinture),
5
+ * sinon un carré de squelette de la même taille. Rien ne bouge quand la vraie arrive.
6
+ */
7
+ export declare function OrgPlaceholder(): React.JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * La place de l'organisation tant que le produit ne l'a pas (ses données arrivent) : la marque de
4
+ * la dernière organisation affichée sous cette origine, au pixel près de celle d'`OrgSwitcher`
5
+ * (initiales ou logo, posés sur `<html>` par `sidebarInitScript` avant la première peinture),
6
+ * sinon un carré de squelette de la même taille. Rien ne bouge quand la vraie arrive.
7
+ */
8
+ export function OrgPlaceholder() {
9
+ return (_jsx("div", { "data-slot": "org-placeholder", "aria-hidden": true, className: "flex h-10 min-w-0 flex-1 items-center px-1.5 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" }) }));
10
+ }