@hgdc/ui 0.9.0 → 0.10.1

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
@@ -41,7 +41,7 @@ Primitives déjà là : `avatar` `badge` `button` `dialog` `drawer` `dropdown-me
41
41
 
42
42
  | Import | Exemple |
43
43
  | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
- | `app-shell` | `<AppShell sidebarHeader={<OrgSwitcher …/>} nav={<SidebarNav label="Projet">…</SidebarNav>} sidebarFooter={<AccountMenu …/>}>` |
44
+ | `app-shell` | `<AppShell product="mail" organization={<OrgSwitcher …/>} nav={<SidebarNav label="Projet">…</SidebarNav>} sidebarFooter={<AccountMenu …/>}>` |
45
45
  | | `<SidebarNavItem icon={Rocket01Icon} label="Déploiements" render={<Link to="/deployments" />} />` |
46
46
  | `org-switcher` | `<OrgSwitcher organizations={orgs} activeId={org.id} onSelect={go} createHref="/organisations/nouvelle" />` |
47
47
  | `account-menu` | `<AccountMenu user={user} onProfile={…} onSecurity={…} onSignOut={signOut} product="Mail" />` : Account et les produits ouverts du catalogue, Mail coché |
@@ -50,7 +50,7 @@ Primitives déjà là : `avatar` `badge` `button` `dialog` `drawer` `dropdown-me
50
50
  | `kbd` | `<KbdGroup><Kbd>Ctrl</Kbd><Kbd>K</Kbd></KbdGroup>` |
51
51
  | `page-header` | `<PageHeader title="Domaines" breadcrumbs={[{ label: "Acme", render: <Link to="/o/acme" /> }, { label: "Domaines" }]} actions={<Button>Ajouter</Button>} />` |
52
52
 
53
- Le menu « Produits HGDC » et la palette (`badge` d'une commande) montrent la pastille de chaque produit.
53
+ Le menu « Produits » et la palette (`badge` d'une commande) montrent la pastille de chaque produit.
54
54
 
55
55
  ## Identité H family
56
56
 
@@ -63,7 +63,7 @@ Le menu « Produits HGDC » et la palette (`badge` d'une commande) montrent la p
63
63
 
64
64
  Couleurs pleines ; pour éclaircir, mélanger au blanc `color-mix(in oklab, …)`, jamais `in oklch`. Une pastille reste seule, jamais dans une tuile de couleur. Les originaux vivent dans le dépôt du site hgdc.ch : les recopier dans `packages/ui/family/`, puis `bun scripts/family.ts && bun run format` (SVG embarqués et PNG des e-mails).
65
65
 
66
- 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).
66
+ Sur ordinateur, la coque ouvre sur la barre des produits (ADR 0011) : organisation en haut, pastilles des produits ouverts, Account en bas, 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 Account 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).
67
67
 
68
68
  ## Contenu et données
69
69
 
@@ -112,7 +112,7 @@ Filtres, tri et page vivent dans l'URL du produit : les composants sont contrôl
112
112
  | `money-field` | `<MoneyField value={centimes} onChange={…} />` : CHF en centimes, « 1’250.50 », accepte « 12.– » ; `formatChf`, `parseMoney` |
113
113
  | `address-fields` | `<AddressFields value={address} onChange={…} errors={validatePostalAddress(address)} />` : rue, numéro, NPA, localité, pays (facture QR) |
114
114
 
115
- `access-request` : `<AccessRequest endpoint="https://<deploiement>.convex.site/f/<id>" product="HGDC Mail" signInRender={<Link to="/sign-in" />} />`, la page « Demander un accès » d'un produit (les comptes HGDC s'ouvrent sur invitation). Poste en JSON vers un formulaire HGDC Mail Forms : prénom, nom, entreprise, e-mail professionnel, message facultatif, `produit` caché, piège `_gotcha`. États envoi, réussite, erreur, champ refusé ; `submitAccessRequest` s'appelle seul.
115
+ `access-request` : `<AccessRequest endpoint="https://<deploiement>.convex.site/f/<id>" product="Mail" signInRender={<Link to="/sign-in" />} />`, la page « Demander un accès » d'un produit (les comptes HGDC s'ouvrent sur invitation). Poste en JSON vers un formulaire HGDC Mail Forms : prénom, nom, entreprise, e-mail professionnel, message facultatif, `produit` caché, piège `_gotcha`. États envoi, réussite, erreur, champ refusé ; `submitAccessRequest` s'appelle seul.
116
116
 
117
117
  Les champs de date sont ceux du navigateur, sans calendrier maison (ADR 0006). Les fonctions pures (`parseMoney`, `contrastRatio`, `validatePostalAddress`, `formatDateRange`) s'importent seules, côté serveur aussi.
118
118
 
@@ -142,18 +142,18 @@ const { html, text } = await renderEmail(
142
142
  )
143
143
  ```
144
144
 
145
- | Bloc | Rôle |
146
- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
147
- | `EmailLayout` | Icône et nom du produit, carte, pied « Propulsé par HGDC Mail » et mentions légales ; `footer` (désabonnement), `name`, `iconSrc`, `accent` |
148
- | `EmailHeading` | Titre |
149
- | `EmailText` | Paragraphe ; `small` pour une note secondaire |
150
- | `EmailButton` | Lien direct, jamais réécrit ; absolu (`https:`, `mailto:`) ou erreur |
151
- | `EmailLink` | Lien dans le texte |
152
- | `EmailCode` | Code à usage unique, en deux groupes à l'œil, d'un bloc au copier |
153
- | `EmailCallout` | Encadré `neutral`, `info`, `success`, `warning`, `danger`, avec titre |
154
- | `EmailDivider` | Séparateur |
155
- | `EmailDetails` | Liste clé / valeur |
156
- | `renderEmail` | `{ html, text }` : HTML pour clients mail, texte tiré du même rendu |
145
+ | Bloc | Rôle |
146
+ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
147
+ | `EmailLayout` | Icône et nom du produit, carte, pied « Envoyé avec Mail de la H family » et mentions légales ; `footer` (désabonnement), `name`, `iconSrc`, `accent` |
148
+ | `EmailHeading` | Titre |
149
+ | `EmailText` | Paragraphe ; `small` pour une note secondaire |
150
+ | `EmailButton` | Lien direct, jamais réécrit ; absolu (`https:`, `mailto:`) ou erreur |
151
+ | `EmailLink` | Lien dans le texte |
152
+ | `EmailCode` | Code à usage unique, en deux groupes à l'œil, d'un bloc au copier |
153
+ | `EmailCallout` | Encadré `neutral`, `info`, `success`, `warning`, `danger`, avec titre |
154
+ | `EmailDivider` | Séparateur |
155
+ | `EmailDetails` | Liste clé / valeur |
156
+ | `renderEmail` | `{ html, text }` : HTML pour clients mail, texte tiré du même rendu |
157
157
 
158
158
  L'accent vient du catalogue (`accent` de `@hgdc/ui/products`), les icônes de `https://hgdc.ch/email/<mot>.png` (fichiers dans `@hgdc/ui/email-icons/`, tirés des pastilles par `bun scripts/family.ts`). Un exemple par type : `scripts/email-showcase.tsx`.
159
159
 
@@ -35,7 +35,7 @@ export declare function submitAccessRequest(endpoint: string, values: AccessRequ
35
35
  export declare function AccessRequest({ endpoint, product, title, description, messageLabel, signInHref, signInRender, homeHref, homeRender, fetcher, }: {
36
36
  /** Adresse d'envoi du formulaire Forms : `https://<deploiement>.convex.site/f/<id>`. */
37
37
  endpoint: string;
38
- /** Nom affiché et valeur du champ caché `produit` : « HGDC Mail ». */
38
+ /** Nom affiché et valeur du champ caché `produit` : « Mail ». */
39
39
  product: string;
40
40
  title?: string;
41
41
  description?: ReactNode;
@@ -49,7 +49,7 @@ function LinkSlot({ href, render, className, children, }) {
49
49
  props: mergeProps({ className, children }, render ? {} : { href }),
50
50
  });
51
51
  }
52
- export function AccessRequest({ endpoint, product, title = `Demander un accès à ${product}`, description = "Les comptes HGDC s'ouvrent sur invitation. Dis-nous qui tu es : on te répond rapidement et, si c'est bon, tu reçois une invitation par e-mail.", messageLabel = "Ton projet, en quelques mots (facultatif)", signInHref = "/sign-in", signInRender, homeHref = "/", homeRender, fetcher, }) {
52
+ export function AccessRequest({ endpoint, product, title = `Demander un accès à ${product}`, description = "Les comptes s'ouvrent sur invitation. Dis-nous qui tu es : on te répond rapidement et, si c'est bon, tu reçois une invitation par e-mail.", messageLabel = "Ton projet, en quelques mots (facultatif)", signInHref = "/sign-in", signInRender, homeHref = "/", homeRender, fetcher, }) {
53
53
  const [values, setValues] = useState(EMPTY_ACCESS_REQUEST);
54
54
  const [trap, setTrap] = useState("");
55
55
  const [pending, setPending] = useState(false);
@@ -75,10 +75,10 @@ export function AccessRequest({ endpoint, product, title = `Demander un accès
75
75
  setPending(false);
76
76
  }
77
77
  if (sentTo) {
78
- return (_jsxs("div", { "data-slot": "access-request", className: "grid rise gap-6", children: [_jsx(HugeiconsIcon, { icon: CheckmarkCircle02Icon, className: "size-10 pop-in text-status-active" }), _jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-semibold text-balance", children: "Demande envoy\u00E9e" }), _jsxs("p", { className: "mt-2 text-muted-foreground", children: ["Merci. Un accus\u00E9 de r\u00E9ception part vers ", sentTo, ". Si ta demande est accept\u00E9e, tu re\u00E7ois une invitation de HGDC Account pour cr\u00E9er ton compte."] })] }), _jsx(LinkSlot, { href: homeHref, render: homeRender, className: buttonVariants({ variant: "outline", size: "lg" }), children: "Retour \u00E0 l'accueil" })] }, "access-done"));
78
+ return (_jsxs("div", { "data-slot": "access-request", className: "grid rise gap-6", children: [_jsx(HugeiconsIcon, { icon: CheckmarkCircle02Icon, className: "size-10 pop-in text-status-active" }), _jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-semibold text-balance", children: "Demande envoy\u00E9e" }), _jsxs("p", { className: "mt-2 text-muted-foreground", children: ["Merci. Un accus\u00E9 de r\u00E9ception part vers ", sentTo, ". Si ta demande est accept\u00E9e, tu re\u00E7ois une invitation d'Account pour cr\u00E9er ton compte."] })] }), _jsx(LinkSlot, { href: homeHref, render: homeRender, className: buttonVariants({ variant: "outline", size: "lg" }), children: "Retour \u00E0 l'accueil" })] }, "access-done"));
79
79
  }
80
80
  const invalid = (key) => (fieldErrors[key] ? true : undefined);
81
81
  const field = (key, label, input) => (_jsxs(Field, { "data-invalid": invalid(key), children: [_jsx(FieldLabel, { htmlFor: `acces-${key}`, children: label }), input, fieldErrors[key] ? _jsx(FieldError, { children: fieldErrors[key] }) : null] }));
82
82
  const text = (key, autoComplete, max, type = "text") => (_jsx(Input, { id: `acces-${key}`, type: type, autoComplete: autoComplete, required: true, maxLength: max, value: values[key], onChange: set(key), "aria-invalid": invalid(key) }));
83
- return (_jsxs("form", { "data-slot": "access-request", onSubmit: submit, className: "grid rise gap-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-semibold text-balance", children: title }), description ? _jsx("p", { className: "mt-2 text-muted-foreground", children: description }) : null] }), failure ? (_jsx(Alert, { tone: "destructive", role: "alert", children: failure })) : null, _jsxs("div", { className: "grid gap-4", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [field("prenom", "Prénom", text("prenom", "given-name", 100)), field("nom", "Nom", text("nom", "family-name", 100))] }), field("entreprise", "Entreprise", text("entreprise", "organization", 120)), field("email", "E-mail professionnel", text("email", "email", 320, "email")), field("message", messageLabel, _jsx(Textarea, { id: "acces-message", rows: 3, maxLength: 2000, value: values.message, onChange: set("message"), "aria-invalid": invalid("message") })), _jsx("input", { type: "text", name: "_gotcha", tabIndex: -1, autoComplete: "off", "aria-hidden": "true", value: trap, onChange: (e) => setTrap(e.target.value), className: "absolute -left-[9999px] size-px opacity-0" })] }), _jsx(Button, { type: "submit", size: "lg", isLoading: pending, children: "Envoyer la demande" }), _jsxs("p", { className: "text-sm text-muted-foreground", children: ["D\u00E9j\u00E0 un compte HGDC ?", " ", _jsx(LinkSlot, { href: signInHref, render: signInRender, className: "font-medium text-foreground underline-offset-4 hover:underline", children: "Se connecter" })] })] }, "access"));
83
+ return (_jsxs("form", { "data-slot": "access-request", onSubmit: submit, className: "grid rise gap-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-semibold text-balance", children: title }), description ? _jsx("p", { className: "mt-2 text-muted-foreground", children: description }) : null] }), failure ? (_jsx(Alert, { tone: "destructive", role: "alert", children: failure })) : null, _jsxs("div", { className: "grid gap-4", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [field("prenom", "Prénom", text("prenom", "given-name", 100)), field("nom", "Nom", text("nom", "family-name", 100))] }), field("entreprise", "Entreprise", text("entreprise", "organization", 120)), field("email", "E-mail professionnel", text("email", "email", 320, "email")), field("message", messageLabel, _jsx(Textarea, { id: "acces-message", rows: 3, maxLength: 2000, value: values.message, onChange: set("message"), "aria-invalid": invalid("message") })), _jsx("input", { type: "text", name: "_gotcha", tabIndex: -1, autoComplete: "off", "aria-hidden": "true", value: trap, onChange: (e) => setTrap(e.target.value), className: "absolute -left-[9999px] size-px opacity-0" })] }), _jsx(Button, { type: "submit", size: "lg", isLoading: pending, children: "Envoyer la demande" }), _jsxs("p", { className: "text-sm text-muted-foreground", children: ["D\u00E9j\u00E0 un compte ?", " ", _jsx(LinkSlot, { href: signInHref, render: signInRender, className: "font-medium text-foreground underline-offset-4 hover:underline", children: "Se connecter" })] })] }, "access"));
84
84
  }
@@ -2,7 +2,7 @@ import { type IconSvgElement } from "@hugeicons/react";
2
2
  import type { ReactNode } from "react";
3
3
  import type { FamilyProduct } from "./family-svg.ts";
4
4
  export type HgdcProduct = {
5
- /** « HGDC Host ». */
5
+ /** « Host ». */
6
6
  name: string;
7
7
  href: string;
8
8
  /** Le produit ouvert : coché, sans lien. */
@@ -25,7 +25,7 @@ export type AccountUser = {
25
25
  * l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
26
26
  * `ThemeProvider`.
27
27
  *
28
- * « Produits HGDC » liste par défaut Account et les produits ouverts du catalogue
28
+ * « Produits » liste par défaut Account et les produits ouverts du catalogue
29
29
  * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
30
30
  * liste ; `products={[]}` la retire.
31
31
  */
@@ -31,7 +31,7 @@ function initials(name) {
31
31
  * l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
32
32
  * `ThemeProvider`.
33
33
  *
34
- * « Produits HGDC » liste par défaut Account et les produits ouverts du catalogue
34
+ * « Produits » liste par défaut Account et les produits ouverts du catalogue
35
35
  * (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
36
36
  * liste ; `products={[]}` la retire.
37
37
  */
@@ -42,7 +42,7 @@ export function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOu
42
42
  const next = THEMES.find((t) => t === value);
43
43
  if (next)
44
44
  setTheme(next);
45
- }, children: [_jsx(DropdownMenuRadioItem, { value: "light", children: "Clair" }), _jsx(DropdownMenuRadioItem, { value: "dark", children: "Sombre" }), _jsx(DropdownMenuRadioItem, { value: "system", children: "Syst\u00E8me" })] }) })] }), onShortcuts ? (_jsxs(DropdownMenuItem, { onClick: onShortcuts, children: [_jsx(HugeiconsIcon, { icon: KeyboardIcon, "aria-hidden": true }), "Raccourcis clavier", _jsx(DropdownMenuShortcut, { children: "?" })] })) : null, products.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Produits HGDC" }), 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, 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: onSignOut, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
45
+ }, children: [_jsx(DropdownMenuRadioItem, { value: "light", children: "Clair" }), _jsx(DropdownMenuRadioItem, { value: "dark", children: "Sombre" }), _jsx(DropdownMenuRadioItem, { value: "system", children: "Syst\u00E8me" })] }) })] }), onShortcuts ? (_jsxs(DropdownMenuItem, { onClick: onShortcuts, children: [_jsx(HugeiconsIcon, { icon: KeyboardIcon, "aria-hidden": true }), "Raccourcis clavier", _jsx(DropdownMenuShortcut, { children: "?" })] })) : null, 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, 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: onSignOut, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
46
46
  }
47
47
  function ProductMenuIcon({ product }) {
48
48
  if (product.badge)
@@ -1,5 +1,6 @@
1
1
  import { type IconSvgElement } from "@hugeicons/react";
2
2
  import { type ReactNode } from "react";
3
+ import type { FamilyProduct } from "./family-svg.ts";
3
4
  /**
4
5
  * À poser dans le `<head>`, avec `themeInitScript` : il remet le rail avant la première
5
6
  * peinture, et la variante CSS `rail:` le lit, donc rien ne saute au chargement.
@@ -33,17 +34,28 @@ export declare function SidebarNavItem({ icon, label, active, href, render, }: {
33
34
  render?: React.ReactElement;
34
35
  }): React.JSX.Element;
35
36
  /**
36
- * La coque commune des consoles HGDC, celle de Host. Sur ordinateur : colonne teintée à gauche
37
- * (organisation en haut, navigation, compte en bas), réductible en rail (`Ctrl+B`, mémorisé), et
38
- * la page sur une carte à côté. Sur téléphone : une barre en haut, le menu dans un tiroir qui
39
- * monte du bas et se referme quand on choisit une page.
37
+ * La coque commune des consoles HGDC, celle de Host. Sur ordinateur, de gauche à droite : la barre
38
+ * des produits de la famille (organisation en haut, pastilles, Account en bas, élargie au survol
39
+ * par-dessus la page), la barre latérale du produit (sa marque, sa navigation, le compte en bas),
40
+ * réductible en rail (`Ctrl+B`, mémorisé), et la page sur une carte. Sur téléphone : une barre en
41
+ * haut, le menu dans un tiroir qui monte du bas et se referme quand on choisit une page ; les
42
+ * produits restent dans le menu de l'avatar.
40
43
  *
41
44
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
42
45
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
43
46
  */
44
- export declare function AppShell({ sidebarHeader, nav, sidebarFooter, header, children, mainClassName, }: {
45
- /** Haut de la barre : `OrgSwitcher`. */
46
- sidebarHeader?: ReactNode;
47
+ export declare function AppShell({ product, organization, productRail, accountHref, nav, sidebarFooter, header, children, mainClassName, }: {
48
+ /** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
49
+ product: FamilyProduct;
50
+ /** `OrgSwitcher` : en haut de la barre des produits, en haut du tiroir sur téléphone. */
51
+ organization?: ReactNode;
52
+ /**
53
+ * `false` : pas de barre des produits (un écran hors de la famille). L'organisation passe alors
54
+ * sous la marque, dans la barre latérale.
55
+ */
56
+ productRail?: boolean;
57
+ /** Adresse d'Account dans la barre des produits (un Account local en développement). */
58
+ accountHref?: string | undefined;
47
59
  /** `SidebarNav` et ses `SidebarNavItem`. */
48
60
  nav: ReactNode;
49
61
  /** Bas de la barre : liens secondaires (Docs, Paramètres) et `AccountMenu`. */
package/dist/app-shell.js CHANGED
@@ -6,6 +6,10 @@ import { HugeiconsIcon } from "@hugeicons/react";
6
6
  import { useRender } from "@base-ui/react/use-render";
7
7
  import { createContext, useContext, useEffect, useState, useSyncExternalStore } from "react";
8
8
  import { Drawer, DrawerContent, DrawerTitle } from "./drawer.js";
9
+ import { isApplePlatform, Kbd, KbdGroup } from "./kbd.js";
10
+ import { ProductBadge } from "./product-badge.js";
11
+ import { ProductRail } from "./product-rail.js";
12
+ import { HGDC_ACCOUNT, HGDC_PRODUCTS } from "./products.js";
9
13
  import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
10
14
  import { cn } from "./cn.js";
11
15
  // ── Rail : la barre latérale réduite aux icônes, mémorisée par navigateur ──────────────
@@ -90,18 +94,42 @@ export function SidebarNavItem({ icon, label, active, href, render, }) {
90
94
  return (_jsx("li", { children: _jsxs(Tooltip, { disabled: !rail, children: [_jsx(TooltipTrigger, { render: link }), _jsx(TooltipContent, { side: "right", children: label })] }) }));
91
95
  }
92
96
  // ── Coque ─────────────────────────────────────────────────────────────────────────────
97
+ // Les noms de Hugeicons disent l'état du panneau, pas le geste : « Open » dessine la flèche vers la
98
+ // gauche (réduire), « Close » vers la droite (déplier).
99
+ const COLLAPSE_ICON = PanelLeftOpenIcon;
100
+ const EXPAND_ICON = PanelLeftCloseIcon;
101
+ /** Même boîte qu'une entrée de la barre en rail : 40 × 36 px, icône de 18 px centrée. */
102
+ const TOGGLE = "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";
103
+ function ShortcutHint({ label }) {
104
+ return (_jsxs("span", { className: "flex items-center gap-2", children: [label, _jsxs(KbdGroup, { children: [_jsx(Kbd, { children: isApplePlatform() ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: "B" })] })] }));
105
+ }
106
+ function productName(product) {
107
+ if (product === HGDC_ACCOUNT.badge)
108
+ return HGDC_ACCOUNT.name;
109
+ return HGDC_PRODUCTS.find((item) => item.badge === product)?.name ?? `HGDC ${product}`;
110
+ }
111
+ /**
112
+ * Haut de la barre latérale. Dépliée : la marque du produit, et le bouton « Réduire » à droite.
113
+ * En rail : la marque seule, qui devient le bouton « Déplier » au survol de la barre ou au focus,
114
+ * à la même place : le bouton ne descend jamais sous le logo. Les deux états sont dans la page et
115
+ * choisis par la variante CSS `rail:`, donc justes dès la première peinture.
116
+ */
117
+ function SidebarHead({ product }) {
118
+ return (_jsxs("div", { className: "mt-2 flex h-14 shrink-0 items-center gap-1 px-3", children: [_jsxs("div", { "data-slot": "sidebar-brand", className: "flex min-w-0 flex-1 items-center gap-2.5 pl-2.25 rail:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "truncate font-heading text-[0.9375rem] font-semibold", children: productName(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" }) })] })] }));
119
+ }
93
120
  /**
94
- * La coque commune des consoles HGDC, celle de Host. Sur ordinateur : colonne teintée à gauche
95
- * (organisation en haut, navigation, compte en bas), réductible en rail (`Ctrl+B`, mémorisé), et
96
- * la page sur une carte à côté. Sur téléphone : une barre en haut, le menu dans un tiroir qui
97
- * monte du bas et se referme quand on choisit une page.
121
+ * La coque commune des consoles HGDC, celle de Host. Sur ordinateur, de gauche à droite : la barre
122
+ * des produits de la famille (organisation en haut, pastilles, Account en bas, élargie au survol
123
+ * par-dessus la page), la barre latérale du produit (sa marque, sa navigation, le compte en bas),
124
+ * réductible en rail (`Ctrl+B`, mémorisé), et la page sur une carte. Sur téléphone : une barre en
125
+ * haut, le menu dans un tiroir qui monte du bas et se referme quand on choisit une page ; les
126
+ * produits restent dans le menu de l'avatar.
98
127
  *
99
128
  * Le kit ne connaît ni le routeur ni l'authentification : le produit passe ses éléments
100
129
  * (`OrgSwitcher`, `SidebarNav`, `AccountMenu`) et ses liens.
101
130
  */
102
- export function AppShell({ sidebarHeader, nav, sidebarFooter, header, children, mainClassName, }) {
103
- const rail = useSidebarRail();
131
+ export function AppShell({ product, organization, productRail = true, accountHref, nav, sidebarFooter, header, children, mainClassName, }) {
104
132
  const [menuOpen, setMenuOpen] = useState(false);
105
133
  useToggleShortcut();
106
- return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsxs("div", { "data-slot": "app-shell", className: "min-h-dvh lg:grid lg:h-dvh lg:grid-cols-[auto_minmax(0,1fr)] 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", { "aria-label": "Barre lat\u00E9rale", className: "hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-drawer) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsxs("div", { className: "mt-2 flex min-h-14 shrink-0 items-center gap-1 px-3 rail:flex-col rail:justify-center rail:px-0 rail:py-1", children: [_jsx("div", { className: "min-w-0 flex-1 rail:flex-none", children: sidebarHeader }), _jsx("button", { type: "button", onClick: toggleSidebar, "aria-label": rail ? "Déplier la barre latérale" : "Réduire la barre latérale", "aria-keyshortcuts": "Control+B Meta+B", title: rail ? "Déplier (Ctrl+B)" : "Réduire (Ctrl+B)", className: "grid size-8 shrink-0 place-items-center rounded-lg 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 active:scale-[0.96]", children: _jsx(HugeiconsIcon, { icon: rail ? PanelLeftCloseIcon : PanelLeftOpenIcon, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem]" }) })] }), nav, sidebarFooter ? _jsx("div", { className: "grid gap-0.5 px-3 pt-1 pb-3", children: sidebarFooter }) : null] }), _jsx("div", { className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { className: "flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex h-14 shrink-0 items-center gap-1 border-b border-foreground/5 bg-background px-2 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" }) }), _jsx("div", { className: "flex min-w-0 flex-1 items-center gap-2", children: header })] }), _jsx("main", { id: "contenu", tabIndex: -1, 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: children })] }) }), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), sidebarHeader ? _jsx("div", { className: "px-3 pt-1", children: sidebarHeader }) : null, nav, sidebarFooter ? (_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)]", children: sidebarFooter })) : null] }) })] }) }));
134
+ return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsxs("div", { "data-slot": "app-shell", className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)]" : "lg:grid-cols-[auto_minmax(0,1fr)]"), 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(ProductRail, { current: product, organization: organization, accountHref: accountHref }) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-drawer) 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, sidebarFooter ? _jsx("div", { className: "grid gap-0.5 px-3 pt-1 pb-3", children: sidebarFooter }) : null] }), _jsx("div", { className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { className: "flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex h-14 shrink-0 items-center gap-1 border-b border-foreground/5 bg-background px-2 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" }) }), _jsx("div", { className: "flex min-w-0 flex-1 items-center gap-2", children: header })] }), _jsx("main", { id: "contenu", tabIndex: -1, 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: children })] }) }), _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, sidebarFooter ? (_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)]", children: sidebarFooter })) : null] }) })] }) }));
107
135
  }
@@ -5,7 +5,7 @@ export type Command = {
5
5
  id: string;
6
6
  label: string;
7
7
  icon?: IconSvgElement;
8
- /** Pastille d'un produit (« Ouvrir HGDC Mail »), à la place de `icon`. */
8
+ /** Pastille d'un produit (« Ouvrir Mail »), à la place de `icon`. */
9
9
  badge?: FamilyProduct;
10
10
  /** Autres mots qui trouvent la commande : « dns » pour « Ajouter un domaine ». */
11
11
  keywords?: Array<string>;
package/dist/email.js CHANGED
@@ -85,7 +85,7 @@ export function EmailLayout({ product: ref, preview, children, name, iconSrc, ac
85
85
  border: `1px solid ${l.border}`,
86
86
  borderRadius: 16,
87
87
  padding: "32px 28px",
88
- }, 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: ["Propuls\u00E9 par", " ", _jsx(Link, { href: MAIL_HREF, className: "hg-muted", style: { color: l.muted, textDecoration: "underline" }, children: "HGDC Mail" })] })] }), _jsx(Text, { className: "hg-muted", style: { ...muted, margin: "4px 0 0" }, children: LEGAL })] })] }) }) })] }));
88
+ }, 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 family" })] })] }), _jsx(Text, { className: "hg-muted", style: { ...muted, margin: "4px 0 0" }, children: LEGAL })] })] }) }) })] }));
89
89
  }
90
90
  export function EmailHeading({ children }) {
91
91
  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 }));
@@ -16,7 +16,9 @@ export declare function OrgMark({ org, className, }: {
16
16
  className?: string;
17
17
  }): React.JSX.Element;
18
18
  /**
19
- * Haut de la barre latérale : l'organisation active, et le menu pour en changer ou en créer une.
19
+ * L'organisation active, et le menu pour en changer ou en créer une. En haut de la barre des
20
+ * produits d'`AppShell` (la marque seule, le nom quand la barre s'élargit), dans le tiroir sur
21
+ * téléphone, ou en haut de la barre latérale quand le produit coupe la barre des produits.
20
22
  * Présentationnel : le produit passe la liste, l'active et ce qu'il faut faire au choix (changer
21
23
  * l'URL `/o/<slug>/…`), le kit n'appelle rien.
22
24
  */
@@ -5,6 +5,7 @@ import UnfoldMoreIcon from "@hugeicons/core-free-icons/UnfoldMoreIcon";
5
5
  import { HugeiconsIcon } from "@hugeicons/react";
6
6
  import { Menu as MenuPrimitive } from "@base-ui/react/menu";
7
7
  import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
8
+ import { useShellPlacement } from "./shell-placement.js";
8
9
  import { cn } from "./cn.js";
9
10
  export const ORG_ROLE_LABEL = {
10
11
  owner: "Propriétaire",
@@ -20,13 +21,24 @@ export function OrgMark({ org, className, }) {
20
21
  return (_jsx("span", { "aria-hidden": true, className: cn("grid size-8 shrink-0 place-items-center rounded-lg bg-foreground text-xs font-semibold text-background uppercase", className), children: org.name.slice(0, 2) }));
21
22
  }
22
23
  /**
23
- * Haut de la barre latérale : l'organisation active, et le menu pour en changer ou en créer une.
24
+ * L'organisation active, et le menu pour en changer ou en créer une. En haut de la barre des
25
+ * produits d'`AppShell` (la marque seule, le nom quand la barre s'élargit), dans le tiroir sur
26
+ * téléphone, ou en haut de la barre latérale quand le produit coupe la barre des produits.
24
27
  * Présentationnel : le produit passe la liste, l'active et ce qu'il faut faire au choix (changer
25
28
  * l'URL `/o/<slug>/…`), le kit n'appelle rien.
26
29
  */
27
30
  export function OrgSwitcher({ organizations, activeId, onSelect, createHref, onCreate, className, }) {
31
+ const placement = useShellPlacement();
28
32
  const active = organizations.find((org) => org.id === activeId);
29
33
  if (!active)
30
34
  return null;
31
- return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": `Organisation ${active.name}, changer d'organisation`, className: cn("flex h-10 w-full min-w-0 items-center gap-2.5 rounded-xl px-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:w-auto lg:rail:justify-center lg:rail:px-1", className) }), children: [_jsx(OrgMark, { org: active }), _jsxs("span", { className: "min-w-0 flex-1 lg:rail:hidden", children: [_jsx("span", { className: "block truncate text-sm font-medium", children: active.name }), active.role ? (_jsx("span", { className: "block truncate text-xs text-muted-foreground lg:text-shell-muted", children: ORG_ROLE_LABEL[active.role] })) : null] }), _jsx(HugeiconsIcon, { icon: UnfoldMoreIcon, "aria-hidden": true, className: "size-4 shrink-0 text-muted-foreground lg:text-shell-muted lg:rail:hidden" })] }), _jsxs(DropdownMenuContent, { align: "start", className: "w-64", children: [_jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Organisations" }), organizations.map((org) => (_jsxs(DropdownMenuItem, { onClick: () => onSelect(org.id), children: [_jsx(OrgMark, { org: org, className: "size-6 rounded-md bg-muted text-[10px] text-foreground" }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: org.name }), org.id === activeId ? (_jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Organisation active", className: "size-4" })) : org.role ? (_jsx("span", { className: "text-xs font-normal text-muted-foreground", children: ORG_ROLE_LABEL[org.role] })) : null] }, org.id)))] }), createHref !== undefined ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(MenuPrimitive.LinkItem, { href: createHref, className: MENU_LINK_ITEM, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true }), "Cr\u00E9er une organisation"] })] })) : onCreate !== undefined ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: onCreate, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true }), "Cr\u00E9er une organisation"] })] })) : null] })] }));
35
+ // En haut de la barre des produits : la marque seule dans 40 px, le nom quand la barre s'élargit.
36
+ const inRail = placement === "product-rail";
37
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": `Organisation ${active.name}, changer d'organisation`, className: cn("flex h-10 w-full min-w-0 items-center gap-2.5 rounded-xl px-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", inRail
38
+ ? "shrink-0 gap-3 px-1 hover:bg-shell-hover aria-expanded:bg-shell-hover"
39
+ : "lg:hover:bg-shell-hover lg:aria-expanded:bg-shell-hover lg:rail:w-auto lg:rail:justify-center lg:rail:px-1", className) }), children: [_jsx(OrgMark, { org: active }), _jsxs("span", { className: cn("min-w-0 flex-1", inRail
40
+ ? "opacity-0 transition-opacity duration-(--duration-fast) ease-out group-data-expanded/rail:opacity-100 motion-reduce:transition-none"
41
+ : "lg:rail:hidden"), children: [_jsx("span", { className: "block truncate text-sm font-medium", children: active.name }), active.role ? (_jsx("span", { className: "block truncate text-xs text-muted-foreground lg:text-shell-muted", children: ORG_ROLE_LABEL[active.role] })) : null] }), _jsx(HugeiconsIcon, { icon: UnfoldMoreIcon, "aria-hidden": true, className: cn("size-4 shrink-0 text-muted-foreground lg:text-shell-muted", inRail
42
+ ? "opacity-0 transition-opacity duration-(--duration-fast) group-data-expanded/rail:opacity-100"
43
+ : "lg:rail:hidden") })] }), _jsxs(DropdownMenuContent, { align: "start", className: "w-64", children: [_jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Organisations" }), organizations.map((org) => (_jsxs(DropdownMenuItem, { onClick: () => onSelect(org.id), children: [_jsx(OrgMark, { org: org, className: "size-6 rounded-md bg-muted text-[10px] text-foreground" }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: org.name }), org.id === activeId ? (_jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Organisation active", className: "size-4" })) : org.role ? (_jsx("span", { className: "text-xs font-normal text-muted-foreground", children: ORG_ROLE_LABEL[org.role] })) : null] }, org.id)))] }), createHref !== undefined ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(MenuPrimitive.LinkItem, { href: createHref, className: MENU_LINK_ITEM, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true }), "Cr\u00E9er une organisation"] })] })) : onCreate !== undefined ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: onCreate, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true }), "Cr\u00E9er une organisation"] })] })) : null] })] }));
32
44
  }
@@ -0,0 +1,19 @@
1
+ import { type ReactNode } from "react";
2
+ import { type HgdcCatalogProduct } from "./products.ts";
3
+ /** Délai d'intention avant d'élargir la barre au survol : un passage de souris ne l'ouvre pas. */
4
+ export declare const PRODUCT_RAIL_OPEN_DELAY = 150;
5
+ /**
6
+ * La barre des produits de la famille, tout à gauche de la coque sur ordinateur : organisation en
7
+ * haut, pastilles des produits, Account en bas. 56 px de large ; au survol (après un court délai
8
+ * d'intention) ou au focus clavier, elle s'élargit par-dessus la page et écrit le nom de chaque
9
+ * produit, sans rien décaler. Elle se referme dès que la souris sort.
10
+ */
11
+ export declare function ProductRail({ current, organization, accountHref, catalog, }: {
12
+ /** Le mot du produit affiché (« host ») : marqué en teinte. */
13
+ current?: string | undefined;
14
+ /** `OrgSwitcher`, rendu compact. */
15
+ organization?: ReactNode;
16
+ /** Adresse d'Account (un Account local en développement). */
17
+ accountHref?: string | undefined;
18
+ catalog?: ReadonlyArray<HgdcCatalogProduct> | undefined;
19
+ }): React.JSX.Element;
@@ -0,0 +1,85 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { ProductBadge } from "./product-badge.js";
4
+ import { productRailEntries } from "./products.js";
5
+ import { ShellPlacementContext } from "./shell-placement.js";
6
+ import { cn } from "./cn.js";
7
+ /** Délai d'intention avant d'élargir la barre au survol : un passage de souris ne l'ouvre pas. */
8
+ export const PRODUCT_RAIL_OPEN_DELAY = 150;
9
+ function ProductLink({ entry }) {
10
+ return (_jsx("li", { children: _jsxs("a", { href: entry.href, "aria-current": entry.current ? "page" : undefined, className: cn("flex h-10 items-center gap-3 rounded-xl px-2 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", "aria-[current=page]:bg-shell-active aria-[current=page]:text-shell-foreground"), children: [_jsx(ProductBadge, { product: entry.badge, className: "size-6" }), _jsxs("span", { className: "min-w-0 flex-1 truncate opacity-0 transition-opacity duration-(--duration-fast) ease-out group-data-expanded/rail:opacity-100 motion-reduce:transition-none", children: [_jsx("span", { className: "font-heading text-sm font-medium", children: entry.name }), entry.hint ? _jsx("span", { className: "sr-only", children: ", " }) : null, entry.hint ? _jsx("span", { className: "ml-1.5 text-xs text-shell-muted", children: entry.hint }) : null] })] }) }));
11
+ }
12
+ /**
13
+ * La barre des produits de la famille, tout à gauche de la coque sur ordinateur : organisation en
14
+ * haut, pastilles des produits, Account en bas. 56 px de large ; au survol (après un court délai
15
+ * d'intention) ou au focus clavier, elle s'élargit par-dessus la page et écrit le nom de chaque
16
+ * produit, sans rien décaler. Elle se referme dès que la souris sort.
17
+ */
18
+ export function ProductRail({ current, organization, accountHref, catalog, }) {
19
+ const [expanded, setExpanded] = useState(false);
20
+ const ref = useRef(null);
21
+ const { products, account } = productRailEntries({ current, accountHref, catalog });
22
+ // Écouteurs natifs : la barre est un repère `nav`, pas un contrôle ; c'est son survol et le
23
+ // focus de ses liens qui l'élargissent.
24
+ useEffect(() => {
25
+ const rail = ref.current;
26
+ if (!rail)
27
+ return;
28
+ let timer;
29
+ const cancel = () => {
30
+ window.clearTimeout(timer);
31
+ timer = undefined;
32
+ };
33
+ const onEnter = (event) => {
34
+ if (event.pointerType !== "mouse")
35
+ return;
36
+ cancel();
37
+ timer = window.setTimeout(() => setExpanded(true), PRODUCT_RAIL_OPEN_DELAY);
38
+ };
39
+ const onLeave = () => {
40
+ cancel();
41
+ setExpanded(false);
42
+ };
43
+ // Au clavier seulement : un clic de souris ne laisse pas la barre ouverte.
44
+ const onFocusIn = (event) => {
45
+ if (event.target && focusVisible(event.target))
46
+ setExpanded(true);
47
+ };
48
+ const onFocusOut = (event) => {
49
+ if (!(event.relatedTarget instanceof Node) || !rail.contains(event.relatedTarget))
50
+ setExpanded(false);
51
+ };
52
+ const onKey = (event) => {
53
+ if (event.key !== "Escape")
54
+ return;
55
+ cancel();
56
+ setExpanded(false);
57
+ };
58
+ rail.addEventListener("pointerenter", onEnter);
59
+ rail.addEventListener("pointerleave", onLeave);
60
+ rail.addEventListener("focusin", onFocusIn);
61
+ rail.addEventListener("focusout", onFocusOut);
62
+ rail.addEventListener("keydown", onKey);
63
+ return () => {
64
+ cancel();
65
+ rail.removeEventListener("pointerenter", onEnter);
66
+ rail.removeEventListener("pointerleave", onLeave);
67
+ rail.removeEventListener("focusin", onFocusIn);
68
+ rail.removeEventListener("focusout", onFocusOut);
69
+ rail.removeEventListener("keydown", onKey);
70
+ };
71
+ }, []);
72
+ return (_jsx("div", { "data-slot": "product-rail", className: "relative hidden w-14 shrink-0 lg:block", children: _jsxs("nav", { ref: ref, "aria-label": "Produits", "data-expanded": expanded ? "" : undefined, className: cn("group/rail absolute inset-y-0 left-0 z-40 flex w-14 flex-col gap-2 overflow-hidden bg-shell-rail px-2 py-3 text-shell-foreground", "transition-[width,box-shadow] duration-(--duration-base) ease-ios motion-reduce:transition-none",
73
+ // L'ombre seulement quand elle flotte ; en sombre, où l'ombre ne se voit pas, un filet.
74
+ "data-expanded:w-55 data-expanded:shadow-xl data-expanded:shadow-black/10 dark:data-expanded:shadow-black/50 dark:data-expanded:ring-1 dark:data-expanded:ring-white/8"), children: [organization ? (_jsxs(_Fragment, { children: [_jsx(ShellPlacementContext, { value: "product-rail", children: organization }), _jsx("div", { "aria-hidden": true, className: "mx-2 h-px shrink-0 bg-foreground/8" })] })) : null, _jsx("ul", { className: "grid min-h-0 gap-1 overflow-x-hidden overflow-y-auto", children: products.map((entry) => (_jsx(ProductLink, { entry: entry }, entry.badge))) }), _jsx("ul", { className: "mt-auto grid", children: _jsx(ProductLink, { entry: account }) })] }) }));
75
+ }
76
+ function focusVisible(target) {
77
+ if (!(target instanceof Element))
78
+ return false;
79
+ try {
80
+ return target.matches(":focus-visible");
81
+ }
82
+ catch {
83
+ return true;
84
+ }
85
+ }
@@ -6,9 +6,9 @@ export type ProductStage = "live" | "preview" | "soon";
6
6
  export declare const PRODUCT_STAGE_LABEL: Readonly<Record<ProductStage, string>>;
7
7
  /** Un produit de la famille, tel que la vision le décrit. */
8
8
  export type HgdcCatalogProduct = {
9
- /** Le mot de « HGDC <mot> » : « Host ». */
9
+ /** Le mot du produit, qui sert de sous-domaine : « Host ». */
10
10
  word: string;
11
- /** « HGDC Host ». */
11
+ /** Nom affiché, le mot seul (« Host ») : la famille ne se répète pas devant chaque produit. */
12
12
  name: string;
13
13
  /** « host.hgdc.ch ». */
14
14
  subdomain: string;
@@ -43,7 +43,7 @@ export declare const HGDC_ACCOUNT: HgdcCatalogProduct;
43
43
  export declare const HGDC_PRODUCTS: ReadonlyArray<HgdcCatalogProduct>;
44
44
  /** Vrai si le produit répond déjà (en service ou en préversion). */
45
45
  export declare function isProductOpen(item: Pick<HgdcCatalogProduct, "stage">): boolean;
46
- /** Une entrée du menu « Produits HGDC » d'`AccountMenu`. */
46
+ /** Une entrée du menu « Produits » d'`AccountMenu`. */
47
47
  export type ProductMenuEntry = {
48
48
  name: string;
49
49
  href: string;
@@ -66,3 +66,27 @@ export declare function productMenuEntries(options?: {
66
66
  accountHref?: string | undefined;
67
67
  catalog?: ReadonlyArray<HgdcCatalogProduct> | undefined;
68
68
  }): Array<ProductMenuEntry>;
69
+ /** Une entrée de la barre des produits d'`AppShell`. */
70
+ export type ProductRailEntry = {
71
+ name: string;
72
+ href: string;
73
+ badge: FamilyProduct;
74
+ /** Le produit ouvert : « vous êtes ici », lien vers sa propre racine. */
75
+ current?: boolean;
76
+ /** Texte discret sous le nom : « Préversion ». */
77
+ hint?: string;
78
+ };
79
+ /**
80
+ * Les entrées de la barre des produits : les produits ouverts du catalogue, puis Account, à part
81
+ * en bas (le compte commun, pas un produit). Chaque produit pointe vers son entrée
82
+ * `/auth/hgdc?next=/` (ADR 0010), Account vers sa racine (`accountHref` en développement). Le
83
+ * produit courant pointe vers `/`, sans détour par l'entrée.
84
+ */
85
+ export declare function productRailEntries(options?: {
86
+ current?: string | undefined;
87
+ accountHref?: string | undefined;
88
+ catalog?: ReadonlyArray<HgdcCatalogProduct> | undefined;
89
+ }): {
90
+ products: Array<ProductRailEntry>;
91
+ account: ProductRailEntry;
92
+ };
package/dist/products.js CHANGED
@@ -26,7 +26,7 @@ function product(word, pitch, icon, stage) {
26
26
  const href = `https://${subdomain}`;
27
27
  return {
28
28
  word,
29
- name: `HGDC ${word}`,
29
+ name: word,
30
30
  subdomain,
31
31
  href,
32
32
  entryHref: `${href}${PRODUCT_ENTRY_PATH}`,
@@ -38,7 +38,7 @@ function product(word, pitch, icon, stage) {
38
38
  };
39
39
  }
40
40
  /** HGDC Account : le compte commun (profil, sécurité, organisations), hors du catalogue des produits. */
41
- const account = product("Account", "Ton compte HGDC : profil, sécurité et organisations, pour tous les produits.", UserCircleIcon, "live");
41
+ const account = product("Account", "Ton compte : profil, sécurité et organisations, pour tous les produits de la famille.", UserCircleIcon, "live");
42
42
  export const HGDC_ACCOUNT = { ...account, entryHref: account.href };
43
43
  /** Les produits de la famille, dans leur ordre d'affichage. Account n'en fait pas partie (`HGDC_ACCOUNT`). */
44
44
  export const HGDC_PRODUCTS = [
@@ -72,3 +72,24 @@ export function productMenuEntries(options = {}) {
72
72
  ...(item.stage === "preview" ? { hint: PRODUCT_STAGE_LABEL.preview } : {}),
73
73
  }));
74
74
  }
75
+ /**
76
+ * Les entrées de la barre des produits : les produits ouverts du catalogue, puis Account, à part
77
+ * en bas (le compte commun, pas un produit). Chaque produit pointe vers son entrée
78
+ * `/auth/hgdc?next=/` (ADR 0010), Account vers sa racine (`accountHref` en développement). Le
79
+ * produit courant pointe vers `/`, sans détour par l'entrée.
80
+ */
81
+ export function productRailEntries(options = {}) {
82
+ const current = options.current?.toLowerCase();
83
+ const entry = (item, href) => ({
84
+ name: item.name,
85
+ badge: item.badge,
86
+ ...(item.badge === current ? { href: "/", current: true } : { href }),
87
+ ...(item.stage === "preview" ? { hint: PRODUCT_STAGE_LABEL.preview } : {}),
88
+ });
89
+ return {
90
+ products: (options.catalog ?? HGDC_PRODUCTS)
91
+ .filter(isProductOpen)
92
+ .map((item) => entry(item, `${item.entryHref}?next=/`)),
93
+ account: entry(HGDC_ACCOUNT, options.accountHref ?? HGDC_ACCOUNT.entryHref),
94
+ };
95
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Où `AppShell` pose un élément du produit (le sélecteur d'organisation) : dans la barre latérale
3
+ * ou le tiroir mobile, ou en haut de la barre des produits, où il n'a qu'une colonne de 56 px.
4
+ */
5
+ export type ShellPlacement = "sidebar" | "product-rail";
6
+ export declare const ShellPlacementContext: React.Context<ShellPlacement>;
7
+ export declare function useShellPlacement(): ShellPlacement;
@@ -0,0 +1,5 @@
1
+ import { createContext, useContext } from "react";
2
+ export const ShellPlacementContext = createContext("sidebar");
3
+ export function useShellPlacement() {
4
+ return useContext(ShellPlacementContext);
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Interface de la H family : jetons de la DA, thèmes, polices et composants.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",
package/styles.css CHANGED
@@ -63,6 +63,7 @@
63
63
  --color-shell-muted: var(--shell-muted-foreground);
64
64
  --color-shell-hover: var(--shell-hover);
65
65
  --color-shell-active: var(--shell-active);
66
+ --color-shell-rail: var(--shell-rail);
66
67
  --color-success: var(--success);
67
68
  --color-success-foreground: var(--success-foreground);
68
69
  --color-status-active: var(--status-active);
@@ -135,6 +136,8 @@
135
136
  --shell-muted-foreground: oklch(0.45 0 0);
136
137
  --shell-hover: oklch(0 0 0 / 5%);
137
138
  --shell-active: oklch(0 0 0 / 8%);
139
+ /* Barre des produits, un cran sous la coque. */
140
+ --shell-rail: oklch(0.93 0 0);
138
141
  /* Séries de graphique, dans cet ordre, jamais recyclées : une 7e série se replie en « Autres ».
139
142
  Validées (daltonisme, écart entre voisines) sur `surface` en clair et sur `card` en sombre.
140
143
  En clair, 2 à 5 passent sous 3:1 : un graphique les accompagne d'une légende et de libellés. */
@@ -198,6 +201,7 @@
198
201
  --shell-muted-foreground: oklch(0.708 0 0);
199
202
  --shell-hover: oklch(1 0 0 / 6%);
200
203
  --shell-active: oklch(1 0 0 / 12%);
204
+ --shell-rail: oklch(0.05 0 0);
201
205
  --chart-1: oklch(0.622 0.161 255.1);
202
206
  --chart-2: oklch(0.622 0.173 40.1);
203
207
  --chart-3: oklch(0.621 0.128 163.1);