@hgdc/ui 0.64.0 → 0.65.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,3 +1,4 @@
1
+ import type { ModuleWord } from "./modules.ts";
1
2
  import { type ReactNode } from "react";
2
3
  import type { ProductView } from "./addresses.ts";
3
4
  import type { FamilyProduct } from "./family-svg.ts";
@@ -20,7 +21,7 @@ export type { AppShellAssistant } from "./shell-assistant.tsx";
20
21
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
21
22
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
22
23
  */
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, }: {
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, project, }: {
24
25
  /** Le produit affiché (`"host"`) : son nom au-dessus de ses onglets, « vous êtes ici » dans la liste des modules. */
25
26
  product: FamilyProduct;
26
27
  /**
@@ -101,4 +102,13 @@ export declare function AppShell({ product, organization, productRail: productRa
101
102
  * de test), ou `false` pour ne rien lire. L'accès « État du service » y mène.
102
103
  */
103
104
  statusPage?: string | false | undefined;
105
+ /**
106
+ * Le projet choisi en haut de page (`useShellProject`) : la liste des modules se limite aux siens, et
107
+ * « Réglages du projet » paraît en bas de la barre. Absent : tous les projets.
108
+ */
109
+ project?: {
110
+ name: string;
111
+ modules: ReadonlyArray<ModuleWord>;
112
+ settingsHref: string;
113
+ } | undefined;
104
114
  }): React.JSX.Element;
package/dist/app-shell.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Settings02Icon from "@hugeicons/core-free-icons/Settings02Icon";
3
+ import { SIDEBAR_ITEM } from "./sidebar-nav.js";
2
4
  import Menu01Icon from "@hugeicons/core-free-icons/Menu01Icon";
3
5
  import { HugeiconsIcon } from "@hugeicons/react";
4
6
  import { Suspense, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
@@ -38,7 +40,7 @@ export { SidebarNavGroup } from "./sidebar-nav-group.js";
38
40
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
39
41
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
40
42
  */
41
- export function AppShell({ product, organization, productRail: productRailOption, view: viewOption, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, }) {
43
+ export function AppShell({ product, organization, productRail: productRailOption, view: viewOption, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, project, }) {
42
44
  const [menuOpen, setMenuOpen] = useState(false);
43
45
  const preference = useProductBarPreference();
44
46
  // Relu chez Account seulement par un produit passé aux deux vues (il donne `view`, ADR 0014).
@@ -100,7 +102,7 @@ export function AppShell({ product, organization, productRail: productRailOption
100
102
  useAssistantShortcut(assistant);
101
103
  const otherView = viewOption === undefined ? null : _jsx(OtherViewLink, { product: product, view: viewOption });
102
104
  // 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] }));
105
+ const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, productRail && project ? (_jsx("div", { className: "px-3 pb-1", children: _jsxs("a", { href: project.settingsHref, className: SIDEBAR_ITEM, children: [_jsx(HugeiconsIcon, { icon: Settings02Icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-5 shrink-0 lg:size-[1.125rem]" }), _jsx("span", { className: "truncate lg:rail:sr-only", children: "R\u00E9glages du projet" })] }) })) : null, _jsx(ShellCommonLinks, { links: productRail ? { ...(links?.settings ? { settings: links.settings } : {}), status: false } : (links ?? {}), statusPageUrl: statusPageUrl, otherView: productRail ? null : otherView }), account ?? sidebarFooter] }));
104
106
  const menuLinks = productRail
105
107
  ? {
106
108
  documentation: links?.documentation,
@@ -108,6 +110,6 @@ export function AppShell({ product, organization, productRail: productRailOption
108
110
  otherView: viewOption === undefined ? undefined : otherViewMenuLink(product, viewOption),
109
111
  }
110
112
  : {};
111
- const navigation = productRail ? (_jsx(ShellNavigation, { product: product, nav: nav, accountHref: accountHref, level: navLevel, onLevelChange: setNavLevel })) : (nav);
113
+ const navigation = productRail ? (_jsx(ShellNavigation, { product: product, nav: nav, accountHref: accountHref, level: navLevel, onLevelChange: setNavLevel, projectModules: project?.modules })) : (nav);
112
114
  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 })] }) })] }) }) }) }) }) }));
113
115
  }
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { FamilyProduct } from "./family-svg.ts";
3
+ import { type ModuleWord } from "./modules.ts";
3
4
  /** Ce que montre le milieu de la barre : les onglets du module ouvert, la liste des modules, ou son réglage. */
4
5
  export type ShellNavLevel = "tabs" | "modules" | "edit";
5
6
  /**
@@ -7,10 +8,12 @@ export type ShellNavLevel = "tabs" | "modules" | "edit";
7
8
  * ses onglets (`nav`) sous « Retour aux modules » ; un clic y affiche la liste sans rien recharger ;
8
9
  * un clic sur un autre module ouvre sa console, préchargée au survol sous la même origine.
9
10
  */
10
- export declare function ShellNavigation({ product, nav, accountHref, level, onLevelChange, }: {
11
+ export declare function ShellNavigation({ product, nav, accountHref, level, onLevelChange, projectModules, }: {
11
12
  product: FamilyProduct;
12
13
  nav: ReactNode;
13
14
  accountHref?: string | undefined;
14
15
  level: ShellNavLevel;
15
16
  onLevelChange: (level: ShellNavLevel) => void;
17
+ /** Modules activés sur le projet choisi : la liste s'y limite. Absent : tous les projets. */
18
+ projectModules?: ReadonlyArray<ModuleWord> | undefined;
16
19
  }): React.JSX.Element;
@@ -80,11 +80,11 @@ function ModuleBarEditor({ order, onSave, onCancel, }) {
80
80
  * ses onglets (`nav`) sous « Retour aux modules » ; un clic y affiche la liste sans rien recharger ;
81
81
  * un clic sur un autre module ouvre sa console, préchargée au survol sous la même origine.
82
82
  */
83
- export function ShellNavigation({ product, nav, accountHref, level, onLevelChange, }) {
83
+ export function ShellNavigation({ product, nav, accountHref, level, onLevelChange, projectModules, }) {
84
84
  const pageOrigin = usePageOrigin();
85
85
  const order = useModuleBar();
86
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) => {
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).filter((word) => !projectModules || word === product || projectModules.includes(word)), onOpenCurrent: () => onLevelChange("tabs"), onCustomize: () => onLevelChange("edit") })) : level === "edit" ? (_jsx(ModuleBarEditor, { order: order, onSave: (next) => {
88
88
  saveModuleBar(next);
89
89
  onLevelChange("modules");
90
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] }))] }));
@@ -0,0 +1,30 @@
1
+ import type { ModuleWord } from "./modules.ts";
2
+ export declare function saveShellProject(organizationId: string, projectId: string | null): void;
3
+ /** L'identifiant du projet choisi ; `null` : tous les projets, et toujours au rendu serveur. */
4
+ export declare function useShellProject(organizationId: string | null | undefined): string | null;
5
+ /** Un projet commun, tel que le rend `listCoreProjects` de `@hgdc/auth/core`. */
6
+ export type ShellCoreProject = {
7
+ id: string;
8
+ name: string;
9
+ modules: ReadonlyArray<string>;
10
+ };
11
+ /** Le projet choisi, s'il existe encore et si le module y est activé. Sinon : tous les projets. */
12
+ export declare function activeCoreProject<Project extends ShellCoreProject>(projects: ReadonlyArray<Project> | undefined, activeId: string | null): Project | null;
13
+ /** Ce que `project` d'`AppShell` attend pour le projet choisi. */
14
+ export declare function shellProjectFrom(project: ShellCoreProject | null, settingsHref: string): {
15
+ name: string;
16
+ modules: ReadonlyArray<ModuleWord>;
17
+ settingsHref: string;
18
+ } | undefined;
19
+ /**
20
+ * Le sélecteur de projet de Basalte, dans `header` d'`AppShell`. Le choix vaut pour toutes les consoles.
21
+ * `onChange` : ce que le module fait en plus, par exemple ouvrir la page du projet. Rien à montrer
22
+ * tant que l'organisation n'a aucun projet.
23
+ */
24
+ export declare function CoreProjectSwitcher({ organizationId, projects, createHref, onChange, }: {
25
+ organizationId: string;
26
+ projects: ReadonlyArray<ShellCoreProject> | undefined;
27
+ /** « Nouveau projet » : la page Projets de Manager. */
28
+ createHref?: string;
29
+ onChange?: (projectId: string | null) => void;
30
+ }): React.JSX.Element | null;
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from "react";
3
+ import { ProjectSwitcher } from "./project-switcher.js";
4
+ /**
5
+ * Le projet choisi en haut de page, le même dans toutes les consoles de Basalte : sous `app.hgdc.ch`,
6
+ * elles partagent l'origine, donc ce réglage. Une valeur par organisation : changer d'organisation
7
+ * ne garde pas le projet d'une autre. Propre à chaque personne et à chaque navigateur.
8
+ */
9
+ const keyOf = (organizationId) => `hgdc-shell:project:${organizationId}`;
10
+ const EVENT = "hgdc-shell-project";
11
+ function subscribe(onChange) {
12
+ window.addEventListener(EVENT, onChange);
13
+ window.addEventListener("storage", onChange);
14
+ return () => {
15
+ window.removeEventListener(EVENT, onChange);
16
+ window.removeEventListener("storage", onChange);
17
+ };
18
+ }
19
+ export function saveShellProject(organizationId, projectId) {
20
+ try {
21
+ if (projectId === null)
22
+ window.localStorage.removeItem(keyOf(organizationId));
23
+ else
24
+ window.localStorage.setItem(keyOf(organizationId), projectId);
25
+ }
26
+ catch {
27
+ // Stockage bloqué : le choix vaut seulement pour cette page.
28
+ }
29
+ window.dispatchEvent(new Event(EVENT));
30
+ }
31
+ /** L'identifiant du projet choisi ; `null` : tous les projets, et toujours au rendu serveur. */
32
+ export function useShellProject(organizationId) {
33
+ return useSyncExternalStore(subscribe, () => {
34
+ if (!organizationId)
35
+ return null;
36
+ try {
37
+ return window.localStorage.getItem(keyOf(organizationId));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }, () => null);
43
+ }
44
+ /** Le projet choisi, s'il existe encore et si le module y est activé. Sinon : tous les projets. */
45
+ export function activeCoreProject(projects, activeId) {
46
+ return projects?.find((project) => project.id === activeId) ?? null;
47
+ }
48
+ /** Ce que `project` d'`AppShell` attend pour le projet choisi. */
49
+ export function shellProjectFrom(project, settingsHref) {
50
+ if (!project)
51
+ return undefined;
52
+ return { name: project.name, modules: project.modules, settingsHref };
53
+ }
54
+ /**
55
+ * Le sélecteur de projet de Basalte, dans `header` d'`AppShell`. Le choix vaut pour toutes les consoles.
56
+ * `onChange` : ce que le module fait en plus, par exemple ouvrir la page du projet. Rien à montrer
57
+ * tant que l'organisation n'a aucun projet.
58
+ */
59
+ export function CoreProjectSwitcher({ organizationId, projects, createHref, onChange, }) {
60
+ const activeId = useShellProject(organizationId);
61
+ if (projects !== undefined && projects.length === 0)
62
+ return null;
63
+ const known = activeCoreProject(projects, activeId);
64
+ const entries = projects?.map(({ id, name }) => ({ id, name }));
65
+ return (_jsx(ProjectSwitcher, { projects: entries, activeId: known?.id ?? null, ...(createHref ? { createHref } : {}), onSelect: (id) => {
66
+ saveShellProject(organizationId, id);
67
+ onChange?.(id);
68
+ } }));
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "description": "Interface de la H Collection : jetons de la DA, thèmes, polices et composants.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",