@hgdc/ui 0.26.0 → 0.28.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 +21 -14
- package/dist/account-menu.d.ts +7 -1
- package/dist/account-menu.js +6 -3
- package/dist/app-shell.d.ts +1 -1
- package/dist/app-shell.js +3 -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/project-switcher.d.ts +48 -0
- package/dist/project-switcher.js +48 -0
- package/dist/shell-placement.d.ts +7 -0
- package/dist/shell-placement.js +9 -0
- package/dist/uptime-bar.d.ts +25 -2
- package/dist/uptime-bar.js +35 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,27 +31,32 @@ 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
|
+
|
|
38
41
|
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
42
|
|
|
40
43
|
## Coque des consoles
|
|
41
44
|
|
|
42
|
-
| Import
|
|
43
|
-
|
|
|
44
|
-
| `app-shell`
|
|
45
|
-
|
|
|
46
|
-
| `org-switcher`
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
45
|
+
| Import | Exemple |
|
|
46
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
47
|
+
| `app-shell` | `<AppShell product="mail" organization={<OrgSwitcher …/>} nav={<SidebarNav label="Projet">…</SidebarNav>} sidebarFooter={<AccountMenu …/>} onSearch={() => setOpen(true)}>` |
|
|
48
|
+
| | `<SidebarNavItem icon={Rocket01Icon} label="Déploiements" render={<Link to="/deployments" />} />` |
|
|
49
|
+
| `org-switcher` | `<OrgSwitcher organizations={orgs} activeId={org.id} onSelect={go} createHref="/organisations/nouvelle" />` |
|
|
50
|
+
| `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 » |
|
|
51
|
+
| `account-menu` | `<AccountMenu user={user} onProfile={…} onSecurity={…} onSignOut={signOut} product="Mail" />` : Account et les produits ouverts du catalogue, Mail coché, thème en trois icônes (`ThemeSwitch`), entrée « État du service » vers la page de statut du produit (`statusPage` d'`AppShell`, `false` pour la retirer) |
|
|
52
|
+
| `command-menu` | `<CommandMenu groups={[{ label: "Aller à", items }, { label: "Créer", items }, { label: "Changer", items }]} />` (`Ctrl+K`), `<CommandMenuButton compact="mobile" …/>` |
|
|
53
|
+
| `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) |
|
|
54
|
+
| `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 |
|
|
55
|
+
| `kbd` | `<KbdGroup><Kbd>Ctrl</Kbd><Kbd>K</Kbd></KbdGroup>` |
|
|
56
|
+
| `page-header` | `<PageHeader title="Domaines" breadcrumbs={[{ label: "Acme", render: <Link to="/o/acme" /> }, { label: "Domaines" }]} actions={<Button>Ajouter</Button>} />` |
|
|
57
|
+
| `page-header` | `<Page title="Réglages">…</Page>` (colonne de lecture de 768 px), `<Page title="Projets" wide actions={…}>…</Page>` (tableaux, listes, tableaux de bord) |
|
|
58
|
+
| `member-item` | `<ResourceList><MemberItem name={m.name} email={m.email} orgRole={m.role} isMe={m.isMe} /></ResourceList>` : avatar à initiales, « (toi) », `RoleBadge` |
|
|
59
|
+
| `initials` | `initials("Test vérif")` donne « TV » : la même règle pour une personne et une organisation (`OrgMark`, `AccountMenu`, `MemberItem`) |
|
|
55
60
|
|
|
56
61
|
Le menu « Produits » et la palette (`badge` d'une commande) montrent la pastille de chaque produit.
|
|
57
62
|
|
|
@@ -94,7 +99,7 @@ Sur ordinateur, la coque ouvre sur la barre des produits (ADR 0011) : organisati
|
|
|
94
99
|
| `stat` | `<StatGrid><StatTile label="Erreurs" value="12" delta={{ value: "8 %", direction: "up", good: "down" }} /></StatGrid>` |
|
|
95
100
|
| `time-chart` | `<TimeChart label="Envois par jour" timestamps={days} series={[{ key: "sent", label: "Envoyés", values }]} kind="bar" lastIncomplete />` (dernier point en pointillés : la journée n'est pas finie) |
|
|
96
101
|
| `status` | `<StatusBadge status="degraded" />` (Opérationnel, Dégradé, Panne, Maintenance, Inconnu) ; hors services, `<StatusBadge tone="success" label="Remis" />` (`neutral`, `success`, `warning`, `danger`, `info`, `pending`) |
|
|
97
|
-
| `uptime-bar` | `<UptimeBar days={[{ date, status: "operational", uptime: "100 %" }]} summary="99,95 % sur 90 jours" />`
|
|
102
|
+
| `uptime-bar` | `<UptimeBar days={[{ date, status: "operational", uptime: "100 %", detail: { levels, incidents } }]} summary="99,95 % sur 90 jours" />` |
|
|
98
103
|
| `timeline` | `<Timeline entries={[{ id, title: "Résolu", time, status: "operational" }]} />` |
|
|
99
104
|
| `relative-time` | `<RelativeTime date={deployment._creationTime} />` (« il y a 3 min », date exacte au survol) |
|
|
100
105
|
| `copy-button` | `<Snippet value="bun add @hgdc/ui" prompt />`, `<CopyButton value={url} />`, `<CopyButton value={all} label="Tout copier" showLabel />` |
|
|
@@ -112,6 +117,8 @@ Sur ordinateur, la coque ouvre sur la barre des produits (ADR 0011) : organisati
|
|
|
112
117
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
113
118
|
| `filter-bar` | `<FilterBar search={{ value: q, onChange }} filters={[{ key: "status", label: "Statut", options }]} values={{ status }} onFilterChange={…} onReset={…} />` |
|
|
114
119
|
| `data-table` | `<DataTable label="Déploiements" rows={rows} columns={columns} rowKey={(d) => d.id} sort={sort} onSortChange={…} filters={<FilterBar …/>} loadMore={…} />` |
|
|
120
|
+
| `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 |
|
|
121
|
+
| `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
122
|
| `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
123
|
| `dns-record-table` | `<DnsRecordTable records={[{ type: "MX", name: "send", value, priority: 10, status: "pending" }]} />` (Vérifié, En attente, Échec, À ajouter) |
|
|
117
124
|
| `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
|
@@ -30,7 +30,7 @@ export type AccountUser = {
|
|
|
30
30
|
* (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
|
|
31
31
|
* liste ; `products={[]}` la retire.
|
|
32
32
|
*/
|
|
33
|
-
export declare function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant, }: {
|
|
33
|
+
export declare function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, statusPage, children, variant, }: {
|
|
34
34
|
user: AccountUser;
|
|
35
35
|
onProfile?: () => void;
|
|
36
36
|
onSecurity?: () => void;
|
|
@@ -43,6 +43,12 @@ export declare function AccountMenu({ user, onProfile, onSecurity, onShortcuts,
|
|
|
43
43
|
product?: string;
|
|
44
44
|
/** Adresse d'Account dans la liste par défaut (un Account local en développement). */
|
|
45
45
|
accountHref?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Page de statut du produit, entrée « État du service » : celle d'`AppShell` par défaut (son
|
|
48
|
+
* `statusPage`, sinon `https://status.hgdc.ch/<produit>`), `false` pour la retirer. Hors de la
|
|
49
|
+
* coque, sans adresse donnée, pas d'entrée.
|
|
50
|
+
*/
|
|
51
|
+
statusPage?: string | false;
|
|
46
52
|
/** Entrées propres au produit (« Administration »), après le profil. */
|
|
47
53
|
children?: ReactNode;
|
|
48
54
|
variant?: "row" | "avatar";
|
package/dist/account-menu.js
CHANGED
|
@@ -1,4 +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";
|
|
2
3
|
import DashboardSquare01Icon from "@hugeicons/core-free-icons/DashboardSquare01Icon";
|
|
3
4
|
import KeyboardIcon from "@hugeicons/core-free-icons/KeyboardIcon";
|
|
4
5
|
import Logout01Icon from "@hugeicons/core-free-icons/Logout01Icon";
|
|
@@ -13,7 +14,7 @@ import { initials } from "./initials.js";
|
|
|
13
14
|
import { MENU_LINK_ITEM } from "./org-switcher.js";
|
|
14
15
|
import { ProductBadge } from "./product-badge.js";
|
|
15
16
|
import { productMenuEntries } from "./products.js";
|
|
16
|
-
import { useLeaveOnClick, useProductRailShown } from "./shell-placement.js";
|
|
17
|
+
import { useLeaveOnClick, useProductRailShown, useStatusPage } from "./shell-placement.js";
|
|
17
18
|
import { ThemeSwitch } from "./theme-switch.js";
|
|
18
19
|
/**
|
|
19
20
|
* Le menu du compte, en bas de la barre latérale : profil, sécurité, choix du thème en trois icônes, raccourcis, autres
|
|
@@ -26,11 +27,13 @@ import { ThemeSwitch } from "./theme-switch.js";
|
|
|
26
27
|
* (`productMenuEntries`, `@hgdc/ui/products`), avec `product` coché. `products` remplace cette
|
|
27
28
|
* liste ; `products={[]}` la retire.
|
|
28
29
|
*/
|
|
29
|
-
export function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, children, variant = "row", }) {
|
|
30
|
+
export function AccountMenu({ user, onProfile, onSecurity, onShortcuts, onSignOut, products: productsProp, product: currentProduct, accountHref, statusPage, children, variant = "row", }) {
|
|
30
31
|
const products = productsProp ?? productMenuEntries({ current: currentProduct, accountHref });
|
|
31
32
|
const railShown = useProductRailShown();
|
|
33
|
+
const shellStatusPage = useStatusPage();
|
|
34
|
+
const statusPageUrl = statusPage === false ? null : (statusPage ?? shellStatusPage);
|
|
32
35
|
const leaveOnClick = useLeaveOnClick();
|
|
33
|
-
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: [_jsxs(Avatar, { children: [user.image ? _jsx(AvatarImage, { src: user.image, alt: "" }) : null, _jsx(AvatarFallback, { className: "text-xs font-semibold uppercase", children: initials(user.name, user.email) })] }), 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-64", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { children: [_jsx("span", { className: "block truncate 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, children, _jsx(ThemeSwitch, {}), 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, { 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"] })] })] }));
|
|
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: [_jsxs(Avatar, { children: [user.image ? _jsx(AvatarImage, { src: user.image, alt: "" }) : null, _jsx(AvatarFallback, { className: "text-xs font-semibold uppercase", children: initials(user.name, user.email) })] }), 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-64", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { children: [_jsx("span", { className: "block truncate 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, children, _jsx(ThemeSwitch, {}), onShortcuts ? (_jsxs(DropdownMenuItem, { onClick: onShortcuts, children: [_jsx(HugeiconsIcon, { icon: KeyboardIcon, "aria-hidden": true }), "Raccourcis clavier", _jsx(DropdownMenuShortcut, { children: "?" })] })) : null, statusPageUrl ? (_jsxs(MenuPrimitive.LinkItem, { href: statusPageUrl, className: MENU_LINK_ITEM, children: [_jsx(HugeiconsIcon, { icon: Activity01Icon, "aria-hidden": true }), "\u00C9tat du service"] })) : 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"] })] })] }));
|
|
34
37
|
}
|
|
35
38
|
function ProductMenuIcon({ product }) {
|
|
36
39
|
if (product.badge)
|
package/dist/app-shell.d.ts
CHANGED
|
@@ -84,7 +84,7 @@ export declare function AppShell({ product, organization, productRail, accountHr
|
|
|
84
84
|
/**
|
|
85
85
|
* Page de statut du produit, dont le bandeau d'état se pose en tête du contenu (ADR 0018) :
|
|
86
86
|
* `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.
|
|
87
|
+
* de test), ou `false` pour ne rien lire. Le menu du compte en tire son entrée « État du service ».
|
|
88
88
|
*/
|
|
89
89
|
statusPage?: string | false | undefined;
|
|
90
90
|
}): React.JSX.Element;
|
package/dist/app-shell.js
CHANGED
|
@@ -14,7 +14,7 @@ import { ProductBadge } from "./product-badge.js";
|
|
|
14
14
|
import { ProductRail } from "./product-rail.js";
|
|
15
15
|
import { useProductSwitch } from "./product-switch.js";
|
|
16
16
|
import { HGDC_ACCOUNT, HGDC_PRODUCTS } from "./products.js";
|
|
17
|
-
import { ProductRailShownContext, ShellLeaveContext } from "./shell-placement.js";
|
|
17
|
+
import { ProductRailShownContext, ShellLeaveContext, StatusPageContext } from "./shell-placement.js";
|
|
18
18
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
|
|
19
19
|
import { cn } from "./cn.js";
|
|
20
20
|
import { ServiceStatus, statusPageUrlFor } from "./service-status.js";
|
|
@@ -201,6 +201,7 @@ export function AppShell({ product, organization, productRail = true, accountHre
|
|
|
201
201
|
window.addEventListener("pageshow", onPageShow);
|
|
202
202
|
return () => window.removeEventListener("pageshow", onPageShow);
|
|
203
203
|
}, []);
|
|
204
|
+
const statusPageUrl = statusPage === false ? null : (statusPage ?? statusPageUrlFor(product));
|
|
204
205
|
const loading = useContentLoading({ navigating, pending: pagePending > 0 || leaving });
|
|
205
206
|
// Avant l'hydratation, le CSS montre le calque et le H dès qu'une page attend (`:has`) ; ensuite
|
|
206
207
|
// `data-loading` prend le relais dans la même image.
|
|
@@ -212,5 +213,5 @@ export function AppShell({ product, organization, productRail = true, accountHre
|
|
|
212
213
|
if (loading && contentRef.current)
|
|
213
214
|
contentRef.current.scrollTop = 0;
|
|
214
215
|
}, [loading]);
|
|
215
|
-
return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, 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, 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, onExpandedChange: setRailPeek })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", 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, 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", { 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] }), _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:
|
|
216
|
+
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, 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, onExpandedChange: setRailPeek })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", 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, 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", { 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] }), _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)) })] })] }) }), 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, 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] }) })] }) }) }) }) }));
|
|
216
217
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -19,3 +19,10 @@ export declare function useProductRailShown(): boolean;
|
|
|
19
19
|
export declare const ShellLeaveContext: React.Context<() => void>;
|
|
20
20
|
/** `onClick` d'un lien vers un autre produit : clic simple seulement (pas Ctrl, Cmd, Maj, molette). */
|
|
21
21
|
export declare function useLeaveOnClick(): (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Page de statut du produit affiché, posée par `AppShell` (son `statusPage`, sinon
|
|
24
|
+
* `https://status.hgdc.ch/<produit>`) : le menu du compte en tire son entrée « État du service ».
|
|
25
|
+
* `null` hors de la coque ou quand la coque ne lit aucune page.
|
|
26
|
+
*/
|
|
27
|
+
export declare const StatusPageContext: React.Context<string | null>;
|
|
28
|
+
export declare function useStatusPage(): string | null;
|
package/dist/shell-placement.js
CHANGED
|
@@ -31,3 +31,12 @@ export function useLeaveOnClick() {
|
|
|
31
31
|
leave();
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Page de statut du produit affiché, posée par `AppShell` (son `statusPage`, sinon
|
|
36
|
+
* `https://status.hgdc.ch/<produit>`) : le menu du compte en tire son entrée « État du service ».
|
|
37
|
+
* `null` hors de la coque ou quand la coque ne lit aucune page.
|
|
38
|
+
*/
|
|
39
|
+
export const StatusPageContext = createContext(null);
|
|
40
|
+
export function useStatusPage() {
|
|
41
|
+
return useContext(StatusPageContext);
|
|
42
|
+
}
|
package/dist/uptime-bar.d.ts
CHANGED
|
@@ -6,15 +6,38 @@ export type UptimeDay = {
|
|
|
6
6
|
status: ServiceStatus;
|
|
7
7
|
/** Disponibilité du jour, déjà formatée : « 99,98 % ». */
|
|
8
8
|
uptime?: string;
|
|
9
|
-
/** Ce qui s'est passé : « Panne de 12 min, base de données ». */
|
|
9
|
+
/** Ce qui s'est passé : « Panne de 12 min, base de données ». Ignoré quand `detail` est là. */
|
|
10
10
|
note?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Détail du jour, comme chez Statuspage : durée par niveau et incidents en liens. Le jour ouvre
|
|
13
|
+
* alors une bulle (survol, toucher, Entrée) dont les liens se suivent au clavier, au lieu de
|
|
14
|
+
* l'infobulle en texte.
|
|
15
|
+
*/
|
|
16
|
+
detail?: UptimeDayDetail;
|
|
17
|
+
};
|
|
18
|
+
export type UptimeDayDetail = {
|
|
19
|
+
/** Temps passé à chaque niveau, du plus grave au moins grave : « Panne majeure », « 12 min ». */
|
|
20
|
+
levels: Array<{
|
|
21
|
+
status: ServiceStatus;
|
|
22
|
+
label: string;
|
|
23
|
+
duration: string;
|
|
24
|
+
}>;
|
|
25
|
+
/** Incidents du jour, vers leur page. */
|
|
26
|
+
incidents: Array<{
|
|
27
|
+
title: string;
|
|
28
|
+
href: string;
|
|
29
|
+
}>;
|
|
30
|
+
/** Phrase quand `levels` est vide : « Aucune panne ce jour-là » par défaut. */
|
|
31
|
+
empty?: string;
|
|
11
32
|
};
|
|
12
33
|
/** Le jour à atteindre pour une touche, ou `null` si la touche ne déplace pas. */
|
|
13
34
|
export declare function nextDay(key: string, current: number, count: number): number | null;
|
|
14
35
|
/**
|
|
15
36
|
* Une barre par jour, du plus ancien au plus récent, comme les pages de statut de référence.
|
|
16
37
|
* Chaque barre a sa couleur et, au survol, une infobulle en texte (jour, statut, disponibilité,
|
|
17
|
-
* note).
|
|
38
|
+
* note). Un jour qui porte un `detail` ouvre à la place une bulle (survol, toucher, Entrée) : durée
|
|
39
|
+
* par niveau et incidents du jour en liens. Les lecteurs d'écran lisent la liste des jours, dont
|
|
40
|
+
* chacun dit son statut en mots.
|
|
18
41
|
*
|
|
19
42
|
* Au clavier, la barre est un seul arrêt de tabulation (le dernier jour visité, aujourd'hui au
|
|
20
43
|
* départ) ; les flèches, Début et Fin passent d'un jour à l'autre et ouvrent son infobulle.
|
package/dist/uptime-bar.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useRef, useState } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "./popover.js";
|
|
4
|
+
import { SERVICE_STATUS_LABEL, StatusDot } from "./status.js";
|
|
4
5
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
|
|
5
6
|
import { cn } from "./cn.js";
|
|
7
|
+
const NO_DOWNTIME = "Aucune panne ce jour-là";
|
|
6
8
|
const BAR = {
|
|
7
9
|
operational: "bg-status-operational",
|
|
8
10
|
degraded: "bg-status-degraded",
|
|
@@ -10,11 +12,20 @@ const BAR = {
|
|
|
10
12
|
maintenance: "bg-status-maintenance",
|
|
11
13
|
unknown: "bg-foreground/10",
|
|
12
14
|
};
|
|
15
|
+
const DAY = "block w-full rounded-[3px] outline-none transition-opacity duration-(--duration-fast) ease-out hover:opacity-70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-popup-open:opacity-70";
|
|
13
16
|
const day = new Intl.DateTimeFormat("fr-CH", { weekday: "short", day: "numeric", month: "short" });
|
|
17
|
+
const longDay = new Intl.DateTimeFormat("fr-CH", { weekday: "long", day: "numeric", month: "long", year: "numeric" });
|
|
14
18
|
function describe(entry) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
const detail = entry.detail;
|
|
20
|
+
const note = detail
|
|
21
|
+
? [
|
|
22
|
+
...detail.levels.map((level) => `${level.label} ${level.duration}`),
|
|
23
|
+
...detail.incidents.map((incident) => incident.title),
|
|
24
|
+
].join(" · ") ||
|
|
25
|
+
detail.empty ||
|
|
26
|
+
NO_DOWNTIME
|
|
27
|
+
: entry.note;
|
|
28
|
+
return [day.format(entry.date), SERVICE_STATUS_LABEL[entry.status], entry.uptime, note].filter(Boolean).join(" · ");
|
|
18
29
|
}
|
|
19
30
|
/** Le jour à atteindre pour une touche, ou `null` si la touche ne déplace pas. */
|
|
20
31
|
export function nextDay(key, current, count) {
|
|
@@ -33,10 +44,16 @@ export function nextDay(key, current, count) {
|
|
|
33
44
|
return null;
|
|
34
45
|
}
|
|
35
46
|
}
|
|
47
|
+
/** Bulle d'un jour : date, disponibilité, durée par niveau (point et mots), incidents en liens. */
|
|
48
|
+
function DayDetail({ entry, detail }) {
|
|
49
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx(PopoverTitle, { className: "first-letter:uppercase", children: longDay.format(entry.date) }), entry.uptime ? _jsx("p", { className: "text-xs text-muted-foreground tabular-nums", children: entry.uptime }) : null] }), detail.levels.length ? (_jsx("ul", { className: "grid gap-1", children: detail.levels.map((level) => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx(StatusDot, { status: level.status }), _jsx("span", { className: "min-w-0 flex-1", children: level.label }), _jsx("span", { className: "shrink-0 text-muted-foreground tabular-nums", children: level.duration })] }, level.label))) })) : (_jsx("p", { className: "text-muted-foreground", children: detail.empty ?? NO_DOWNTIME })), detail.incidents.length ? (_jsx("ul", { "aria-label": "Incidents du jour", className: "grid gap-1.5 border-t border-border pt-2", children: detail.incidents.map((incident) => (_jsx("li", { children: _jsx("a", { href: incident.href, className: "rounded-sm font-medium text-pretty underline decoration-muted-foreground/50 underline-offset-4 outline-none hover:decoration-foreground focus-visible:ring-2 focus-visible:ring-ring", children: incident.title }) }, incident.href))) })) : null] }));
|
|
50
|
+
}
|
|
36
51
|
/**
|
|
37
52
|
* Une barre par jour, du plus ancien au plus récent, comme les pages de statut de référence.
|
|
38
53
|
* Chaque barre a sa couleur et, au survol, une infobulle en texte (jour, statut, disponibilité,
|
|
39
|
-
* note).
|
|
54
|
+
* note). Un jour qui porte un `detail` ouvre à la place une bulle (survol, toucher, Entrée) : durée
|
|
55
|
+
* par niveau et incidents du jour en liens. Les lecteurs d'écran lisent la liste des jours, dont
|
|
56
|
+
* chacun dit son statut en mots.
|
|
40
57
|
*
|
|
41
58
|
* Au clavier, la barre est un seul arrêt de tabulation (le dernier jour visité, aujourd'hui au
|
|
42
59
|
* départ) ; les flèches, Début et Fin passent d'un jour à l'autre et ouvrent son infobulle.
|
|
@@ -53,5 +70,16 @@ export function UptimeBar({ days, label = "Disponibilité", summary, className,
|
|
|
53
70
|
setFocused(target);
|
|
54
71
|
list.current?.querySelectorAll("[data-slot=uptime-day]")[target]?.focus();
|
|
55
72
|
}
|
|
56
|
-
return (_jsxs("div", { "data-slot": "uptime-bar", className: cn("grid gap-2", className), children: [_jsx("ol", { ref: list, "aria-label": label, className: "flex h-8 items-stretch gap-[2px]", children: days.map((entry, index) =>
|
|
73
|
+
return (_jsxs("div", { "data-slot": "uptime-bar", className: cn("grid gap-2", className), children: [_jsx("ol", { ref: list, "aria-label": label, className: "flex h-8 items-stretch gap-[2px]", children: days.map((entry, index) => {
|
|
74
|
+
const trigger = {
|
|
75
|
+
render: _jsx("button", { type: "button" }),
|
|
76
|
+
"data-slot": "uptime-day",
|
|
77
|
+
tabIndex: index === current ? 0 : -1,
|
|
78
|
+
onFocus: () => setFocused(index),
|
|
79
|
+
onKeyDown,
|
|
80
|
+
className: cn(DAY, BAR[entry.status]),
|
|
81
|
+
children: _jsx("span", { className: "sr-only", children: describe(entry) }),
|
|
82
|
+
};
|
|
83
|
+
return (_jsx("li", { className: "flex min-w-0 flex-1", children: entry.detail ? (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { openOnHover: true, ...trigger }), _jsx(PopoverContent, { side: "top", className: "w-64 gap-3", "data-slot": "uptime-day-detail", children: _jsx(DayDetail, { entry: entry, detail: entry.detail }) })] })) : (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { ...trigger }), _jsx(TooltipContent, { children: describe(entry) })] })) }, new Date(entry.date).toISOString()));
|
|
84
|
+
}) }), _jsxs("div", { "aria-hidden": true, "data-slot": "uptime-legend", className: "flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums", children: [_jsx("span", { className: "shrink-0", children: days.length > 0 ? `il y a ${days.length} jours` : null }), summary ? (_jsx("span", { className: "min-w-0 text-center text-pretty break-words text-foreground", children: summary })) : null, _jsx("span", { className: "shrink-0", children: "Aujourd'hui" })] })] }));
|
|
57
85
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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",
|