@hgdc/ui 0.27.0 → 0.29.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 +22 -13
- package/dist/account-menu.d.ts +15 -8
- package/dist/account-menu.js +15 -10
- package/dist/app-shell.d.ts +57 -3
- package/dist/app-shell.js +121 -3
- package/dist/avatar.d.ts +9 -1
- package/dist/avatar.js +21 -2
- package/dist/cursor-pager.d.ts +22 -0
- package/dist/cursor-pager.js +40 -0
- package/dist/cursor-pages.d.ts +57 -0
- package/dist/cursor-pages.js +56 -0
- package/dist/family-loader.d.ts +4 -5
- package/dist/family-loader.js +6 -7
- package/dist/initials.d.ts +9 -0
- package/dist/initials.js +15 -0
- package/dist/member-item.js +1 -1
- package/dist/project-switcher.d.ts +48 -0
- package/dist/project-switcher.js +48 -0
- package/dist/theme-switch.js +5 -1
- package/package.json +1 -1
- package/styles.css +57 -34
package/README.md
CHANGED
|
@@ -31,27 +31,34 @@ Un sous-chemin par composant, sans fichier d'index. Les primitives viennent tout
|
|
|
31
31
|
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
32
32
|
| `@hgdc/ui/styles.css` | Jetons OKLCH, thèmes (`.dark` sur `<html>`), variante `rail:`, polices, utilitaires `surface`, `pressable`, `skeleton-pulse`… |
|
|
33
33
|
| `@hgdc/ui/theme` | `ThemeProvider`, `useTheme`, `themeInitScript` (dans le `<head>`, contre le flash de thème) |
|
|
34
|
+
| `@hgdc/ui/theme-switch` | `ThemeSwitch` : Système, Clair, Sombre en trois icônes ; déjà dans `AccountMenu`, à poser seul ailleurs |
|
|
34
35
|
| `@hgdc/ui/cn` | `cn()` (clsx + tailwind-merge) |
|
|
35
36
|
| `@hgdc/ui/use-media-query` | `useMediaQuery`, `useIsMobile` |
|
|
36
37
|
| `@hgdc/ui/floating` | Classes des surfaces flottantes |
|
|
37
38
|
|
|
39
|
+
Sur écran tactile (pointeur grossier), les cibles font 44 px (variante `coarse:`, classes `touch-target` et `touch-field`) et les champs 16 px, pour qu'iOS ne zoome pas à la saisie ; `ResourceItem` et la barre du haut d'`AppShell` passent à la ligne sur un écran étroit ou à texte agrandi.
|
|
40
|
+
|
|
41
|
+
Avatars à initiales : `<AvatarFallback colorKey={user.email}>HG</AvatarFallback>` prend une des sept teintes des icônes des produits (`--avatar-1` à `--avatar-7`), stable pour une même adresse, initiales à 4,5:1 au moins en clair et en sombre ; la photo (`AvatarImage`) reste prioritaire.
|
|
42
|
+
|
|
38
43
|
Primitives déjà là : `avatar` `badge` `button` `dialog` `drawer` `dropdown-menu` `empty` `field` `input` `input-otp` `label` `segmented-control` `select` `separator` `sonner` `spinner` `switch` `textarea`.
|
|
39
44
|
|
|
40
45
|
## Coque des consoles
|
|
41
46
|
|
|
42
|
-
| Import
|
|
43
|
-
|
|
|
44
|
-
| `app-shell`
|
|
45
|
-
|
|
|
46
|
-
| `org-switcher`
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
47
|
+
| Import | Exemple |
|
|
48
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
49
|
+
| `app-shell` | `<AppShell product="mail" organization={<OrgSwitcher …/>} nav={<SidebarNav label="Projet">…</SidebarNav>} links={{ documentation: "/docs", settings: { render: <Link to="/settings" /> } }} account={<AccountMenu …/>} onSearch={() => setOpen(true)} assistant={{ open, onOpenChange: setOpen, panel: <Conversation /> }}>` : Documentation, Réglages et État du service (la page de statut par défaut) juste au-dessus du compte ; bouton Assistant à côté de la recherche, panneau dans une colonne à droite (la page se rétrécit, vue dédiée sous 1280 px, Échap ferme, `shortcut: "j"` facultatif). `sidebarFooter` est déprécié |
|
|
50
|
+
| | `<SidebarNavItem icon={Rocket01Icon} label="Déploiements" render={<Link to="/deployments" />} />` |
|
|
51
|
+
| `org-switcher` | `<OrgSwitcher organizations={orgs} activeId={org.id} onSelect={go} createHref="/organisations/nouvelle" />` |
|
|
52
|
+
| `project-switcher` | `<ProjectSwitcher projects={projects} activeId={project?.id} activeName={params.project} allHref="/o/acme" createHref="/o/acme/new" renderLink={(href) => <Link to={href} />} />` : projet actif ou « Tous les projets », marque du projet (`ProjectMark`), « Nouveau projet » |
|
|
53
|
+
| `account-menu` | `<AccountMenu user={user} onProfile={…} onSecurity={…} onSignOut={signOut} product="Mail" />` : Account et les produits ouverts du catalogue, Mail coché. Dans l'ordre : identité, Profil, Sécurité, Thème (Système, Clair, Sombre, `ThemeSwitch`), entrées du produit, Se déconnecter. Ni « État du service » ni raccourcis : la coque pose le premier au-dessus du compte, l'aide garde sa touche `?` |
|
|
54
|
+
| `command-menu` | `<CommandMenu groups={[{ label: "Aller à", items }, { label: "Créer", items }, { label: "Changer", items }]} />` (`Ctrl+K`), `<CommandMenuButton compact="mobile" …/>` |
|
|
55
|
+
| `products` | `HGDC_PRODUCTS` (mot, nom, sous-domaine, phrase, pastille `badge`, icône, état, `entryHref`), `HGDC_ACCOUNT`, `productMenuEntries({ current: "Mail" })` : chaque produit du menu pointe vers son entrée `/auth/hgdc` (ADR 0010) |
|
|
56
|
+
| `service-status` | Bandeau d'incident ou de maintenance posé par `AppShell` en tête du contenu (ADR 0018) : `useServiceStatus(pageUrl)`, `ServiceStatusBanner`. `statusPage={false}` pour ne rien lire |
|
|
57
|
+
| `kbd` | `<KbdGroup><Kbd>Ctrl</Kbd><Kbd>K</Kbd></KbdGroup>` |
|
|
58
|
+
| `page-header` | `<PageHeader title="Domaines" breadcrumbs={[{ label: "Acme", render: <Link to="/o/acme" /> }, { label: "Domaines" }]} actions={<Button>Ajouter</Button>} />` |
|
|
59
|
+
| `page-header` | `<Page title="Réglages">…</Page>` (colonne de lecture de 768 px), `<Page title="Projets" wide actions={…}>…</Page>` (tableaux, listes, tableaux de bord) |
|
|
60
|
+
| `member-item` | `<ResourceList><MemberItem name={m.name} email={m.email} orgRole={m.role} isMe={m.isMe} /></ResourceList>` : avatar à initiales, « (toi) », `RoleBadge` |
|
|
61
|
+
| `initials` | `initials("Test vérif")` donne « TV » : la même règle pour une personne et une organisation (`OrgMark`, `AccountMenu`, `MemberItem`) ; `avatarTone(email)` donne la teinte stable (1 à 7) de l'avatar |
|
|
55
62
|
|
|
56
63
|
Le menu « Produits » et la palette (`badge` d'une commande) montrent la pastille de chaque produit.
|
|
57
64
|
|
|
@@ -112,6 +119,8 @@ Sur ordinateur, la coque ouvre sur la barre des produits (ADR 0011) : organisati
|
|
|
112
119
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
113
120
|
| `filter-bar` | `<FilterBar search={{ value: q, onChange }} filters={[{ key: "status", label: "Statut", options }]} values={{ status }} onFilterChange={…} onReset={…} />` |
|
|
114
121
|
| `data-table` | `<DataTable label="Déploiements" rows={rows} columns={columns} rowKey={(d) => d.id} sort={sort} onSortChange={…} filters={<FilterBar …/>} loadMore={…} />` |
|
|
122
|
+
| `cursor-pages` | `const pages = useCursorPages(JSON.stringify(filters))` : pile de curseurs, 200 lignes par page (`PAGE_SIZE`), retour à la page 1 quand la clé change, `pages.paginationOpts` pour `paginate` de Convex |
|
|
123
|
+
| `cursor-pager` | `<CursorPager pages={pages} result={result} scrollTarget={topRef} />` : Précédent, Suivant, « Lignes 201 à 400 », chargement sans saut ; règle des grandes listes dans le guide |
|
|
115
124
|
| `board` | `<Board label="Tableau du projet" columns={[{ id, label, title, cards }]} cardKey={(c) => c.id} cardLabel={(c) => c.title} renderCard={(c, { handle }) => …} onMove={…} />` : poignée souris, doigt, clavier |
|
|
116
125
|
| `dns-record-table` | `<DnsRecordTable records={[{ type: "MX", name: "send", value, priority: 10, status: "pending" }]} />` (Vérifié, En attente, Échec, À ajouter) |
|
|
117
126
|
| `log-viewer` | `<LogViewer lines={[{ id, time, level: "error", message }]} live />` : niveaux, recherche, « Suivre », copie ; seules les lignes visibles sont rendues |
|
package/dist/account-menu.d.ts
CHANGED
|
@@ -20,21 +20,29 @@ export type AccountUser = {
|
|
|
20
20
|
image?: string | null;
|
|
21
21
|
};
|
|
22
22
|
/**
|
|
23
|
-
* Le menu du compte, en bas de la barre latérale
|
|
24
|
-
*
|
|
23
|
+
* Le menu du compte, en bas de la barre latérale (`account` d'`AppShell`). Dans cet ordre :
|
|
24
|
+
* l'identité (avatar, nom, adresse), Profil, Sécurité, Thème (Système, Clair, Sombre, dans le
|
|
25
|
+
* menu), les entrées propres au produit, les autres produits (sur téléphone), puis « Se
|
|
26
|
+
* déconnecter », à part. `row` : avatar, nom et adresse (l'avatar seul en rail) ; `avatar` :
|
|
25
27
|
* l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
|
|
26
28
|
* `ThemeProvider`.
|
|
27
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.
|
|
32
|
+
*
|
|
28
33
|
* « Produits » (masquée au-delà de `lg` dans une coque qui affiche la barre des produits, où ils
|
|
29
34
|
* sont déjà ; gardée sur téléphone) liste par défaut Account et les produits ouverts du catalogue
|
|
30
35
|
* (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
|
|
31
36
|
* liste ; `products={[]}` la retire.
|
|
32
37
|
*/
|
|
33
|
-
export declare function AccountMenu({ user, onProfile, onSecurity,
|
|
38
|
+
export declare function AccountMenu({ user, onProfile, onSecurity, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant, }: {
|
|
34
39
|
user: AccountUser;
|
|
35
40
|
onProfile?: () => void;
|
|
36
41
|
onSecurity?: () => void;
|
|
37
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* @deprecated Plus d'entrée « Raccourcis clavier » dans le menu (0.29) : l'aide s'ouvre par la
|
|
44
|
+
* touche `?`, que le produit garde. Ignoré.
|
|
45
|
+
*/
|
|
38
46
|
onShortcuts?: () => void;
|
|
39
47
|
onSignOut: () => void;
|
|
40
48
|
/** Les produits de la famille, pour passer de l'un à l'autre. Le catalogue du kit par défaut. */
|
|
@@ -44,12 +52,11 @@ export declare function AccountMenu({ user, onProfile, onSecurity, onShortcuts,
|
|
|
44
52
|
/** Adresse d'Account dans la liste par défaut (un Account local en développement). */
|
|
45
53
|
accountHref?: string;
|
|
46
54
|
/**
|
|
47
|
-
*
|
|
48
|
-
* `
|
|
49
|
-
* coque, sans adresse donnée, pas d'entrée.
|
|
55
|
+
* @deprecated « État du service » a quitté le menu (0.29) : la coque le pose au-dessus du compte
|
|
56
|
+
* (`links.status` d'`AppShell`, la page de statut de la coque par défaut). Ignoré.
|
|
50
57
|
*/
|
|
51
58
|
statusPage?: string | false;
|
|
52
|
-
/** Entrées propres au produit (« Administration »), après le
|
|
59
|
+
/** Entrées propres au produit (« Administration »), après le thème. */
|
|
53
60
|
children?: ReactNode;
|
|
54
61
|
variant?: "row" | "avatar";
|
|
55
62
|
}): React.JSX.Element;
|
package/dist/account-menu.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
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
2
|
import DashboardSquare01Icon from "@hugeicons/core-free-icons/DashboardSquare01Icon";
|
|
4
|
-
import KeyboardIcon from "@hugeicons/core-free-icons/KeyboardIcon";
|
|
5
3
|
import Logout01Icon from "@hugeicons/core-free-icons/Logout01Icon";
|
|
6
4
|
import SecurityLockIcon from "@hugeicons/core-free-icons/SecurityLockIcon";
|
|
7
5
|
import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
|
|
@@ -9,31 +7,38 @@ import UserIcon from "@hugeicons/core-free-icons/UserIcon";
|
|
|
9
7
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
10
8
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
|
11
9
|
import { Avatar, AvatarFallback, AvatarImage } from "./avatar.js";
|
|
12
|
-
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator,
|
|
10
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
|
|
13
11
|
import { initials } from "./initials.js";
|
|
14
12
|
import { MENU_LINK_ITEM } from "./org-switcher.js";
|
|
15
13
|
import { ProductBadge } from "./product-badge.js";
|
|
16
14
|
import { productMenuEntries } from "./products.js";
|
|
17
|
-
import { useLeaveOnClick, useProductRailShown
|
|
15
|
+
import { useLeaveOnClick, useProductRailShown } from "./shell-placement.js";
|
|
18
16
|
import { ThemeSwitch } from "./theme-switch.js";
|
|
17
|
+
/** L'avatar du compte : la photo, sinon les initiales sur la teinte stable de l'adresse. */
|
|
18
|
+
function AccountAvatar({ user, size }) {
|
|
19
|
+
return (_jsxs(Avatar, { size: size, children: [user.image ? _jsx(AvatarImage, { src: user.image, alt: "" }) : null, _jsx(AvatarFallback, { colorKey: user.email || user.name, className: "text-xs font-semibold uppercase", children: initials(user.name, user.email) })] }));
|
|
20
|
+
}
|
|
19
21
|
/**
|
|
20
|
-
* Le menu du compte, en bas de la barre latérale
|
|
21
|
-
*
|
|
22
|
+
* Le menu du compte, en bas de la barre latérale (`account` d'`AppShell`). Dans cet ordre :
|
|
23
|
+
* l'identité (avatar, nom, adresse), Profil, Sécurité, Thème (Système, Clair, Sombre, dans le
|
|
24
|
+
* menu), les entrées propres au produit, les autres produits (sur téléphone), puis « Se
|
|
25
|
+
* déconnecter », à part. `row` : avatar, nom et adresse (l'avatar seul en rail) ; `avatar` :
|
|
22
26
|
* l'avatar seul, pour une barre du haut. Le produit passe les actions ; le thème passe par
|
|
23
27
|
* `ThemeProvider`.
|
|
24
28
|
*
|
|
29
|
+
* Documentation, Réglages et État du service ne sont pas ici : la coque les pose au-dessus du
|
|
30
|
+
* compte (`links` d'`AppShell`). L'aide des raccourcis garde sa touche `?`, sans entrée de menu.
|
|
31
|
+
*
|
|
25
32
|
* « Produits » (masquée au-delà de `lg` dans une coque qui affiche la barre des produits, où ils
|
|
26
33
|
* sont déjà ; gardée sur téléphone) liste par défaut Account et les produits ouverts du catalogue
|
|
27
34
|
* (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
|
|
28
35
|
* liste ; `products={[]}` la retire.
|
|
29
36
|
*/
|
|
30
|
-
export function AccountMenu({ user, onProfile, onSecurity,
|
|
37
|
+
export function AccountMenu({ user, onProfile, onSecurity, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant = "row", }) {
|
|
31
38
|
const products = productsProp ?? productMenuEntries({ current: currentProduct, accountHref });
|
|
32
39
|
const railShown = useProductRailShown();
|
|
33
|
-
const shellStatusPage = useStatusPage();
|
|
34
|
-
const statusPageUrl = statusPage === false ? null : (statusPage ?? shellStatusPage);
|
|
35
40
|
const leaveOnClick = useLeaveOnClick();
|
|
36
|
-
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: [
|
|
41
|
+
return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: variant === "row" ? (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "flex w-full min-w-0 items-center gap-2.5 rounded-xl p-1.5 text-left outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted lg:hover:bg-shell-hover lg:aria-expanded:bg-shell-hover lg:rail:justify-center" })) : (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "touch-target grid size-9 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50" })), children: [_jsx(AccountAvatar, { user: user }), variant === "row" ? (_jsxs("span", { className: "min-w-0 flex-1 lg:rail:hidden", children: [_jsx("span", { className: "block truncate text-sm font-medium", children: user.name }), _jsx("span", { className: "block truncate text-xs text-muted-foreground lg:text-shell-muted", children: user.email })] })) : null] }), _jsxs(DropdownMenuContent, { align: variant === "row" ? "start" : "end", side: variant === "row" ? "top" : "bottom", className: "w-72", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { className: "flex items-center gap-2.5 py-2", children: [_jsx(AccountAvatar, { user: user }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate text-sm font-medium text-foreground", children: user.name }), _jsx("span", { className: "block truncate", children: user.email })] })] }) }), _jsx(DropdownMenuSeparator, {}), onProfile ? (_jsxs(DropdownMenuItem, { onClick: onProfile, children: [_jsx(HugeiconsIcon, { icon: UserIcon, "aria-hidden": true }), "Profil"] })) : null, onSecurity ? (_jsxs(DropdownMenuItem, { onClick: onSecurity, children: [_jsx(HugeiconsIcon, { icon: SecurityLockIcon, "aria-hidden": true }), "S\u00E9curit\u00E9"] })) : null, _jsx(ThemeSwitch, {}), children ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), children] })) : null, products.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, { className: railShown ? "lg:hidden" : undefined }), _jsxs(DropdownMenuGroup, { className: railShown ? "lg:hidden" : undefined, children: [_jsx(DropdownMenuLabel, { children: "Produits" }), products.map((product) => product.current ? (_jsxs(DropdownMenuItem, { disabled: true, className: "data-disabled:opacity-100", children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Produit ouvert" })] }, product.name)) : (_jsxs(MenuPrimitive.LinkItem, { href: product.href, onClick: leaveOnClick, className: MENU_LINK_ITEM, children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), product.hint ? (_jsx("span", { className: "text-xs font-normal text-muted-foreground", children: product.hint })) : null] }, product.name)))] })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: onSignOut, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
|
|
37
42
|
}
|
|
38
43
|
function ProductMenuIcon({ product }) {
|
|
39
44
|
if (product.badge)
|
package/dist/app-shell.d.ts
CHANGED
|
@@ -33,7 +33,45 @@ export declare function SidebarNavItem({ icon, label, active, href, render, }: {
|
|
|
33
33
|
/** Lien du routeur : `render={<Link to="…" />}`. */
|
|
34
34
|
render?: React.ReactElement;
|
|
35
35
|
}): React.JSX.Element;
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Un accès commun de la barre : une adresse, ou un lien du routeur (`{ render: <Link to="/settings" /> }`,
|
|
38
|
+
* dont le `data-status="active"` marque la page ouverte), ou une adresse avec `active`.
|
|
39
|
+
*/
|
|
40
|
+
export type ShellLink = string | {
|
|
41
|
+
href?: string;
|
|
42
|
+
render?: React.ReactElement;
|
|
43
|
+
active?: boolean;
|
|
44
|
+
};
|
|
45
|
+
/** Les trois accès que la coque pose juste au-dessus du compte, toujours dans cet ordre. */
|
|
46
|
+
export type AppShellLinks = {
|
|
47
|
+
/** Documentation du produit. */
|
|
48
|
+
documentation?: ShellLink;
|
|
49
|
+
/** Réglages du produit ou de l'organisation. */
|
|
50
|
+
settings?: ShellLink;
|
|
51
|
+
/** État du service : la page de statut de la coque (`statusPage`) par défaut, `false` pour le retirer. */
|
|
52
|
+
status?: ShellLink | false;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* L'assistant IA du produit. La coque pose son bouton juste à côté de la recherche et l'ouvre dans
|
|
56
|
+
* une colonne à droite de la page, sur le fond de l'application : la page se rétrécit et se
|
|
57
|
+
* réorganise, jamais recouverte. Sous 1280 px, là où les deux ne tiennent plus côte à côte, une
|
|
58
|
+
* vue dédiée plein écran avec « Retour à la page ». La page reste montée dans les deux cas, et le
|
|
59
|
+
* panneau, une fois ouvert, aussi : rien ne se perd en fermant ou en changeant de largeur.
|
|
60
|
+
*/
|
|
61
|
+
export type AppShellAssistant = {
|
|
62
|
+
open: boolean;
|
|
63
|
+
onOpenChange: (open: boolean) => void;
|
|
64
|
+
/**
|
|
65
|
+
* Le corps de l'assistant : conversation et saisie (`@hgdc/ui/assistant-chat`). La coque pose le
|
|
66
|
+
* titre, la fermeture et le retour à la page ; le panneau occupe la hauteur restante et défile.
|
|
67
|
+
*/
|
|
68
|
+
panel: ReactNode;
|
|
69
|
+
/** Nom du bouton et titre du panneau : « Assistant » par défaut. */
|
|
70
|
+
label?: string;
|
|
71
|
+
/** Lettre du raccourci `Ctrl` (`⌘`) + lettre qui ouvre et ferme l'assistant (`"j"`). Aucun par défaut. */
|
|
72
|
+
shortcut?: string;
|
|
73
|
+
};
|
|
74
|
+
export declare function AppShell({ product, organization, productRail, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, }: {
|
|
37
75
|
/** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
|
|
38
76
|
product: FamilyProduct;
|
|
39
77
|
/** `OrgSwitcher` : en haut de la barre des produits, en haut du tiroir sur téléphone. */
|
|
@@ -47,7 +85,21 @@ export declare function AppShell({ product, organization, productRail, accountHr
|
|
|
47
85
|
accountHref?: string | undefined;
|
|
48
86
|
/** `SidebarNav` et ses `SidebarNavItem`. */
|
|
49
87
|
nav: ReactNode;
|
|
50
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Documentation, Réglages et État du service, juste au-dessus du compte, dans cet ordre, chacun
|
|
90
|
+
* optionnel (État du service suit `statusPage` par défaut).
|
|
91
|
+
*/
|
|
92
|
+
links?: AppShellLinks;
|
|
93
|
+
/** `AccountMenu`, tout en bas de la barre latérale (et du tiroir sur téléphone). */
|
|
94
|
+
account?: ReactNode;
|
|
95
|
+
/**
|
|
96
|
+
* Avec `account` : entrées propres au produit, posées au-dessus des accès communs.
|
|
97
|
+
*
|
|
98
|
+
* @deprecated Comme bas de barre complet (liens et `AccountMenu`), remplacé par `links` et
|
|
99
|
+
* `account` (0.29). Sans `account`, il est rendu tel quel sous les accès communs, pour ne rien
|
|
100
|
+
* casser : déplacer `AccountMenu` dans `account`, Docs et Réglages dans `links`, et retirer
|
|
101
|
+
* l'ancien lien vers l'assistant (`assistant`).
|
|
102
|
+
*/
|
|
51
103
|
sidebarFooter?: ReactNode;
|
|
52
104
|
/**
|
|
53
105
|
* Barre du haut, à gauche, après la marque sur téléphone : le contexte de la page (sélecteur de
|
|
@@ -61,6 +113,8 @@ export declare function AppShell({ product, organization, productRail, accountHr
|
|
|
61
113
|
onSearch?: () => void;
|
|
62
114
|
/** Texte du champ de recherche : « Rechercher » par défaut. */
|
|
63
115
|
searchLabel?: string;
|
|
116
|
+
/** L'assistant IA : son bouton à côté de la recherche, son panneau à droite de la page. */
|
|
117
|
+
assistant?: AppShellAssistant;
|
|
64
118
|
children: ReactNode;
|
|
65
119
|
/**
|
|
66
120
|
* Clé de la page affichée (le chemin, `location.pathname`) : la zone de contenu devient une
|
|
@@ -84,7 +138,7 @@ export declare function AppShell({ product, organization, productRail, accountHr
|
|
|
84
138
|
/**
|
|
85
139
|
* Page de statut du produit, dont le bandeau d'état se pose en tête du contenu (ADR 0018) :
|
|
86
140
|
* `https://status.hgdc.ch/<produit>` par défaut, une autre adresse (prévisualisation sur une page
|
|
87
|
-
* de test), ou `false` pour ne rien lire.
|
|
141
|
+
* de test), ou `false` pour ne rien lire. L'accès « État du service », au-dessus du compte, y mène.
|
|
88
142
|
*/
|
|
89
143
|
statusPage?: string | false | undefined;
|
|
90
144
|
}): React.JSX.Element;
|
package/dist/app-shell.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Activity01Icon from "@hugeicons/core-free-icons/Activity01Icon";
|
|
3
|
+
import AiMagicIcon from "@hugeicons/core-free-icons/AiMagicIcon";
|
|
4
|
+
import ArrowLeft01Icon from "@hugeicons/core-free-icons/ArrowLeft01Icon";
|
|
5
|
+
import BookOpen01Icon from "@hugeicons/core-free-icons/BookOpen01Icon";
|
|
6
|
+
import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
|
|
2
7
|
import Menu01Icon from "@hugeicons/core-free-icons/Menu01Icon";
|
|
3
8
|
import PanelLeftCloseIcon from "@hugeicons/core-free-icons/PanelLeftCloseIcon";
|
|
4
9
|
import PanelLeftOpenIcon from "@hugeicons/core-free-icons/PanelLeftOpenIcon";
|
|
10
|
+
import Settings01Icon from "@hugeicons/core-free-icons/Settings01Icon";
|
|
5
11
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
6
12
|
import { useRender } from "@base-ui/react/use-render";
|
|
7
|
-
import { createContext, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
|
|
13
|
+
import { createContext, Suspense, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
|
|
8
14
|
import { CommandMenuButton } from "./command-menu.js";
|
|
9
15
|
import { Drawer, DrawerContent, DrawerTitle } from "./drawer.js";
|
|
10
16
|
import { FamilyLoaderHost, FamilyLoaderRootContext } from "./family-loader.js";
|
|
@@ -18,6 +24,7 @@ import { ProductRailShownContext, ShellLeaveContext, StatusPageContext } from ".
|
|
|
18
24
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
|
|
19
25
|
import { cn } from "./cn.js";
|
|
20
26
|
import { ServiceStatus, statusPageUrlFor } from "./service-status.js";
|
|
27
|
+
import { useMediaQuery } from "./use-media-query.js";
|
|
21
28
|
// ── Rail : la barre latérale réduite aux icônes, mémorisée par navigateur ──────────────
|
|
22
29
|
const RAIL_KEY = "sidebar";
|
|
23
30
|
const RAIL_EVENT = "sidebarchange";
|
|
@@ -99,6 +106,104 @@ export function SidebarNavItem({ icon, label, active, href, render, }) {
|
|
|
99
106
|
});
|
|
100
107
|
return (_jsx("li", { children: _jsxs(Tooltip, { disabled: !rail, children: [_jsx(TooltipTrigger, { render: link }), _jsx(TooltipContent, { side: "right", children: label })] }) }));
|
|
101
108
|
}
|
|
109
|
+
const COMMON_LINKS = [
|
|
110
|
+
{ key: "documentation", label: "Documentation", icon: BookOpen01Icon },
|
|
111
|
+
{ key: "settings", label: "Réglages", icon: Settings01Icon },
|
|
112
|
+
{ key: "status", label: "État du service", icon: Activity01Icon },
|
|
113
|
+
];
|
|
114
|
+
function ShellCommonLinks({ links, statusPageUrl, }) {
|
|
115
|
+
const resolved = {
|
|
116
|
+
documentation: links.documentation,
|
|
117
|
+
settings: links.settings,
|
|
118
|
+
status: links.status === false ? undefined : (links.status ?? statusPageUrl ?? undefined),
|
|
119
|
+
};
|
|
120
|
+
const items = COMMON_LINKS.flatMap(({ key, label, icon }) => {
|
|
121
|
+
const link = resolved[key];
|
|
122
|
+
if (link === undefined)
|
|
123
|
+
return [];
|
|
124
|
+
const props = typeof link === "string" ? { href: link } : link;
|
|
125
|
+
return [
|
|
126
|
+
_jsx(SidebarNavItem, { icon: icon, label: label, ...(props.href !== undefined ? { href: props.href } : {}), ...(props.render !== undefined ? { render: props.render } : {}), ...(props.active !== undefined ? { active: props.active } : {}) }, key),
|
|
127
|
+
];
|
|
128
|
+
});
|
|
129
|
+
if (!items.length)
|
|
130
|
+
return null;
|
|
131
|
+
return (_jsx("nav", { "aria-label": "Acc\u00E8s communs", children: _jsx("ul", { "data-slot": "app-shell-links", className: "grid gap-0.5", children: items }) }));
|
|
132
|
+
}
|
|
133
|
+
/** Largeur à partir de laquelle la page et l'assistant tiennent côte à côte (`xl`). */
|
|
134
|
+
const ASSISTANT_SIDE_BY_SIDE = "(min-width: 80rem)";
|
|
135
|
+
const FOCUSABLE = 'textarea, input, [contenteditable="true"], [autofocus]';
|
|
136
|
+
function AssistantButton({ assistant, panelId, buttonRef, }) {
|
|
137
|
+
const label = assistant.label ?? "Assistant";
|
|
138
|
+
const letter = assistant.shortcut?.toUpperCase();
|
|
139
|
+
return (_jsxs(Tooltip, { children: [_jsxs(TooltipTrigger, { render: _jsx("button", { ref: buttonRef, type: "button", "data-slot": "app-shell-assistant-button", "aria-expanded": assistant.open, "aria-controls": panelId, ...(letter ? { "aria-keyshortcuts": `Control+${letter} Meta+${letter}` } : {}), onClick: () => assistant.onOpenChange(!assistant.open), className: cn("touch-target flex h-9 shrink-0 items-center gap-2 rounded-xl px-3 text-sm font-medium text-muted-foreground outline-none", "transition-colors duration-(--duration-fast) ease-out hover:bg-foreground/[0.05] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-expanded:bg-foreground/[0.08] aria-expanded:text-foreground", "max-lg:w-9 max-lg:justify-center max-lg:px-0") }), children: [_jsx(HugeiconsIcon, { icon: AiMagicIcon, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem] shrink-0" }), _jsx("span", { className: "max-lg:sr-only", children: label })] }), _jsx(TooltipContent, { children: _jsxs("span", { className: "flex items-center gap-2", children: [assistant.open ? `Fermer ${label.toLocaleLowerCase("fr-CH")}` : label, letter ? (_jsxs(KbdGroup, { children: [_jsx(Kbd, { children: isApplePlatform() ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: letter })] })) : null] }) })] }));
|
|
140
|
+
}
|
|
141
|
+
/** `Ctrl` (`⌘`) + la lettre de l'assistant : l'ouvre ou le ferme, où que soit le focus. */
|
|
142
|
+
function useAssistantShortcut(assistant) {
|
|
143
|
+
const latest = useRef(assistant);
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
latest.current = assistant;
|
|
146
|
+
});
|
|
147
|
+
const letter = assistant?.shortcut?.toLowerCase();
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!letter)
|
|
150
|
+
return undefined;
|
|
151
|
+
const onKey = (event) => {
|
|
152
|
+
if (event.key.toLowerCase() !== letter || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey)
|
|
153
|
+
return;
|
|
154
|
+
const current = latest.current;
|
|
155
|
+
if (!current)
|
|
156
|
+
return;
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
current.onOpenChange(!current.open);
|
|
159
|
+
};
|
|
160
|
+
document.addEventListener("keydown", onKey);
|
|
161
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
162
|
+
}, [letter]);
|
|
163
|
+
}
|
|
164
|
+
function AssistantPanel({ assistant, panelId, wide, openAtShellMount, buttonRef, }) {
|
|
165
|
+
const { open, onOpenChange } = assistant;
|
|
166
|
+
const label = assistant.label ?? "Assistant";
|
|
167
|
+
const titleId = useId();
|
|
168
|
+
const panelRef = useRef(null);
|
|
169
|
+
// Ouvert dès le premier rendu de la coque (état restauré) : le focus reste où le navigateur le met.
|
|
170
|
+
const wasOpen = useRef(openAtShellMount);
|
|
171
|
+
// Ouvert : le focus entre dans le panneau (sa saisie, sinon le panneau). Fermé : il revient au
|
|
172
|
+
// bouton, s'il était dans le panneau ou perdu sur la page.
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const panel = panelRef.current;
|
|
175
|
+
if (open && !wasOpen.current && panel && !panel.contains(document.activeElement)) {
|
|
176
|
+
const target = panel.querySelector(FOCUSABLE) ?? panel;
|
|
177
|
+
target.focus({ preventScroll: true });
|
|
178
|
+
}
|
|
179
|
+
if (!open && wasOpen.current) {
|
|
180
|
+
const active = document.activeElement;
|
|
181
|
+
if (!active || active === document.body || panel?.contains(active))
|
|
182
|
+
buttonRef.current?.focus();
|
|
183
|
+
}
|
|
184
|
+
wasOpen.current = open;
|
|
185
|
+
}, [open, buttonRef]);
|
|
186
|
+
// Échap, le focus dans le panneau : il se ferme (sauf si un élément du panneau l'a déjà traité).
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
const panel = panelRef.current;
|
|
189
|
+
if (!open || !panel)
|
|
190
|
+
return undefined;
|
|
191
|
+
const onKey = (event) => {
|
|
192
|
+
if (event.key !== "Escape" || event.defaultPrevented)
|
|
193
|
+
return;
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
onOpenChange(false);
|
|
196
|
+
};
|
|
197
|
+
panel.addEventListener("keydown", onKey);
|
|
198
|
+
return () => panel.removeEventListener("keydown", onKey);
|
|
199
|
+
}, [open, onOpenChange]);
|
|
200
|
+
const close = () => onOpenChange(false);
|
|
201
|
+
return (_jsx("section", { ref: panelRef, id: panelId, tabIndex: -1, "data-slot": "app-shell-assistant", "data-open": open ? "" : undefined, "aria-labelledby": titleId, inert: !open || undefined, ...(open && !wide ? { role: "dialog", "aria-modal": true } : {}), className: cn("outline-none",
|
|
202
|
+
// Vue dédiée, sous 1280 px : plein écran, la page reste montée dessous.
|
|
203
|
+
"hidden data-open:max-xl:fixed data-open:max-xl:inset-0 data-open:max-xl:z-40 data-open:max-xl:flex data-open:max-xl:flex-col data-open:max-xl:bg-background", "data-open:max-xl:pt-[env(safe-area-inset-top,0px)] data-open:max-xl:soft-in motion-reduce:animate-none",
|
|
204
|
+
// Côte à côte : une colonne de la grille, qui s'ouvre en largeur ; la page se réorganise.
|
|
205
|
+
"xl:invisible xl:flex xl:w-0 xl:flex-col xl:overflow-hidden xl:transition-[width,visibility] xl:duration-(--duration-slow) xl:ease-ios motion-reduce:transition-none", "xl:data-open:visible xl:data-open:w-[26rem] 2xl:data-open:w-[28rem]"), children: _jsxs("div", { className: "flex min-h-0 flex-1 flex-col xl:w-[26rem] xl:py-2 xl:pr-2 2xl:w-[28rem]", children: [_jsxs("div", { className: "flex min-h-14 shrink-0 items-center gap-1 border-b border-foreground/5 px-2 sm:px-4 xl:border-0 xl:pr-1 xl:pl-3", children: [_jsxs("button", { type: "button", onClick: close, className: "touch-target flex h-9 shrink-0 items-center gap-1.5 rounded-xl pr-3 pl-2 text-sm font-medium text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 xl:hidden", children: [_jsx(HugeiconsIcon, { icon: ArrowLeft01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4" }), "Retour \u00E0 la page"] }), _jsxs("h2", { id: titleId, className: "flex min-w-0 flex-1 items-center gap-2 truncate font-heading text-[0.9375rem] font-semibold max-xl:justify-end max-xl:pr-2", children: [_jsx(HugeiconsIcon, { icon: AiMagicIcon, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem] shrink-0" }), label] }), _jsx("button", { type: "button", onClick: close, "aria-label": `Fermer ${label.toLocaleLowerCase("fr-CH")}`, className: "touch-target grid size-9 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-shell-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 max-xl:hidden", children: _jsx(HugeiconsIcon, { icon: Cancel01Icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem]" }) })] }), _jsx("div", { "data-slot": "app-shell-assistant-body", className: "flex min-h-0 flex-1 flex-col overflow-y-auto", children: assistant.panel })] }) }));
|
|
206
|
+
}
|
|
102
207
|
// ── Coque ─────────────────────────────────────────────────────────────────────────────
|
|
103
208
|
// Les noms de Hugeicons disent l'état du panneau, pas le geste : « Open » dessine la flèche vers la
|
|
104
209
|
// gauche (réduire), « Close » vers la droite (déplier).
|
|
@@ -174,7 +279,7 @@ function useContentLoading({ navigating, pending }) {
|
|
|
174
279
|
}, [active, visible]);
|
|
175
280
|
return visible;
|
|
176
281
|
}
|
|
177
|
-
export function AppShell({ product, organization, productRail = true, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, contentKey, navigating = false, mainClassName, statusPage, }) {
|
|
282
|
+
export function AppShell({ product, organization, productRail = true, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, }) {
|
|
178
283
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
179
284
|
// Barre des produits élargie : la barre latérale se plie le temps du survol (ADR 0011).
|
|
180
285
|
const [railPeek, setRailPeek] = useState(false);
|
|
@@ -213,5 +318,18 @@ export function AppShell({ product, organization, productRail = true, accountHre
|
|
|
213
318
|
if (loading && contentRef.current)
|
|
214
319
|
contentRef.current.scrollTop = 0;
|
|
215
320
|
}, [loading]);
|
|
216
|
-
|
|
321
|
+
// Assistant : monté à la première ouverture, puis gardé (sa conversation survit à la fermeture).
|
|
322
|
+
const assistantOpen = assistant?.open ?? false;
|
|
323
|
+
const [assistantMounted, setAssistantMounted] = useState(assistantOpen);
|
|
324
|
+
const [assistantOpenAtMount] = useState(assistantOpen);
|
|
325
|
+
if (assistantOpen && !assistantMounted)
|
|
326
|
+
setAssistantMounted(true);
|
|
327
|
+
const wide = useMediaQuery(ASSISTANT_SIDE_BY_SIDE);
|
|
328
|
+
// Vue dédiée (sous 1280 px) : la page, dessous, ne reçoit ni focus ni clic.
|
|
329
|
+
const pageBlocked = assistantOpen && !wide;
|
|
330
|
+
const assistantPanelId = useId();
|
|
331
|
+
const assistantButtonRef = useRef(null);
|
|
332
|
+
useAssistantShortcut(assistant);
|
|
333
|
+
const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, _jsx(ShellCommonLinks, { links: links ?? {}, statusPageUrl: statusPageUrl }), account ?? sidebarFooter] }));
|
|
334
|
+
return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsx(StatusPageContext, { value: statusPageUrl, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, "data-assistant": assistantOpen ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)_auto]" : "lg:grid-cols-[auto_minmax(0,1fr)_auto]"), children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), productRail ? (_jsx("div", { className: "contents", inert: pageBlocked || undefined, children: _jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek }) })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", inert: pageBlocked || undefined, className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsx(SidebarHead, { product: product }), !productRail && organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null, nav, _jsx("div", { "data-slot": "app-shell-footer", className: "grid gap-0.5 px-3 pt-1 pb-3 empty:hidden", children: footer })] }), _jsx("div", { inert: pageBlocked || undefined, className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: productName(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 })] }) })] }) }) }) }) }));
|
|
217
335
|
}
|
package/dist/avatar.d.ts
CHANGED
|
@@ -4,7 +4,15 @@ declare function Avatar({ className, size, ...props }: AvatarPrimitive.Root.Prop
|
|
|
4
4
|
size?: "default" | "sm" | "lg";
|
|
5
5
|
}): React.JSX.Element;
|
|
6
6
|
declare function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props): React.JSX.Element;
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Ce qui s'affiche sans photo (la photo, `AvatarImage`, reste prioritaire). Des initiales en
|
|
9
|
+
* texte prennent une teinte de la marque, stable : celle de `colorKey` (identifiant ou adresse
|
|
10
|
+
* e-mail, à préférer, deux homonymes n'ont pas la même couleur), sinon celle du texte. Une icône,
|
|
11
|
+
* ou `colorKey={null}`, garde le gris neutre.
|
|
12
|
+
*/
|
|
13
|
+
declare function AvatarFallback({ className, colorKey, children, ...props }: AvatarPrimitive.Fallback.Props & {
|
|
14
|
+
colorKey?: string | null | undefined;
|
|
15
|
+
}): React.JSX.Element;
|
|
8
16
|
declare function AvatarBadge({ className, ...props }: React.ComponentProps<"span">): React.JSX.Element;
|
|
9
17
|
declare function AvatarGroup({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
10
18
|
declare function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
package/dist/avatar.js
CHANGED
|
@@ -2,14 +2,33 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "./cn.js";
|
|
5
|
+
import { avatarTone } from "./initials.js";
|
|
5
6
|
function Avatar({ className, size = "default", ...props }) {
|
|
6
7
|
return (_jsx(AvatarPrimitive.Root, { "data-slot": "avatar", "data-size": size, className: cn("group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten", className), ...props }));
|
|
7
8
|
}
|
|
8
9
|
function AvatarImage({ className, ...props }) {
|
|
9
10
|
return (_jsx(AvatarPrimitive.Image, { "data-slot": "avatar-image", className: cn("aspect-square size-full rounded-full object-cover", className), ...props }));
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
/** Lavis et encre de chaque teinte (`--avatar-<n>`, `styles.css`), à 4,5:1 en clair et en sombre. */
|
|
13
|
+
const TONE_CLASSES = {
|
|
14
|
+
1: "bg-avatar-1 text-avatar-1-ink",
|
|
15
|
+
2: "bg-avatar-2 text-avatar-2-ink",
|
|
16
|
+
3: "bg-avatar-3 text-avatar-3-ink",
|
|
17
|
+
4: "bg-avatar-4 text-avatar-4-ink",
|
|
18
|
+
5: "bg-avatar-5 text-avatar-5-ink",
|
|
19
|
+
6: "bg-avatar-6 text-avatar-6-ink",
|
|
20
|
+
7: "bg-avatar-7 text-avatar-7-ink",
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Ce qui s'affiche sans photo (la photo, `AvatarImage`, reste prioritaire). Des initiales en
|
|
24
|
+
* texte prennent une teinte de la marque, stable : celle de `colorKey` (identifiant ou adresse
|
|
25
|
+
* e-mail, à préférer, deux homonymes n'ont pas la même couleur), sinon celle du texte. Une icône,
|
|
26
|
+
* ou `colorKey={null}`, garde le gris neutre.
|
|
27
|
+
*/
|
|
28
|
+
function AvatarFallback({ className, colorKey, children, ...props }) {
|
|
29
|
+
const key = colorKey === undefined ? (typeof children === "string" ? children : "") : (colorKey ?? "");
|
|
30
|
+
const tone = key.trim() ? avatarTone(key) : null;
|
|
31
|
+
return (_jsx(AvatarPrimitive.Fallback, { "data-slot": "avatar-fallback", "data-tone": tone ?? undefined, className: cn("flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs", tone ? cn("font-semibold", TONE_CLASSES[tone]) : "bg-muted text-muted-foreground", className), ...props, children: children }));
|
|
13
32
|
}
|
|
14
33
|
function AvatarBadge({ className, ...props }) {
|
|
15
34
|
return (_jsx("span", { "data-slot": "avatar-badge", className: cn("absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none", "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", className), ...props }));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import type { CursorPageResult, CursorPages } from "./cursor-pages.ts";
|
|
3
|
+
/** « Lignes 201 à 400 », « 1 ligne », « 37 lignes ». */
|
|
4
|
+
export declare function rangeLabel(offset: number, count: number, single: boolean): string;
|
|
5
|
+
/**
|
|
6
|
+
* Navigation d'une liste paginée par curseur (`useCursorPages`) : Précédent, Suivant et les lignes
|
|
7
|
+
* affichées. Sous le tableau ou le journal, à la place de `pagination` ou `loadMore` de `DataTable`.
|
|
8
|
+
*
|
|
9
|
+
* `result` à `undefined` pendant le chargement : la barre garde sa hauteur, les boutons sont
|
|
10
|
+
* désactivés, « Chargement… » remplace le décompte. Une seule page : le décompte seul. Aucune
|
|
11
|
+
* ligne sur la première page : rien, l'état vide de la liste suffit.
|
|
12
|
+
*
|
|
13
|
+
* `scrollTarget` : le haut de la liste, ramené à l'écran à chaque changement de page.
|
|
14
|
+
*/
|
|
15
|
+
export declare function CursorPager({ pages, result, scrollTarget, label, className, }: {
|
|
16
|
+
pages: CursorPages;
|
|
17
|
+
result: CursorPageResult | undefined;
|
|
18
|
+
scrollTarget?: RefObject<HTMLElement | null>;
|
|
19
|
+
/** Nom de la navigation pour les lecteurs d'écran : « Pagination des appels ». */
|
|
20
|
+
label?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
}): React.JSX.Element | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import ArrowLeft01Icon from "@hugeicons/core-free-icons/ArrowLeft01Icon";
|
|
3
|
+
import ArrowRight01Icon from "@hugeicons/core-free-icons/ArrowRight01Icon";
|
|
4
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
5
|
+
import { Button } from "./button.js";
|
|
6
|
+
import { cn } from "./cn.js";
|
|
7
|
+
import { Spinner } from "./spinner.js";
|
|
8
|
+
const NUMBER = new Intl.NumberFormat("fr-CH");
|
|
9
|
+
/** « Lignes 201 à 400 », « 1 ligne », « 37 lignes ». */
|
|
10
|
+
export function rangeLabel(offset, count, single) {
|
|
11
|
+
if (single)
|
|
12
|
+
return `${NUMBER.format(count)} ${count > 1 ? "lignes" : "ligne"}`;
|
|
13
|
+
return `Lignes ${NUMBER.format(offset + 1)} à ${NUMBER.format(offset + count)}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Navigation d'une liste paginée par curseur (`useCursorPages`) : Précédent, Suivant et les lignes
|
|
17
|
+
* affichées. Sous le tableau ou le journal, à la place de `pagination` ou `loadMore` de `DataTable`.
|
|
18
|
+
*
|
|
19
|
+
* `result` à `undefined` pendant le chargement : la barre garde sa hauteur, les boutons sont
|
|
20
|
+
* désactivés, « Chargement… » remplace le décompte. Une seule page : le décompte seul. Aucune
|
|
21
|
+
* ligne sur la première page : rien, l'état vide de la liste suffit.
|
|
22
|
+
*
|
|
23
|
+
* `scrollTarget` : le haut de la liste, ramené à l'écran à chaque changement de page.
|
|
24
|
+
*/
|
|
25
|
+
export function CursorPager({ pages, result, scrollTarget, label = "Pagination", className, }) {
|
|
26
|
+
const loading = result === undefined;
|
|
27
|
+
const count = result?.page.length ?? 0;
|
|
28
|
+
const hasNext = result !== undefined && !result.isDone;
|
|
29
|
+
const single = !pages.hasPrevious && result?.isDone === true;
|
|
30
|
+
if (!loading && count === 0 && !pages.hasPrevious)
|
|
31
|
+
return null;
|
|
32
|
+
const go = (move) => {
|
|
33
|
+
move();
|
|
34
|
+
scrollTarget?.current?.scrollIntoView({ block: "start" });
|
|
35
|
+
};
|
|
36
|
+
return (_jsxs("nav", { "aria-label": label, "aria-busy": loading || undefined, "data-slot": "cursor-pager", className: cn("flex min-h-10 flex-wrap items-center justify-between gap-x-3 gap-y-2 px-1 text-sm", className), children: [_jsx("p", { className: "flex items-center gap-2 text-muted-foreground tabular-nums", "aria-live": "polite", children: loading ? _jsx(Spinner, { label: "Chargement\u2026" }) : rangeLabel(pages.offset, count, single) }), single ? null : (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Button, { variant: "outline", size: "sm", disabled: !pages.hasPrevious || loading, onClick: () => go(pages.previous), children: [_jsx(HugeiconsIcon, { icon: ArrowLeft01Icon, strokeWidth: 2, "aria-hidden": true, "data-icon": "inline-start" }), "Pr\u00E9c\u00E9dent"] }), _jsxs(Button, { variant: "outline", size: "sm", disabled: !hasNext, onClick: () => {
|
|
37
|
+
if (result !== undefined)
|
|
38
|
+
go(() => pages.next(result));
|
|
39
|
+
}, children: ["Suivant", _jsx(HugeiconsIcon, { icon: ArrowRight01Icon, strokeWidth: 2, "aria-hidden": true, "data-icon": "inline-end" })] })] }))] }));
|
|
40
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** La règle commune des grandes listes : 200 lignes par page. */
|
|
2
|
+
export declare const PAGE_SIZE = 200;
|
|
3
|
+
/** Ce que renvoie `paginate` de Convex (`PaginationResult`), réduit à ce dont la pile a besoin. */
|
|
4
|
+
export type CursorPageResult<Item = unknown> = {
|
|
5
|
+
page: ReadonlyArray<Item>;
|
|
6
|
+
isDone: boolean;
|
|
7
|
+
continueCursor: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Pile des curseurs : `cursors[i]` ouvre la page `i` (`null` pour la première), `offsets[i]` compte
|
|
11
|
+
* les lignes des pages d'avant. La page `i + 1`, si elle est connue, borne la page `i` : revenir en
|
|
12
|
+
* arrière relit exactement les mêmes lignes, sans perte ni doublon, même si la liste bouge.
|
|
13
|
+
*/
|
|
14
|
+
export type CursorStack = {
|
|
15
|
+
index: number;
|
|
16
|
+
cursors: ReadonlyArray<string | null>;
|
|
17
|
+
offsets: ReadonlyArray<number>;
|
|
18
|
+
};
|
|
19
|
+
export declare const FIRST_PAGE: CursorStack;
|
|
20
|
+
/** Page suivante, depuis le résultat de la page `from` ; rien ne change si l'on n'y est plus (double clic) ou si la liste est finie. */
|
|
21
|
+
export declare function nextPage(stack: CursorStack, from: number, result: CursorPageResult): CursorStack;
|
|
22
|
+
/** Page précédente ; rien ne change sur la première. */
|
|
23
|
+
export declare function previousPage(stack: CursorStack, from: number): CursorStack;
|
|
24
|
+
/** Options à passer telles quelles à une query Convex qui reçoit `paginationOptsValidator`. */
|
|
25
|
+
export type CursorPaginationOpts = {
|
|
26
|
+
numItems: number;
|
|
27
|
+
cursor: string | null;
|
|
28
|
+
endCursor?: string;
|
|
29
|
+
};
|
|
30
|
+
export type CursorPages = {
|
|
31
|
+
pageSize: number;
|
|
32
|
+
/** Page affichée, à partir de 0. */
|
|
33
|
+
pageIndex: number;
|
|
34
|
+
/** Lignes des pages d'avant : la page commence à la ligne `offset + 1`. */
|
|
35
|
+
offset: number;
|
|
36
|
+
cursor: string | null;
|
|
37
|
+
/** Fin de la page quand la suivante a déjà été ouverte. */
|
|
38
|
+
endCursor: string | null;
|
|
39
|
+
paginationOpts: CursorPaginationOpts;
|
|
40
|
+
hasPrevious: boolean;
|
|
41
|
+
/** À appeler avec le résultat de la page affichée. */
|
|
42
|
+
next: (result: CursorPageResult) => void;
|
|
43
|
+
previous: () => void;
|
|
44
|
+
/** Retour à la première page. */
|
|
45
|
+
reset: () => void;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Pagination par curseur, page par page (Précédent, Suivant), pour une liste paginée côté serveur.
|
|
49
|
+
* Indépendant de Convex, mais calé sur `paginate({ numItems, cursor, endCursor })` et son résultat
|
|
50
|
+
* `{ page, isDone, continueCursor }` : `paginationOpts` se passe à la query, le résultat à `next`.
|
|
51
|
+
*
|
|
52
|
+
* `filtersKey` résume les filtres (période, statut, recherche, tri) : quand elle change, la liste
|
|
53
|
+
* revient à la première page, puisque les curseurs d'avant ne valent plus rien.
|
|
54
|
+
*/
|
|
55
|
+
export declare function useCursorPages(filtersKey: string, { pageSize }?: {
|
|
56
|
+
pageSize?: number;
|
|
57
|
+
}): CursorPages;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
/** La règle commune des grandes listes : 200 lignes par page. */
|
|
3
|
+
export const PAGE_SIZE = 200;
|
|
4
|
+
export const FIRST_PAGE = { index: 0, cursors: [null], offsets: [0] };
|
|
5
|
+
/** Page suivante, depuis le résultat de la page `from` ; rien ne change si l'on n'y est plus (double clic) ou si la liste est finie. */
|
|
6
|
+
export function nextPage(stack, from, result) {
|
|
7
|
+
if (stack.index !== from || result.isDone)
|
|
8
|
+
return stack;
|
|
9
|
+
const to = from + 1;
|
|
10
|
+
if (stack.cursors.length > to)
|
|
11
|
+
return { ...stack, index: to };
|
|
12
|
+
return {
|
|
13
|
+
index: to,
|
|
14
|
+
cursors: [...stack.cursors, result.continueCursor],
|
|
15
|
+
offsets: [...stack.offsets, (stack.offsets[from] ?? 0) + result.page.length],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Page précédente ; rien ne change sur la première. */
|
|
19
|
+
export function previousPage(stack, from) {
|
|
20
|
+
if (stack.index !== from || from === 0)
|
|
21
|
+
return stack;
|
|
22
|
+
return { ...stack, index: from - 1 };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Pagination par curseur, page par page (Précédent, Suivant), pour une liste paginée côté serveur.
|
|
26
|
+
* Indépendant de Convex, mais calé sur `paginate({ numItems, cursor, endCursor })` et son résultat
|
|
27
|
+
* `{ page, isDone, continueCursor }` : `paginationOpts` se passe à la query, le résultat à `next`.
|
|
28
|
+
*
|
|
29
|
+
* `filtersKey` résume les filtres (période, statut, recherche, tri) : quand elle change, la liste
|
|
30
|
+
* revient à la première page, puisque les curseurs d'avant ne valent plus rien.
|
|
31
|
+
*/
|
|
32
|
+
export function useCursorPages(filtersKey, { pageSize = PAGE_SIZE } = {}) {
|
|
33
|
+
const [state, setState] = useState({ key: filtersKey, stack: FIRST_PAGE });
|
|
34
|
+
// Retour au début pendant le rendu même : aucune requête ne part avec un curseur d'un autre filtre.
|
|
35
|
+
const stack = state.key === filtersKey ? state.stack : FIRST_PAGE;
|
|
36
|
+
if (state.key !== filtersKey)
|
|
37
|
+
setState({ key: filtersKey, stack: FIRST_PAGE });
|
|
38
|
+
const index = stack.index;
|
|
39
|
+
const next = useCallback((result) => setState((s) => (s.key === filtersKey ? { key: s.key, stack: nextPage(s.stack, index, result) } : s)), [filtersKey, index]);
|
|
40
|
+
const previous = useCallback(() => setState((s) => (s.key === filtersKey ? { key: s.key, stack: previousPage(s.stack, index) } : s)), [filtersKey, index]);
|
|
41
|
+
const reset = useCallback(() => setState({ key: filtersKey, stack: FIRST_PAGE }), [filtersKey]);
|
|
42
|
+
const cursor = stack.cursors[index] ?? null;
|
|
43
|
+
const endCursor = stack.cursors[index + 1] ?? null;
|
|
44
|
+
return {
|
|
45
|
+
pageSize,
|
|
46
|
+
pageIndex: index,
|
|
47
|
+
offset: stack.offsets[index] ?? 0,
|
|
48
|
+
cursor,
|
|
49
|
+
endCursor,
|
|
50
|
+
paginationOpts: endCursor === null ? { numItems: pageSize, cursor } : { numItems: pageSize, cursor, endCursor },
|
|
51
|
+
hasPrevious: index > 0,
|
|
52
|
+
next,
|
|
53
|
+
previous,
|
|
54
|
+
reset,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/family-loader.d.ts
CHANGED
|
@@ -21,11 +21,10 @@ export declare function syncFamilyLoader(root: Element | null): void;
|
|
|
21
21
|
*/
|
|
22
22
|
export declare const FamilyLoaderRootContext: React.Context<boolean>;
|
|
23
23
|
/**
|
|
24
|
-
* Le H du chargement
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* seulement. Mouvement réduit : le H plein respire doucement.
|
|
24
|
+
* Le H du chargement : un seul H, sans reflet ni ombre, sa silhouette grise toujours visible. Les
|
|
25
|
+
* trois pièces se remplissent de leurs dégradés en une seule boucle de 2 s (gauche, pont, droite,
|
|
26
|
+
* H complet, fondu), calée sur l'horloge. CSS seul (`styles.css`), `transform` et `opacity`
|
|
27
|
+
* seulement. Mouvement réduit : le H plein, immobile.
|
|
29
28
|
*
|
|
30
29
|
* Un chargement de page ne dessine pas ce H lui-même : c'est `PagePending`, qui passe par le H du
|
|
31
30
|
* document (`FamilyLoaderRoot`), au même pixel dans tous les états. Ce composant sert au H en
|
package/dist/family-loader.js
CHANGED
|
@@ -54,9 +54,9 @@ function useClockSync(ref) {
|
|
|
54
54
|
}, [ref]);
|
|
55
55
|
}
|
|
56
56
|
const PIECES = ["left", "bar", "right"];
|
|
57
|
-
/**
|
|
57
|
+
/** Un seul H : les trois pièces du H plein qui se remplissent, sans reflet ni ombre. */
|
|
58
58
|
function Mark() {
|
|
59
|
-
return (
|
|
59
|
+
return (_jsx(_Fragment, { children: PIECES.map((piece) => (_jsx("span", { "data-piece": piece, "aria-hidden": true, children: _jsx("span", { "data-fill": true }) }, piece))) }));
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
62
|
* Vrai sous `FamilyLoaderRoot` : le document a son chargeur, `PagePending` et la coque s'y
|
|
@@ -64,11 +64,10 @@ function Mark() {
|
|
|
64
64
|
*/
|
|
65
65
|
export const FamilyLoaderRootContext = createContext(false);
|
|
66
66
|
/**
|
|
67
|
-
* Le H du chargement
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* seulement. Mouvement réduit : le H plein respire doucement.
|
|
67
|
+
* Le H du chargement : un seul H, sans reflet ni ombre, sa silhouette grise toujours visible. Les
|
|
68
|
+
* trois pièces se remplissent de leurs dégradés en une seule boucle de 2 s (gauche, pont, droite,
|
|
69
|
+
* H complet, fondu), calée sur l'horloge. CSS seul (`styles.css`), `transform` et `opacity`
|
|
70
|
+
* seulement. Mouvement réduit : le H plein, immobile.
|
|
72
71
|
*
|
|
73
72
|
* Un chargement de page ne dessine pas ce H lui-même : c'est `PagePending`, qui passe par le H du
|
|
74
73
|
* document (`FamilyLoaderRoot`), au même pixel dans tous les états. Ce composant sert au H en
|
package/dist/initials.d.ts
CHANGED
|
@@ -6,3 +6,12 @@
|
|
|
6
6
|
* e-mail : « jean.dupont@… » donne « JD »), puis « ? ».
|
|
7
7
|
*/
|
|
8
8
|
export declare function initials(name: string | null | undefined, fallback?: string | null): string;
|
|
9
|
+
/** Nombre de teintes d'avatar (`--avatar-1` à `--avatar-7`, `styles.css`). */
|
|
10
|
+
export declare const AVATAR_TONES = 7;
|
|
11
|
+
export type AvatarTone = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
12
|
+
/**
|
|
13
|
+
* La teinte de l'avatar à initiales d'une personne ou d'une organisation, stable : la même clé
|
|
14
|
+
* (identifiant, ou adresse e-mail, sans tenir compte de la casse ni des espaces autour) donne
|
|
15
|
+
* toujours la même teinte, dans tous les produits. FNV-1a 32 bits, puis modulo 7.
|
|
16
|
+
*/
|
|
17
|
+
export declare function avatarTone(key: string): AvatarTone;
|
package/dist/initials.js
CHANGED
|
@@ -21,3 +21,18 @@ export function initials(name, fallback) {
|
|
|
21
21
|
return fromName;
|
|
22
22
|
return firstLetters((fallback ?? "").split("@")[0] ?? "", /[\s._+-]+/) || "?";
|
|
23
23
|
}
|
|
24
|
+
/** Nombre de teintes d'avatar (`--avatar-1` à `--avatar-7`, `styles.css`). */
|
|
25
|
+
export const AVATAR_TONES = 7;
|
|
26
|
+
/**
|
|
27
|
+
* La teinte de l'avatar à initiales d'une personne ou d'une organisation, stable : la même clé
|
|
28
|
+
* (identifiant, ou adresse e-mail, sans tenir compte de la casse ni des espaces autour) donne
|
|
29
|
+
* toujours la même teinte, dans tous les produits. FNV-1a 32 bits, puis modulo 7.
|
|
30
|
+
*/
|
|
31
|
+
export function avatarTone(key) {
|
|
32
|
+
let hash = 0x811c9dc5;
|
|
33
|
+
for (const char of key.trim().toLocaleLowerCase("fr-CH")) {
|
|
34
|
+
hash ^= char.codePointAt(0) ?? 0;
|
|
35
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
36
|
+
}
|
|
37
|
+
return ((hash % AVATAR_TONES) + 1);
|
|
38
|
+
}
|
package/dist/member-item.js
CHANGED
|
@@ -18,5 +18,5 @@ import { ResourceItem } from "./resource-list.js";
|
|
|
18
18
|
*/
|
|
19
19
|
export function MemberItem({ name, email, orgRole: role, image, isMe = false, roleHint, actions, className, }) {
|
|
20
20
|
const shown = name.trim() || email || "Sans nom";
|
|
21
|
-
return (_jsx(ResourceItem, { className: className, icon: _jsxs(Avatar, { className: "size-10", children: [image ? _jsx(AvatarImage, { src: image, alt: "" }) : null, _jsx(AvatarFallback, { className: "text-xs font-semibold", children: initials(name, email) })] }), title: _jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx("span", { className: "truncate", children: shown }), isMe ? _jsx("span", { className: "shrink-0 font-normal text-muted-foreground", children: "(toi)" }) : null] }), meta: email && email !== shown ? email : undefined, badge: role ? _jsx(RoleBadge, { orgRole: role, ...(roleHint !== undefined ? { title: roleHint } : {}) }) : undefined, actions: actions }));
|
|
21
|
+
return (_jsx(ResourceItem, { className: className, icon: _jsxs(Avatar, { className: "size-10", children: [image ? _jsx(AvatarImage, { src: image, alt: "" }) : null, _jsx(AvatarFallback, { colorKey: email || name, className: "text-xs font-semibold", children: initials(name, email) })] }), title: _jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx("span", { className: "truncate", children: shown }), isMe ? _jsx("span", { className: "shrink-0 font-normal text-muted-foreground", children: "(toi)" }) : null] }), meta: email && email !== shown ? email : undefined, badge: role ? _jsx(RoleBadge, { orgRole: role, ...(roleHint !== undefined ? { title: roleHint } : {}) }) : undefined, actions: actions }));
|
|
22
22
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
export type SwitcherProject = {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
/** Image carrée du projet (favicon du site, logo). Sans image, ou si elle ne charge pas : `mark`. */
|
|
6
|
+
iconUrl?: string | null;
|
|
7
|
+
/** Une ou deux lettres de repli : la marque du framework, l'initiale du produit. Par défaut, la première lettre du nom. */
|
|
8
|
+
mark?: string;
|
|
9
|
+
/** Lien vers le projet ; sans lien, le choix passe par `onSelect`. */
|
|
10
|
+
href?: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Identité visuelle d'un projet : son image, ou sa marque en lettres sur un carré gris. La même
|
|
14
|
+
* dans le sélecteur, une liste de projets ou un en-tête.
|
|
15
|
+
*/
|
|
16
|
+
export declare function ProjectMark({ project, className, }: {
|
|
17
|
+
project: Pick<SwitcherProject, "name" | "iconUrl" | "mark">;
|
|
18
|
+
className?: string;
|
|
19
|
+
}): React.JSX.Element;
|
|
20
|
+
/**
|
|
21
|
+
* Le projet ouvert, ou « Tous les projets » sur les pages de l'organisation, et le menu pour passer
|
|
22
|
+
* d'un projet à l'autre ou en créer un. Dans la barre du haut d'`AppShell` (`header`), à côté de la
|
|
23
|
+
* barre latérale. Le même dans tous les produits qui ont des projets (Host, Docs…).
|
|
24
|
+
*
|
|
25
|
+
* Présentationnel : le produit passe la liste et dit où mène chaque choix, par lien (`href` sur le
|
|
26
|
+
* projet, `allHref`, `createHref`, rendus par `renderLink`, par exemple
|
|
27
|
+
* `(href) => <Link to={href} />`) ou par action (`onSelect`, `onCreate`). `projects` à `undefined`
|
|
28
|
+
* pendant le chargement : le déclencheur montre déjà `activeName` (le segment de l'URL), le menu
|
|
29
|
+
* reste utilisable pour « Tous les projets » et « Nouveau projet ».
|
|
30
|
+
*/
|
|
31
|
+
export declare function ProjectSwitcher({ projects, activeId, activeName, onSelect, allHref, createHref, onCreate, renderLink, createLabel, className, }: {
|
|
32
|
+
projects: ReadonlyArray<SwitcherProject> | undefined;
|
|
33
|
+
/** Le projet ouvert ; `null` ou absent : « Tous les projets ». */
|
|
34
|
+
activeId?: string | null;
|
|
35
|
+
/** Nom affiché tant que la liste charge, ou si le projet actif n'y figure pas. */
|
|
36
|
+
activeName?: string;
|
|
37
|
+
/** Choix d'un projet sans `href` (`id`), ou de « Tous les projets » sans `allHref` (`null`). */
|
|
38
|
+
onSelect?: (id: string | null) => void;
|
|
39
|
+
allHref?: string;
|
|
40
|
+
/** Lien « Nouveau projet ». */
|
|
41
|
+
createHref?: string;
|
|
42
|
+
/** Ou une action, si la création s'ouvre dans une boîte de dialogue. */
|
|
43
|
+
onCreate?: () => void;
|
|
44
|
+
/** Rend un lien du routeur du produit ; par défaut un `<a href>`. */
|
|
45
|
+
renderLink?: (href: string) => ReactElement;
|
|
46
|
+
createLabel?: string;
|
|
47
|
+
className?: string;
|
|
48
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import DashboardSquare01Icon from "@hugeicons/core-free-icons/DashboardSquare01Icon";
|
|
3
|
+
import PlusSignIcon from "@hugeicons/core-free-icons/PlusSignIcon";
|
|
4
|
+
import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
|
|
5
|
+
import UnfoldMoreIcon from "@hugeicons/core-free-icons/UnfoldMoreIcon";
|
|
6
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
7
|
+
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { cn } from "./cn.js";
|
|
10
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
|
|
11
|
+
import { MENU_LINK_ITEM } from "./org-switcher.js";
|
|
12
|
+
/**
|
|
13
|
+
* Identité visuelle d'un projet : son image, ou sa marque en lettres sur un carré gris. La même
|
|
14
|
+
* dans le sélecteur, une liste de projets ou un en-tête.
|
|
15
|
+
*/
|
|
16
|
+
export function ProjectMark({ project, className, }) {
|
|
17
|
+
const [broken, setBroken] = useState(null);
|
|
18
|
+
const url = project.iconUrl && project.iconUrl !== broken ? project.iconUrl : null;
|
|
19
|
+
const base = "grid size-6 shrink-0 place-items-center overflow-hidden rounded-md bg-muted";
|
|
20
|
+
if (url)
|
|
21
|
+
return (_jsx("span", { "aria-hidden": true, "data-slot": "project-mark", className: cn(base, className), children: _jsx("img", { src: url, alt: "", loading: "lazy", decoding: "async", onError: () => setBroken(url), className: "size-[60%] object-contain" }) }));
|
|
22
|
+
const mark = (project.mark ?? project.name.trim().charAt(0)) || "?";
|
|
23
|
+
return (_jsx("span", { "aria-hidden": true, "data-slot": "project-mark", className: cn(base, "font-mono text-[10px] font-semibold text-foreground uppercase", className), children: mark }));
|
|
24
|
+
}
|
|
25
|
+
/** Rend une destination du menu : un lien (`href`, par `renderLink`) ou une action. */
|
|
26
|
+
function SwitcherEntry({ href, onSelect, renderLink, children, }) {
|
|
27
|
+
if (href !== undefined)
|
|
28
|
+
return (_jsx(MenuPrimitive.LinkItem, { href: href, render: renderLink?.(href), closeOnClick: true, "data-slot": "dropdown-menu-item", className: MENU_LINK_ITEM, children: children }));
|
|
29
|
+
return _jsx(DropdownMenuItem, { onClick: onSelect, children: children });
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Le projet ouvert, ou « Tous les projets » sur les pages de l'organisation, et le menu pour passer
|
|
33
|
+
* d'un projet à l'autre ou en créer un. Dans la barre du haut d'`AppShell` (`header`), à côté de la
|
|
34
|
+
* barre latérale. Le même dans tous les produits qui ont des projets (Host, Docs…).
|
|
35
|
+
*
|
|
36
|
+
* Présentationnel : le produit passe la liste et dit où mène chaque choix, par lien (`href` sur le
|
|
37
|
+
* projet, `allHref`, `createHref`, rendus par `renderLink`, par exemple
|
|
38
|
+
* `(href) => <Link to={href} />`) ou par action (`onSelect`, `onCreate`). `projects` à `undefined`
|
|
39
|
+
* pendant le chargement : le déclencheur montre déjà `activeName` (le segment de l'URL), le menu
|
|
40
|
+
* reste utilisable pour « Tous les projets » et « Nouveau projet ».
|
|
41
|
+
*/
|
|
42
|
+
export function ProjectSwitcher({ projects, activeId, activeName, onSelect, allHref, createHref, onCreate, renderLink, createLabel = "Nouveau projet", className, }) {
|
|
43
|
+
const hasActive = activeId !== undefined && activeId !== null;
|
|
44
|
+
const current = hasActive ? projects?.find((p) => p.id === activeId) : undefined;
|
|
45
|
+
const label = hasActive ? (current?.name ?? activeName ?? "") : "Tous les projets";
|
|
46
|
+
const canCreate = createHref !== undefined || onCreate !== undefined;
|
|
47
|
+
return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": hasActive ? `Projet ${label}, changer de projet` : "Tous les projets, changer de projet", className: cn("flex h-10 max-w-full min-w-0 items-center gap-2 rounded-xl px-2.5 text-sm font-medium 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 coarse:h-11", className) }), children: [hasActive ? (current ? (_jsx(ProjectMark, { project: current })) : (_jsx("span", { "aria-hidden": true, className: "size-6 shrink-0 rounded-md bg-muted skeleton-pulse" }))) : (_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground" })), _jsx("span", { className: "min-w-0 truncate", children: label }), _jsx(HugeiconsIcon, { icon: UnfoldMoreIcon, "aria-hidden": true, className: "size-4 shrink-0 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", className: "w-72 max-w-[calc(100vw-2rem)]", children: [_jsxs(SwitcherEntry, { href: allHref, onSelect: () => onSelect?.(null), renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: DashboardSquare01Icon, "aria-hidden": true, className: "size-4" }), _jsx("span", { className: "flex-1", children: "Tous les projets" }), hasActive ? null : _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Vue active", className: "size-4" })] }), projects?.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Projets" }), projects.map((project) => (_jsxs(SwitcherEntry, { href: project.href, onSelect: () => onSelect?.(project.id), renderLink: renderLink, children: [_jsx(ProjectMark, { project: project }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: project.name }), project.id === activeId ? (_jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Projet actif", className: "size-4" })) : null] }, project.id)))] })] })) : null, canCreate ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(SwitcherEntry, { href: createHref, onSelect: onCreate, renderLink: renderLink, children: [_jsx(HugeiconsIcon, { icon: PlusSignIcon, "aria-hidden": true, className: "size-4" }), createLabel] })] })) : null] })] }));
|
|
48
|
+
}
|
package/dist/theme-switch.js
CHANGED
|
@@ -37,6 +37,10 @@ export function ThemeSwitch({ className }) {
|
|
|
37
37
|
const current = CHOICES.find((choice) => choice.value === theme);
|
|
38
38
|
return (_jsxs("div", { className: cn("flex items-center gap-2.5 px-3 py-[3px] text-sm font-medium max-lg:min-h-11 coarse:min-h-11", className), children: [_jsx(HugeiconsIcon, { icon: current?.icon ?? ComputerIcon, "aria-hidden": true, className: "size-4 shrink-0" }), _jsx("span", { className: "flex-1", children: "Th\u00E8me" }), _jsx("div", { ref: group, role: "radiogroup", "aria-label": "Th\u00E8me", tabIndex: -1, onKeyDown: onKeyDown, className: "flex w-fit items-center gap-0.5 rounded-full border border-border p-0.5", children: CHOICES.map(({ value, label, icon }) => {
|
|
39
39
|
const active = theme === value;
|
|
40
|
-
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(MenuPrimitive.Item, { closeOnClick: false, role: "radio", "aria-checked": active, "aria-label": label, "data-theme-choice": value, onClick: () => setTheme(value), className: cn("touch-target grid size-
|
|
40
|
+
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(MenuPrimitive.Item, { closeOnClick: false, role: "radio", "aria-checked": active, "aria-label": label, "data-theme-choice": value, onClick: () => setTheme(value), className: cn("touch-target grid size-7 cursor-default place-items-center rounded-full outline-none", "transition-colors duration-(--duration-fast) ease-out focus-visible:ring-2 focus-visible:ring-ring/50",
|
|
41
|
+
// Le choix actif comme dans `SegmentedControl` : encre pleine, lisible d'un coup d'œil.
|
|
42
|
+
active
|
|
43
|
+
? "bg-foreground text-background"
|
|
44
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground data-highlighted:bg-muted data-highlighted:text-foreground") }), children: _jsx(HugeiconsIcon, { icon: icon, "aria-hidden": true, className: "size-4" }) }), _jsx(TooltipContent, { children: label })] }, value));
|
|
41
45
|
}) })] }));
|
|
42
46
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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",
|
package/styles.css
CHANGED
|
@@ -100,6 +100,20 @@
|
|
|
100
100
|
--color-inverse-foreground: var(--inverse-foreground);
|
|
101
101
|
--color-inverse-muted: var(--inverse-muted);
|
|
102
102
|
--color-inverse-code: var(--inverse-code);
|
|
103
|
+
--color-avatar-1: var(--avatar-1);
|
|
104
|
+
--color-avatar-1-ink: var(--avatar-1-ink);
|
|
105
|
+
--color-avatar-2: var(--avatar-2);
|
|
106
|
+
--color-avatar-2-ink: var(--avatar-2-ink);
|
|
107
|
+
--color-avatar-3: var(--avatar-3);
|
|
108
|
+
--color-avatar-3-ink: var(--avatar-3-ink);
|
|
109
|
+
--color-avatar-4: var(--avatar-4);
|
|
110
|
+
--color-avatar-4-ink: var(--avatar-4-ink);
|
|
111
|
+
--color-avatar-5: var(--avatar-5);
|
|
112
|
+
--color-avatar-5-ink: var(--avatar-5-ink);
|
|
113
|
+
--color-avatar-6: var(--avatar-6);
|
|
114
|
+
--color-avatar-6-ink: var(--avatar-6-ink);
|
|
115
|
+
--color-avatar-7: var(--avatar-7);
|
|
116
|
+
--color-avatar-7-ink: var(--avatar-7-ink);
|
|
103
117
|
--radius-sm: calc(var(--radius) * 0.6);
|
|
104
118
|
--radius-md: calc(var(--radius) * 0.8);
|
|
105
119
|
--radius-lg: var(--radius);
|
|
@@ -185,6 +199,23 @@
|
|
|
185
199
|
--inverse-foreground: oklch(0.985 0 0);
|
|
186
200
|
--inverse-muted: oklch(0.708 0 0);
|
|
187
201
|
--inverse-code: oklch(0.82 0.11 296);
|
|
202
|
+
/* Avatars à initiales (`AvatarFallback`) : sept teintes, celles des icônes des produits (Host,
|
|
203
|
+
Portal, Account, Mail, Status, Analytics, Docs), en lavis avec une encre de la même teinte.
|
|
204
|
+
Initiales à 5,5:1 au moins, en clair comme en sombre (vérifié par styles.test.ts). */
|
|
205
|
+
--avatar-1: oklch(0.88 0.055 262);
|
|
206
|
+
--avatar-1-ink: oklch(0.42 0.12 262);
|
|
207
|
+
--avatar-2: oklch(0.88 0.06 293);
|
|
208
|
+
--avatar-2-ink: oklch(0.42 0.12 293);
|
|
209
|
+
--avatar-3: oklch(0.88 0.062 40);
|
|
210
|
+
--avatar-3-ink: oklch(0.42 0.12 40);
|
|
211
|
+
--avatar-4: oklch(0.88 0.063 2);
|
|
212
|
+
--avatar-4-ink: oklch(0.42 0.12 2);
|
|
213
|
+
--avatar-5: oklch(0.88 0.07 160);
|
|
214
|
+
--avatar-5-ink: oklch(0.42 0.09 160);
|
|
215
|
+
--avatar-6: oklch(0.88 0.07 75);
|
|
216
|
+
--avatar-6-ink: oklch(0.42 0.083 75);
|
|
217
|
+
--avatar-7: oklch(0.88 0.07 215);
|
|
218
|
+
--avatar-7-ink: oklch(0.42 0.07 215);
|
|
188
219
|
--radius: 0.85rem;
|
|
189
220
|
--scrollbar-thumb: color-mix(in oklch, var(--foreground) 18%, transparent);
|
|
190
221
|
}
|
|
@@ -229,6 +260,20 @@
|
|
|
229
260
|
--success: oklch(0.7 0.15 152);
|
|
230
261
|
--success-foreground: oklch(0.16 0.02 152);
|
|
231
262
|
--inverse: oklch(0.1 0 0);
|
|
263
|
+
--avatar-1: oklch(0.4 0.09 262);
|
|
264
|
+
--avatar-1-ink: oklch(0.9 0.045 262);
|
|
265
|
+
--avatar-2: oklch(0.4 0.09 293);
|
|
266
|
+
--avatar-2-ink: oklch(0.9 0.05 293);
|
|
267
|
+
--avatar-3: oklch(0.4 0.09 40);
|
|
268
|
+
--avatar-3-ink: oklch(0.9 0.05 40);
|
|
269
|
+
--avatar-4: oklch(0.4 0.09 2);
|
|
270
|
+
--avatar-4-ink: oklch(0.9 0.05 2);
|
|
271
|
+
--avatar-5: oklch(0.4 0.085 160);
|
|
272
|
+
--avatar-5-ink: oklch(0.9 0.05 160);
|
|
273
|
+
--avatar-6: oklch(0.4 0.079 75);
|
|
274
|
+
--avatar-6-ink: oklch(0.9 0.05 75);
|
|
275
|
+
--avatar-7: oklch(0.4 0.066 215);
|
|
276
|
+
--avatar-7-ink: oklch(0.9 0.05 215);
|
|
232
277
|
}
|
|
233
278
|
|
|
234
279
|
/* Identité H Collection. Dégradé de chaque icône produit sur fond clair, de la teinte vive à la
|
|
@@ -533,7 +578,7 @@
|
|
|
533
578
|
}
|
|
534
579
|
}
|
|
535
580
|
/* Chargeur de la famille (`FamilyLoader`, ADR 0015) : silhouette grise du H, pièces remplies de
|
|
536
|
-
leurs dégradés. Géométrie du H
|
|
581
|
+
leurs dégradés. Géométrie du H de FAMILY_MARK_SVG, sans le reflet. Une seule boucle de 2 s pour tout le H,
|
|
537
582
|
calée sur l'horloge (`syncFamilyLoader`) : le jambage gauche monte, la barre le prolonge vers la
|
|
538
583
|
droite, le jambage droit s'ouvre depuis la barre vers le haut et le bas ; le H complet tient, se
|
|
539
584
|
vide d'un fondu, la silhouette reste un instant, et ça recommence. Seuls `transform` et `opacity`
|
|
@@ -551,23 +596,21 @@
|
|
|
551
596
|
.dark .family-loader {
|
|
552
597
|
--fl-ghost-mix: 19%;
|
|
553
598
|
}
|
|
554
|
-
/*
|
|
555
|
-
|
|
556
|
-
|
|
599
|
+
/* Un seul H, plein, trois pièces, centré dans un cadre de 46 unités (le H de la marque,
|
|
600
|
+
`family/hcollection-icon.svg`, sans son reflet) : ni ombre, ni double, à aucune image de la
|
|
601
|
+
boucle (demande de Hugo, 2026-10-18). */
|
|
557
602
|
.family-loader {
|
|
558
603
|
--fl-u: calc(var(--fl) / 46);
|
|
559
604
|
--fl-l0: #2c78fd;
|
|
560
605
|
--fl-l1: #7343fe;
|
|
561
606
|
--fl-r0: #fe5292;
|
|
562
607
|
--fl-r1: #ff9a46;
|
|
563
|
-
--fl-reflet: 0.35;
|
|
564
608
|
}
|
|
565
609
|
.dark .family-loader {
|
|
566
610
|
--fl-l0: #6aa0ff;
|
|
567
611
|
--fl-l1: #9a7bff;
|
|
568
612
|
--fl-r0: #ff7aa8;
|
|
569
613
|
--fl-r1: #ffb070;
|
|
570
|
-
--fl-reflet: 0.5;
|
|
571
614
|
}
|
|
572
615
|
.family-loader [data-piece] {
|
|
573
616
|
position: absolute;
|
|
@@ -581,31 +624,24 @@
|
|
|
581
624
|
.family-loader [data-piece="left"],
|
|
582
625
|
.family-loader [data-piece="right"] {
|
|
583
626
|
z-index: 1;
|
|
584
|
-
top: calc(var(--fl-u) *
|
|
627
|
+
top: calc(var(--fl-u) * 5);
|
|
585
628
|
width: calc(var(--fl-u) * 10);
|
|
586
629
|
height: calc(var(--fl-u) * 36);
|
|
587
630
|
border-radius: calc(var(--fl-u) * 3);
|
|
588
631
|
}
|
|
589
632
|
.family-loader [data-piece="left"] {
|
|
590
|
-
left: calc(var(--fl-u) *
|
|
633
|
+
left: calc(var(--fl-u) * 8);
|
|
591
634
|
}
|
|
592
635
|
.family-loader [data-piece="right"] {
|
|
593
|
-
left: calc(var(--fl-u) *
|
|
636
|
+
left: calc(var(--fl-u) * 28);
|
|
594
637
|
}
|
|
595
638
|
/* Le pont déborde d'une demi-unité sous chaque montant, comme dans le dessin. */
|
|
596
639
|
.family-loader [data-piece="bar"] {
|
|
597
|
-
left: calc(var(--fl-u) *
|
|
598
|
-
top: calc(var(--fl-u) *
|
|
640
|
+
left: calc(var(--fl-u) * 17.5);
|
|
641
|
+
top: calc(var(--fl-u) * 18.5);
|
|
599
642
|
width: calc(var(--fl-u) * 11);
|
|
600
643
|
height: calc(var(--fl-u) * 9);
|
|
601
644
|
}
|
|
602
|
-
/* Le reflet : le même H, plein, décalé de (6, 6), en un seul calque translucide. */
|
|
603
|
-
.family-loader > [data-reflet] {
|
|
604
|
-
position: absolute;
|
|
605
|
-
inset: 0;
|
|
606
|
-
opacity: var(--fl-reflet);
|
|
607
|
-
transform: translate(calc(var(--fl-u) * 6), calc(var(--fl-u) * 6));
|
|
608
|
-
}
|
|
609
645
|
/* Couleurs du H : chaque montant son dégradé vertical ; le pont prend celui du montant droit,
|
|
610
646
|
recouvert de celui du gauche qui s'efface de gauche à droite (le masque du dessin). Le pont
|
|
611
647
|
occupe les unités 25 à 34 des 11,5 à 47,5 des montants : dégradé quatre fois plus haut, centré. */
|
|
@@ -627,10 +663,6 @@
|
|
|
627
663
|
background-position: 0 50%;
|
|
628
664
|
mask-image: linear-gradient(to right, #000 4.545%, transparent 95.455%);
|
|
629
665
|
}
|
|
630
|
-
.family-loader [data-reflet] [data-piece] > * {
|
|
631
|
-
position: absolute;
|
|
632
|
-
inset: 0;
|
|
633
|
-
}
|
|
634
666
|
.family-loader [data-fill] {
|
|
635
667
|
position: absolute;
|
|
636
668
|
inset: 0;
|
|
@@ -723,21 +755,12 @@
|
|
|
723
755
|
opacity: 1;
|
|
724
756
|
}
|
|
725
757
|
}
|
|
726
|
-
/* Mouvement réduit : le H
|
|
727
|
-
@keyframes family-loader-breathe {
|
|
728
|
-
0%,
|
|
729
|
-
100% {
|
|
730
|
-
opacity: 0.4;
|
|
731
|
-
}
|
|
732
|
-
50% {
|
|
733
|
-
opacity: 1;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
758
|
+
/* Mouvement réduit : le H plein, immobile. Le chargement se lit au libellé (`role="status"`). */
|
|
736
759
|
@media (prefers-reduced-motion: reduce) {
|
|
737
760
|
.family-loader [data-piece] [data-fill] {
|
|
738
761
|
transform: none;
|
|
739
|
-
|
|
740
|
-
animation-
|
|
762
|
+
opacity: 1;
|
|
763
|
+
animation-name: none;
|
|
741
764
|
}
|
|
742
765
|
}
|
|
743
766
|
|