@hgdc/ui 0.47.0 → 0.47.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-shell.d.ts +18 -123
- package/dist/app-shell.js +18 -431
- package/dist/assistant-bar.d.ts +16 -0
- package/dist/assistant-bar.js +25 -0
- package/dist/assistant-labels.d.ts +29 -0
- package/dist/assistant-labels.js +42 -0
- package/dist/assistant-parts.d.ts +19 -0
- package/dist/assistant-parts.js +38 -0
- package/dist/assistant-text.d.ts +15 -0
- package/dist/assistant-text.js +30 -0
- package/dist/assistant-types.d.ts +59 -0
- package/dist/assistant-types.js +1 -0
- package/dist/assistant-view.d.ts +7 -120
- package/dist/assistant-view.js +9 -127
- package/dist/shell-assistant.d.ts +37 -0
- package/dist/shell-assistant.js +83 -0
- package/dist/shell-context.d.ts +11 -0
- package/dist/shell-context.js +8 -0
- package/dist/shell-links.d.ts +35 -0
- package/dist/shell-links.js +46 -0
- package/dist/sidebar-head.d.ts +10 -0
- package/dist/sidebar-head.js +28 -0
- package/dist/sidebar-nav-group.d.ts +33 -0
- package/dist/sidebar-nav-group.js +105 -0
- package/dist/sidebar-nav.d.ts +38 -0
- package/dist/sidebar-nav.js +60 -0
- package/dist/sidebar-rail.d.ts +12 -0
- package/dist/sidebar-rail.js +53 -0
- package/dist/use-content-loading.d.ts +13 -0
- package/dist/use-content-loading.js +42 -0
- package/dist/use-product-leave.d.ts +9 -0
- package/dist/use-product-leave.js +32 -0
- package/package.json +1 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import AiMagicIcon from "@hugeicons/core-free-icons/AiMagicIcon";
|
|
3
|
+
import ArrowLeft01Icon from "@hugeicons/core-free-icons/ArrowLeft01Icon";
|
|
4
|
+
import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
|
|
5
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
6
|
+
import { useEffect, useId, useRef } from "react";
|
|
7
|
+
import { cn } from "./cn.js";
|
|
8
|
+
import { isApplePlatform, Kbd, KbdGroup } from "./kbd.js";
|
|
9
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
|
|
10
|
+
/** Largeur à partir de laquelle la page et l'assistant tiennent côte à côte (`xl`). */
|
|
11
|
+
export const ASSISTANT_SIDE_BY_SIDE = "(min-width: 80rem)";
|
|
12
|
+
const FOCUSABLE = 'textarea, input, [contenteditable="true"], [autofocus]';
|
|
13
|
+
export function AssistantButton({ assistant, panelId, buttonRef, }) {
|
|
14
|
+
const label = assistant.label ?? "Assistant";
|
|
15
|
+
const letter = assistant.shortcut?.toUpperCase();
|
|
16
|
+
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] }) })] }));
|
|
17
|
+
}
|
|
18
|
+
/** `Ctrl` (`⌘`) + la lettre de l'assistant : l'ouvre ou le ferme, où que soit le focus. */
|
|
19
|
+
export function useAssistantShortcut(assistant) {
|
|
20
|
+
const latest = useRef(assistant);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
latest.current = assistant;
|
|
23
|
+
});
|
|
24
|
+
const letter = assistant?.shortcut?.toLowerCase();
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!letter)
|
|
27
|
+
return undefined;
|
|
28
|
+
const onKey = (event) => {
|
|
29
|
+
if (event.key.toLowerCase() !== letter || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey)
|
|
30
|
+
return;
|
|
31
|
+
const current = latest.current;
|
|
32
|
+
if (!current)
|
|
33
|
+
return;
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
current.onOpenChange(!current.open);
|
|
36
|
+
};
|
|
37
|
+
document.addEventListener("keydown", onKey);
|
|
38
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
39
|
+
}, [letter]);
|
|
40
|
+
}
|
|
41
|
+
export function AssistantPanel({ assistant, panelId, wide, openAtShellMount, buttonRef, }) {
|
|
42
|
+
const { open, onOpenChange } = assistant;
|
|
43
|
+
const label = assistant.label ?? "Assistant";
|
|
44
|
+
const titleId = useId();
|
|
45
|
+
const panelRef = useRef(null);
|
|
46
|
+
// Ouvert dès le premier rendu de la coque (état restauré) : le focus reste où le navigateur le met.
|
|
47
|
+
const wasOpen = useRef(openAtShellMount);
|
|
48
|
+
// Ouvert : le focus entre dans le panneau (sa saisie, sinon le panneau). Fermé : il revient au
|
|
49
|
+
// bouton, s'il était dans le panneau ou perdu sur la page.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const panel = panelRef.current;
|
|
52
|
+
if (open && !wasOpen.current && panel && !panel.contains(document.activeElement)) {
|
|
53
|
+
const target = panel.querySelector(FOCUSABLE) ?? panel;
|
|
54
|
+
target.focus({ preventScroll: true });
|
|
55
|
+
}
|
|
56
|
+
if (!open && wasOpen.current) {
|
|
57
|
+
const active = document.activeElement;
|
|
58
|
+
if (!active || active === document.body || panel?.contains(active))
|
|
59
|
+
buttonRef.current?.focus();
|
|
60
|
+
}
|
|
61
|
+
wasOpen.current = open;
|
|
62
|
+
}, [open, buttonRef]);
|
|
63
|
+
// Échap, le focus dans le panneau : il se ferme (sauf si un élément du panneau l'a déjà traité).
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const panel = panelRef.current;
|
|
66
|
+
if (!open || !panel)
|
|
67
|
+
return undefined;
|
|
68
|
+
const onKey = (event) => {
|
|
69
|
+
if (event.key !== "Escape" || event.defaultPrevented)
|
|
70
|
+
return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
onOpenChange(false);
|
|
73
|
+
};
|
|
74
|
+
panel.addEventListener("keydown", onKey);
|
|
75
|
+
return () => panel.removeEventListener("keydown", onKey);
|
|
76
|
+
}, [open, onOpenChange]);
|
|
77
|
+
const close = () => onOpenChange(false);
|
|
78
|
+
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",
|
|
79
|
+
// Vue dédiée, sous 1280 px : plein écran, la page reste montée dessous.
|
|
80
|
+
"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",
|
|
81
|
+
// Côte à côte : une colonne de la grille, qui s'ouvre en largeur ; la page se réorganise.
|
|
82
|
+
"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 })] }) }));
|
|
83
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Context } from "react";
|
|
2
|
+
import type { FamilyProduct } from "./family-svg.ts";
|
|
3
|
+
export type ShellContextValue = {
|
|
4
|
+
closeMobileNav: () => void;
|
|
5
|
+
product?: FamilyProduct;
|
|
6
|
+
};
|
|
7
|
+
export declare const ShellContext: Context<ShellContextValue>;
|
|
8
|
+
/** Pour fermer le tiroir mobile depuis un élément de la barre (un sélecteur, un bouton). */
|
|
9
|
+
export declare function useAppShell(): {
|
|
10
|
+
closeMobileNav: () => void;
|
|
11
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
export const ShellContext = createContext({
|
|
3
|
+
closeMobileNav: () => { },
|
|
4
|
+
});
|
|
5
|
+
/** Pour fermer le tiroir mobile depuis un élément de la barre (un sélecteur, un bouton). */
|
|
6
|
+
export function useAppShell() {
|
|
7
|
+
return useContext(ShellContext);
|
|
8
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type ProductView } from "./addresses.ts";
|
|
3
|
+
import type { FamilyProduct } from "./family-svg.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Un accès commun de la barre : une adresse, ou un lien du routeur (`{ render: <Link to="/settings" /> }`,
|
|
6
|
+
* dont le `data-status="active"` marque la page ouverte), ou une adresse avec `active`.
|
|
7
|
+
*/
|
|
8
|
+
export type ShellLink = string | {
|
|
9
|
+
href?: string;
|
|
10
|
+
render?: React.ReactElement;
|
|
11
|
+
active?: boolean;
|
|
12
|
+
};
|
|
13
|
+
/** Les trois accès que la coque pose juste au-dessus du compte, toujours dans cet ordre. */
|
|
14
|
+
export type AppShellLinks = {
|
|
15
|
+
/** Documentation du produit. */
|
|
16
|
+
documentation?: ShellLink;
|
|
17
|
+
/** Réglages du produit ou de l'organisation. */
|
|
18
|
+
settings?: ShellLink;
|
|
19
|
+
/** État du service : la page de statut de la coque (`statusPage`) par défaut, `false` pour le retirer. */
|
|
20
|
+
status?: ShellLink | false;
|
|
21
|
+
};
|
|
22
|
+
export declare function ShellCommonLinks({ links, statusPageUrl, otherView, }: {
|
|
23
|
+
links: AppShellLinks;
|
|
24
|
+
statusPageUrl: string | null;
|
|
25
|
+
/** Lien vers l'autre vue du produit (ADR 0014), en dernier ; il ne suffit pas à montrer la liste. */
|
|
26
|
+
otherView?: ReactNode;
|
|
27
|
+
}): React.JSX.Element | null;
|
|
28
|
+
/**
|
|
29
|
+
* « Ouvrir seul » (vers `<mot>.hgdc.ch`) ou « Ouvrir dans app.hgdc.ch », discret, sous les accès communs.
|
|
30
|
+
* Le chemin courant n'est lu qu'au clic : le lien rendu par le serveur mène à l'accueil.
|
|
31
|
+
*/
|
|
32
|
+
export declare function OtherViewLink({ product, view, }: {
|
|
33
|
+
product: FamilyProduct;
|
|
34
|
+
view: ProductView;
|
|
35
|
+
}): React.JSX.Element | null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Activity01Icon from "@hugeicons/core-free-icons/Activity01Icon";
|
|
3
|
+
import BookOpen01Icon from "@hugeicons/core-free-icons/BookOpen01Icon";
|
|
4
|
+
import LinkSquare02Icon from "@hugeicons/core-free-icons/LinkSquare02Icon";
|
|
5
|
+
import Settings01Icon from "@hugeicons/core-free-icons/Settings01Icon";
|
|
6
|
+
import { soloHref } from "./addresses.js";
|
|
7
|
+
import { otherViewHref, otherViewLabel } from "./products.js";
|
|
8
|
+
import { SidebarNavItem } from "./sidebar-nav.js";
|
|
9
|
+
const COMMON_LINKS = [
|
|
10
|
+
{ key: "documentation", label: "Documentation", icon: BookOpen01Icon },
|
|
11
|
+
{ key: "settings", label: "Réglages", icon: Settings01Icon },
|
|
12
|
+
{ key: "status", label: "État du service", icon: Activity01Icon },
|
|
13
|
+
];
|
|
14
|
+
export function ShellCommonLinks({ links, statusPageUrl, otherView, }) {
|
|
15
|
+
const resolved = {
|
|
16
|
+
documentation: links.documentation,
|
|
17
|
+
settings: links.settings,
|
|
18
|
+
status: links.status === false ? undefined : (links.status ?? statusPageUrl ?? undefined),
|
|
19
|
+
};
|
|
20
|
+
const items = COMMON_LINKS.flatMap(({ key, label, icon }) => {
|
|
21
|
+
const link = resolved[key];
|
|
22
|
+
if (link === undefined)
|
|
23
|
+
return [];
|
|
24
|
+
const props = typeof link === "string" ? { href: link } : link;
|
|
25
|
+
return [
|
|
26
|
+
_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),
|
|
27
|
+
];
|
|
28
|
+
});
|
|
29
|
+
if (!items.length)
|
|
30
|
+
return null;
|
|
31
|
+
return (_jsx("nav", { "aria-label": "Acc\u00E8s communs", children: _jsxs("ul", { "data-slot": "app-shell-links", className: "grid gap-0.5", children: [items, otherView] }) }));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* « Ouvrir seul » (vers `<mot>.hgdc.ch`) ou « Ouvrir dans app.hgdc.ch », discret, sous les accès communs.
|
|
35
|
+
* Le chemin courant n'est lu qu'au clic : le lien rendu par le serveur mène à l'accueil.
|
|
36
|
+
*/
|
|
37
|
+
export function OtherViewLink({ product, view, }) {
|
|
38
|
+
const href = otherViewHref(product, view);
|
|
39
|
+
if (!href)
|
|
40
|
+
return null;
|
|
41
|
+
const onClick = (event) => {
|
|
42
|
+
const path = soloHref(`/${product}`, `${window.location.pathname}${window.location.search}`);
|
|
43
|
+
event.currentTarget.href = otherViewHref(product, view, path) ?? href;
|
|
44
|
+
};
|
|
45
|
+
return (_jsx(SidebarNavItem, { icon: LinkSquare02Icon, label: otherViewLabel(view), render: _jsx("a", { href: href, onClick: onClick }) }));
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FamilyProduct } from "./family-svg.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Haut de la barre latérale. Dépliée : la marque du produit, et le bouton « Réduire » à droite.
|
|
4
|
+
* En rail : la marque seule, qui devient le bouton « Déplier » au survol de la barre ou au focus,
|
|
5
|
+
* à la même place : le bouton ne descend jamais sous le logo. Les deux états sont dans la page et
|
|
6
|
+
* choisis par la variante CSS `rail:`, donc justes dès la première peinture.
|
|
7
|
+
*/
|
|
8
|
+
export declare function SidebarHead({ product }: {
|
|
9
|
+
product: FamilyProduct;
|
|
10
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import PanelLeftCloseIcon from "@hugeicons/core-free-icons/PanelLeftCloseIcon";
|
|
3
|
+
import PanelLeftOpenIcon from "@hugeicons/core-free-icons/PanelLeftOpenIcon";
|
|
4
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
5
|
+
import { cn } from "./cn.js";
|
|
6
|
+
import { isApplePlatform, Kbd, KbdGroup } from "./kbd.js";
|
|
7
|
+
import { ProductBadge } from "./product-badge.js";
|
|
8
|
+
import { familyProductName, SIDEBAR_BRAND, SIDEBAR_BRAND_NAME, SIDEBAR_HEAD } from "./shell-frame.js";
|
|
9
|
+
import { toggleSidebar } from "./sidebar-rail.js";
|
|
10
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
|
|
11
|
+
// Les noms de Hugeicons disent l'état du panneau, pas le geste : « Open » dessine la flèche vers la
|
|
12
|
+
// gauche (réduire), « Close » vers la droite (déplier).
|
|
13
|
+
const COLLAPSE_ICON = PanelLeftOpenIcon;
|
|
14
|
+
const EXPAND_ICON = PanelLeftCloseIcon;
|
|
15
|
+
/** Même boîte qu'une entrée de la barre en rail : 40 × 36 px, icône de 18 px centrée. */
|
|
16
|
+
const TOGGLE = "touch-target relative grid h-9 w-10 shrink-0 place-items-center rounded-xl text-shell-muted outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-shell-hover hover:text-shell-foreground focus-visible:ring-2 focus-visible:ring-ring/50";
|
|
17
|
+
function ShortcutHint({ label }) {
|
|
18
|
+
return (_jsxs("span", { className: "flex items-center gap-2", children: [label, _jsxs(KbdGroup, { children: [_jsx(Kbd, { children: isApplePlatform() ? "⌘" : "Ctrl" }), _jsx(Kbd, { children: "B" })] })] }));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Haut de la barre latérale. Dépliée : la marque du produit, et le bouton « Réduire » à droite.
|
|
22
|
+
* En rail : la marque seule, qui devient le bouton « Déplier » au survol de la barre ou au focus,
|
|
23
|
+
* à la même place : le bouton ne descend jamais sous le logo. Les deux états sont dans la page et
|
|
24
|
+
* choisis par la variante CSS `rail:`, donc justes dès la première peinture.
|
|
25
|
+
*/
|
|
26
|
+
export function SidebarHead({ product }) {
|
|
27
|
+
return (_jsxs("div", { className: SIDEBAR_HEAD, children: [_jsxs("div", { "data-slot": "sidebar-brand", className: cn(SIDEBAR_BRAND, "rail:hidden"), children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: SIDEBAR_BRAND_NAME, children: familyProductName(product) })] }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("button", { type: "button", "data-slot": "sidebar-collapse", onClick: toggleSidebar, "aria-label": "R\u00E9duire la barre lat\u00E9rale", "aria-keyshortcuts": "Control+B Meta+B", className: cn(TOGGLE, "rail:hidden") }), children: _jsx(HugeiconsIcon, { icon: COLLAPSE_ICON, strokeWidth: 1.8, "aria-hidden": true, className: "size-[1.125rem]" }) }), _jsx(TooltipContent, { side: "right", children: _jsx(ShortcutHint, { label: "R\u00E9duire" }) })] }), _jsxs(Tooltip, { children: [_jsxs(TooltipTrigger, { render: _jsx("button", { type: "button", "data-slot": "sidebar-expand", onClick: toggleSidebar, "aria-label": "D\u00E9plier la barre lat\u00E9rale", "aria-keyshortcuts": "Control+B Meta+B", className: cn(TOGGLE, "group/toggle hidden rail:grid") }), children: [_jsx(ProductBadge, { product: product, className: "col-start-1 row-start-1 size-6 transition-opacity duration-(--duration-fast) ease-out group-hover/sidebar:opacity-0 group-focus-visible/toggle:opacity-0 motion-reduce:transition-none" }), _jsx(HugeiconsIcon, { icon: EXPAND_ICON, strokeWidth: 1.8, "aria-hidden": true, className: "col-start-1 row-start-1 size-[1.125rem] opacity-0 transition-opacity duration-(--duration-fast) ease-out group-hover/sidebar:opacity-100 group-focus-visible/toggle:opacity-100 motion-reduce:transition-none" })] }), _jsx(TooltipContent, { side: "right", children: _jsx(ShortcutHint, { label: "D\u00E9plier" }) })] })] }));
|
|
28
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type IconSvgElement } from "@hugeicons/react";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Un groupe titré de la barre : « Suivi », « Réglages ». Ses `SidebarNavItem` en enfants. Le titre
|
|
5
|
+
* nomme la liste pour les lecteurs d'écran ; en rail, il cède la place à un trait.
|
|
6
|
+
*
|
|
7
|
+
* Avec `collapsible`, le titre devient une entrée à flèche qui déroule ses rubriques : fermé par
|
|
8
|
+
* défaut, ouvert dès qu'une de ses rubriques est la page ouverte, puis tel que la personne l'a
|
|
9
|
+
* laissé (mémorisé par navigateur). En rail, ses rubriques restent visibles, sous un trait.
|
|
10
|
+
*/
|
|
11
|
+
export declare function SidebarNavGroup(props: {
|
|
12
|
+
label: string;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
/** Le titre déroule ou replie le groupe. */
|
|
15
|
+
collapsible?: false;
|
|
16
|
+
}): React.JSX.Element;
|
|
17
|
+
export declare function SidebarNavGroup(props: {
|
|
18
|
+
label: string;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
collapsible: true;
|
|
21
|
+
/** Icône de l'entrée qui déroule le groupe, comme une rubrique. */
|
|
22
|
+
icon?: IconSvgElement;
|
|
23
|
+
/**
|
|
24
|
+
* Une rubrique du groupe est la page ouverte : le groupe s'ouvre, dès le rendu serveur. Sans cette
|
|
25
|
+
* valeur, la coque le lit dans les liens (`aria-current="page"`, `data-status="active"`) après
|
|
26
|
+
* l'hydratation.
|
|
27
|
+
*/
|
|
28
|
+
active?: boolean;
|
|
29
|
+
/** Ouvert tant que la personne n'a rien choisi. Fermé par défaut. */
|
|
30
|
+
defaultOpen?: boolean;
|
|
31
|
+
/** Clé de mémorisation, par défaut le titre, propre au produit de la coque : à donner quand deux groupes portent le même. */
|
|
32
|
+
storageKey?: string;
|
|
33
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import ArrowRight01Icon from "@hugeicons/core-free-icons/ArrowRight01Icon";
|
|
3
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
4
|
+
import { useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
|
|
5
|
+
import { cn } from "./cn.js";
|
|
6
|
+
import { ShellContext } from "./shell-context.js";
|
|
7
|
+
const GROUP_KEY_PREFIX = "sidebar-group:";
|
|
8
|
+
const GROUP_EVENT = "sidebargroupchange";
|
|
9
|
+
function subscribeGroups(onChange) {
|
|
10
|
+
window.addEventListener(GROUP_EVENT, onChange);
|
|
11
|
+
window.addEventListener("storage", onChange);
|
|
12
|
+
return () => {
|
|
13
|
+
window.removeEventListener(GROUP_EVENT, onChange);
|
|
14
|
+
window.removeEventListener("storage", onChange);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function readGroup(key) {
|
|
18
|
+
try {
|
|
19
|
+
const value = localStorage.getItem(GROUP_KEY_PREFIX + key);
|
|
20
|
+
return value === "open" ? true : value === "closed" ? false : null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function writeGroup(key, open) {
|
|
27
|
+
try {
|
|
28
|
+
localStorage.setItem(GROUP_KEY_PREFIX + key, open ? "open" : "closed");
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Stockage bloqué : le choix vaut pour cette page seulement.
|
|
32
|
+
}
|
|
33
|
+
window.dispatchEvent(new Event(GROUP_EVENT));
|
|
34
|
+
}
|
|
35
|
+
/** Une entrée de la liste est-elle la page ouverte ? Suit les attributs que pose le routeur. */
|
|
36
|
+
function useActiveDescendant(ref) {
|
|
37
|
+
const [active, setActive] = useState(false);
|
|
38
|
+
useLayoutEffect(() => {
|
|
39
|
+
const element = ref.current;
|
|
40
|
+
if (!element)
|
|
41
|
+
return;
|
|
42
|
+
const read = () => setActive(element.querySelector('[aria-current="page"], [data-status="active"]') !== null);
|
|
43
|
+
read();
|
|
44
|
+
const observer = new MutationObserver(read);
|
|
45
|
+
observer.observe(element, {
|
|
46
|
+
subtree: true,
|
|
47
|
+
childList: true,
|
|
48
|
+
attributes: true,
|
|
49
|
+
attributeFilter: ["aria-current", "data-status"],
|
|
50
|
+
});
|
|
51
|
+
return () => observer.disconnect();
|
|
52
|
+
}, [ref]);
|
|
53
|
+
return active;
|
|
54
|
+
}
|
|
55
|
+
export function SidebarNavGroup(props) {
|
|
56
|
+
if (props.collapsible)
|
|
57
|
+
return _jsx(CollapsibleNavGroup, { ...props });
|
|
58
|
+
return _jsx(TitledNavGroup, { label: props.label, children: props.children });
|
|
59
|
+
}
|
|
60
|
+
function CollapsibleNavGroup({ label, children, icon, active, defaultOpen = false, storageKey, }) {
|
|
61
|
+
// Propre au produit : sous une origine partagée (`app.hgdc.ch/<produit>`), `localStorage` est
|
|
62
|
+
// commun à tous. Le thème et la barre réduite le sont volontairement (`theme`, `sidebar`), un
|
|
63
|
+
// groupe « Réglages » de Mail et de Host ne l'est pas (ADR 0020).
|
|
64
|
+
const { product } = useContext(ShellContext);
|
|
65
|
+
const key = product ? `${product}:${storageKey ?? label}` : (storageKey ?? label);
|
|
66
|
+
const buttonId = useId();
|
|
67
|
+
const listId = useId();
|
|
68
|
+
const listRef = useRef(null);
|
|
69
|
+
const stored = useSyncExternalStore(subscribeGroups, () => readGroup(key), () => null);
|
|
70
|
+
const activeInDom = useActiveDescendant(listRef);
|
|
71
|
+
const current = active ?? activeInDom;
|
|
72
|
+
// Refermé par la personne alors qu'une de ses rubriques est ouverte : il le reste jusqu'à ce
|
|
73
|
+
// qu'elle en sorte. La navigation suivante vers une rubrique du groupe le rouvre.
|
|
74
|
+
const [dismissed, setDismissed] = useState(false);
|
|
75
|
+
const [wasCurrent, setWasCurrent] = useState(current);
|
|
76
|
+
if (wasCurrent !== current) {
|
|
77
|
+
setWasCurrent(current);
|
|
78
|
+
setDismissed(false);
|
|
79
|
+
}
|
|
80
|
+
const open = current ? !dismissed : (stored ?? defaultOpen);
|
|
81
|
+
// Pas d'animation au premier rendu : l'état lu dans le navigateur s'applique sans glisser.
|
|
82
|
+
const [ready, setReady] = useState(false);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const frame = requestAnimationFrame(() => setReady(true));
|
|
85
|
+
return () => cancelAnimationFrame(frame);
|
|
86
|
+
}, []);
|
|
87
|
+
const toggle = () => {
|
|
88
|
+
const next = !open;
|
|
89
|
+
if (current)
|
|
90
|
+
setDismissed(!next);
|
|
91
|
+
writeGroup(key, next);
|
|
92
|
+
};
|
|
93
|
+
return (_jsxs("li", { "data-slot": "sidebar-nav-group", "data-collapsible": "", "data-open": open ? "" : undefined, className: "pt-1 first:pt-0", children: [_jsx("span", { "aria-hidden": true, className: "mx-auto mt-3 mb-2 hidden h-px w-6 bg-border lg:rail:block" }), _jsxs("button", { type: "button", id: buttonId, "aria-expanded": open, "aria-controls": listId, onClick: toggle, className: cn("group/disclosure flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium whitespace-nowrap text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "min-h-11 lg:min-h-9 coarse:min-h-11 lg:gap-2.5 lg:py-2 lg:text-shell-muted lg:hover:bg-shell-hover lg:hover:text-shell-foreground",
|
|
94
|
+
// Une rubrique du groupe est ouverte et le groupe est replié : l'entrée le dit, sans fond.
|
|
95
|
+
current && !open && "text-foreground lg:text-shell-foreground", "lg:rail:hidden"), children: [icon ? (_jsx(HugeiconsIcon, { icon: icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-5 shrink-0 lg:size-[1.125rem]" })) : null, _jsx("span", { className: "truncate", children: label }), _jsx(HugeiconsIcon, { icon: ArrowRight01Icon, strokeWidth: 2, "aria-hidden": true, className: cn("ml-auto size-4 shrink-0 opacity-70 transition-transform duration-(--duration-base) ease-ios motion-reduce:transition-none lg:size-3.5", open && "rotate-90") })] }), _jsx("div", { "data-slot": "sidebar-nav-group-panel", className: cn("grid", open ? "visible grid-rows-[1fr]" : "invisible grid-rows-[0fr]", ready &&
|
|
96
|
+
"transition-[grid-template-rows,visibility] duration-(--duration-base) ease-ios motion-reduce:transition-none",
|
|
97
|
+
// En rail, les rubriques restent là, sous le trait : seules leurs icônes se voient.
|
|
98
|
+
"lg:rail:visible lg:rail:grid-rows-[1fr]"), children: _jsx("ul", { ref: listRef, id: listId, "aria-labelledby": buttonId, className: cn("grid min-h-0 gap-0.5 overflow-hidden",
|
|
99
|
+
// Rubriques en retrait, sous un filet aligné sur l'icône du titre.
|
|
100
|
+
icon ? "ml-[1.375rem] border-l border-border pl-2 lg:ml-5" : "pl-2", "pt-0.5 lg:rail:ml-0 lg:rail:border-l-0 lg:rail:pt-0 lg:rail:pl-0"), children: children }) })] }));
|
|
101
|
+
}
|
|
102
|
+
function TitledNavGroup({ label, children }) {
|
|
103
|
+
const id = useId();
|
|
104
|
+
return (_jsxs("li", { "data-slot": "sidebar-nav-group", className: "pt-4 first:pt-0", children: [_jsx("p", { id: id, className: "px-3 pb-1.5 text-xs font-medium text-muted-foreground lg:text-shell-muted lg:rail:sr-only", children: label }), _jsx("span", { "aria-hidden": true, className: "mx-auto mb-2 hidden h-px w-6 bg-border lg:rail:block" }), _jsx("ul", { "aria-labelledby": id, className: "grid gap-0.5", children: children })] }));
|
|
105
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type IconSvgElement } from "@hugeicons/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* La navigation du produit : 4 à 7 entrées, à plat. Une navigation imbriquée (l'espace d'une page,
|
|
5
|
+
* d'un projet) commence par `SidebarNavBack` et range ses entrées dans des `SidebarNavGroup`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function SidebarNav({ label, className, children, }: {
|
|
8
|
+
/** Nom de la navigation pour les lecteurs d'écran : « Projet », « Organisation ». */
|
|
9
|
+
label: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}): React.JSX.Element;
|
|
13
|
+
/**
|
|
14
|
+
* Une entrée de la barre. « Vous êtes ici » par `active` (ou `data-status="active"`, que pose le
|
|
15
|
+
* `Link` de TanStack Router) : teinte de la coque, jamais l'accent. En rail, l'icône seule, et le
|
|
16
|
+
* nom en infobulle.
|
|
17
|
+
*/
|
|
18
|
+
export declare function SidebarNavItem({ icon, label, active, href, render, }: {
|
|
19
|
+
icon: IconSvgElement;
|
|
20
|
+
label: string;
|
|
21
|
+
active?: boolean;
|
|
22
|
+
href?: string;
|
|
23
|
+
/** Lien du routeur : `render={<Link to="…" />}`. */
|
|
24
|
+
render?: React.ReactElement;
|
|
25
|
+
}): React.JSX.Element;
|
|
26
|
+
/**
|
|
27
|
+
* Retour au niveau du dessus, en tête d'une navigation imbriquée : « Retour aux pages » quand la
|
|
28
|
+
* barre montre l'espace d'une page, d'un projet. Premier enfant de `SidebarNav`, au-dessus des
|
|
29
|
+
* groupes. Une pastille teintée, flèche à gauche, jamais « vous êtes ici ». En rail, la flèche seule
|
|
30
|
+
* et le texte en infobulle.
|
|
31
|
+
*/
|
|
32
|
+
export declare function SidebarNavBack({ label, href, render, }: {
|
|
33
|
+
/** « Retour aux pages » */
|
|
34
|
+
label: string;
|
|
35
|
+
href?: string;
|
|
36
|
+
/** Lien du routeur : `render={<Link to="…" />}`. */
|
|
37
|
+
render?: React.ReactElement;
|
|
38
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import ArrowLeft01Icon from "@hugeicons/core-free-icons/ArrowLeft01Icon";
|
|
3
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
4
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
5
|
+
import { cn } from "./cn.js";
|
|
6
|
+
import { useAppShell } from "./shell-context.js";
|
|
7
|
+
import { useSidebarRail } from "./sidebar-rail.js";
|
|
8
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
|
|
9
|
+
/**
|
|
10
|
+
* La navigation du produit : 4 à 7 entrées, à plat. Une navigation imbriquée (l'espace d'une page,
|
|
11
|
+
* d'un projet) commence par `SidebarNavBack` et range ses entrées dans des `SidebarNavGroup`.
|
|
12
|
+
*/
|
|
13
|
+
export function SidebarNav({ label, className, children, }) {
|
|
14
|
+
return (_jsx("nav", { "aria-label": label, className: cn("min-h-0 flex-1 overflow-y-auto px-3 py-2", className), children: _jsx("ul", { className: "grid gap-0.5", children: children }) }));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Une entrée de la barre. « Vous êtes ici » par `active` (ou `data-status="active"`, que pose le
|
|
18
|
+
* `Link` de TanStack Router) : teinte de la coque, jamais l'accent. En rail, l'icône seule, et le
|
|
19
|
+
* nom en infobulle.
|
|
20
|
+
*/
|
|
21
|
+
export function SidebarNavItem({ icon, label, active, href, render, }) {
|
|
22
|
+
const rail = useSidebarRail();
|
|
23
|
+
const { closeMobileNav } = useAppShell();
|
|
24
|
+
const link = useRender({
|
|
25
|
+
defaultTagName: "a",
|
|
26
|
+
render,
|
|
27
|
+
props: {
|
|
28
|
+
href,
|
|
29
|
+
"aria-current": active ? "page" : undefined,
|
|
30
|
+
onClick: closeMobileNav,
|
|
31
|
+
className: cn("flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", "aria-[current=page]:bg-muted aria-[current=page]:text-foreground data-[status=active]:bg-muted data-[status=active]:text-foreground", "min-h-11 lg:min-h-9 coarse:min-h-11 lg:gap-2.5 lg:py-2 lg:text-shell-muted lg:hover:bg-shell-hover lg:hover:text-shell-foreground", "lg:aria-[current=page]:bg-shell-active lg:aria-[current=page]:text-shell-foreground lg:data-[status=active]:bg-shell-active lg:data-[status=active]:text-shell-foreground", "lg:rail:justify-center lg:rail:px-0"),
|
|
32
|
+
children: (_jsxs(_Fragment, { children: [_jsx(HugeiconsIcon, { icon: icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-5 shrink-0 lg:size-[1.125rem]" }), _jsx("span", { className: "truncate lg:rail:sr-only", children: label })] })),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
return (_jsx("li", { children: _jsxs(Tooltip, { disabled: !rail, children: [_jsx(TooltipTrigger, { render: link }), _jsx(TooltipContent, { side: "right", children: label })] }) }));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Retour au niveau du dessus, en tête d'une navigation imbriquée : « Retour aux pages » quand la
|
|
39
|
+
* barre montre l'espace d'une page, d'un projet. Premier enfant de `SidebarNav`, au-dessus des
|
|
40
|
+
* groupes. Une pastille teintée, flèche à gauche, jamais « vous êtes ici ». En rail, la flèche seule
|
|
41
|
+
* et le texte en infobulle.
|
|
42
|
+
*/
|
|
43
|
+
export function SidebarNavBack({ label, href, render, }) {
|
|
44
|
+
const rail = useSidebarRail();
|
|
45
|
+
const { closeMobileNav } = useAppShell();
|
|
46
|
+
const link = useRender({
|
|
47
|
+
defaultTagName: "a",
|
|
48
|
+
render,
|
|
49
|
+
props: {
|
|
50
|
+
href,
|
|
51
|
+
onClick: closeMobileNav,
|
|
52
|
+
"data-slot": "sidebar-nav-back",
|
|
53
|
+
className: cn(
|
|
54
|
+
// Jamais marqué actif : le lien du routeur pose `data-status="active"` sur un parent ouvert.
|
|
55
|
+
"group/back flex items-center gap-2 rounded-xl bg-muted/70 px-3 py-2.5 text-sm font-medium whitespace-nowrap text-foreground outline-none transition-colors duration-(--duration-fast) ease-out", "hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50", "min-h-11 lg:min-h-9 coarse:min-h-11 lg:py-2 lg:bg-shell-hover lg:text-shell-foreground lg:hover:bg-shell-active", "lg:rail:justify-center lg:rail:px-0"),
|
|
56
|
+
children: (_jsxs(_Fragment, { children: [_jsx(HugeiconsIcon, { icon: ArrowLeft01Icon, strokeWidth: 2, "aria-hidden": true, className: "size-4.5 shrink-0 text-muted-foreground transition-transform duration-(--duration-fast) ease-out group-hover/back:-translate-x-0.5 motion-reduce:transition-none lg:size-4 lg:text-shell-muted" }), _jsx("span", { className: "truncate lg:rail:sr-only", children: label })] })),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
return (_jsx("li", { className: "pb-2", children: _jsxs(Tooltip, { disabled: !rail, children: [_jsx(TooltipTrigger, { render: link }), _jsx(TooltipContent, { side: "right", children: label })] }) }));
|
|
60
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* À poser dans le `<head>`, avec `themeInitScript` : avant la première peinture, il remet le
|
|
3
|
+
* rail (la variante CSS `rail:` le lit, rien ne saute au chargement) et la mémoire de la coque
|
|
4
|
+
* partagée sous la même origine (`shell-memory` : produit de l'adresse, dernière organisation,
|
|
5
|
+
* hauteur de la navigation), que le cadre du chargeur et `OrgPlaceholder` lisent (ADR 0020).
|
|
6
|
+
*/
|
|
7
|
+
export declare const sidebarInitScript: string;
|
|
8
|
+
/** La barre latérale est-elle réduite en rail ? L'état vit sur `<html data-sidebar="rail">`. */
|
|
9
|
+
export declare function useSidebarRail(): boolean;
|
|
10
|
+
export declare function toggleSidebar(): void;
|
|
11
|
+
/** `Ctrl+B` (`⌘B`) réduit ou déplie la barre latérale, hors des champs de saisie. */
|
|
12
|
+
export declare function useToggleShortcut(): void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
2
|
+
import { shellBootScript } from "./shell-memory.js";
|
|
3
|
+
const RAIL_KEY = "sidebar";
|
|
4
|
+
const RAIL_EVENT = "sidebarchange";
|
|
5
|
+
/**
|
|
6
|
+
* À poser dans le `<head>`, avec `themeInitScript` : avant la première peinture, il remet le
|
|
7
|
+
* rail (la variante CSS `rail:` le lit, rien ne saute au chargement) et la mémoire de la coque
|
|
8
|
+
* partagée sous la même origine (`shell-memory` : produit de l'adresse, dernière organisation,
|
|
9
|
+
* hauteur de la navigation), que le cadre du chargeur et `OrgPlaceholder` lisent (ADR 0020).
|
|
10
|
+
*/
|
|
11
|
+
export const sidebarInitScript = shellBootScript();
|
|
12
|
+
function subscribeRail(onChange) {
|
|
13
|
+
window.addEventListener(RAIL_EVENT, onChange);
|
|
14
|
+
return () => window.removeEventListener(RAIL_EVENT, onChange);
|
|
15
|
+
}
|
|
16
|
+
/** La barre latérale est-elle réduite en rail ? L'état vit sur `<html data-sidebar="rail">`. */
|
|
17
|
+
export function useSidebarRail() {
|
|
18
|
+
return useSyncExternalStore(subscribeRail, () => document.documentElement.dataset.sidebar === "rail", () => false);
|
|
19
|
+
}
|
|
20
|
+
export function toggleSidebar() {
|
|
21
|
+
const root = document.documentElement;
|
|
22
|
+
const rail = root.dataset.sidebar !== "rail";
|
|
23
|
+
if (rail)
|
|
24
|
+
root.dataset.sidebar = "rail";
|
|
25
|
+
else
|
|
26
|
+
delete root.dataset.sidebar;
|
|
27
|
+
try {
|
|
28
|
+
if (rail)
|
|
29
|
+
localStorage.setItem(RAIL_KEY, "rail");
|
|
30
|
+
else
|
|
31
|
+
localStorage.removeItem(RAIL_KEY);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Stockage bloqué : le choix vaut pour cette page seulement.
|
|
35
|
+
}
|
|
36
|
+
window.dispatchEvent(new Event(RAIL_EVENT));
|
|
37
|
+
}
|
|
38
|
+
/** `Ctrl+B` (`⌘B`) réduit ou déplie la barre latérale, hors des champs de saisie. */
|
|
39
|
+
export function useToggleShortcut() {
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const onKey = (event) => {
|
|
42
|
+
if (event.key.toLowerCase() !== "b" || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey)
|
|
43
|
+
return;
|
|
44
|
+
const target = event.target;
|
|
45
|
+
if (target instanceof Element && target.closest("input, textarea, select, [contenteditable=true]"))
|
|
46
|
+
return;
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
toggleSidebar();
|
|
49
|
+
};
|
|
50
|
+
document.addEventListener("keydown", onKey);
|
|
51
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
52
|
+
}, []);
|
|
53
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useLayoutEffect } from "react";
|
|
2
|
+
export declare const useIsomorphicLayoutEffect: typeof useLayoutEffect;
|
|
3
|
+
/** Faux au rendu serveur et à l'hydratation, vrai avant la première image qui suit. */
|
|
4
|
+
export declare function useHydrated(): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Le chargement de la zone de contenu : `navigating` après 50 ms, `pending` aussitôt, puis au
|
|
7
|
+
* moins 300 ms. Effets de mise en page : une page qui passe en attente est couverte avant d'être
|
|
8
|
+
* peinte, jamais une image vide entre les deux.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useContentLoading({ navigating, pending }: {
|
|
11
|
+
navigating: boolean;
|
|
12
|
+
pending: boolean;
|
|
13
|
+
}): boolean;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { PAGE_PENDING_MIN_MS, PAGE_PENDING_MS } from "./page-pending.js";
|
|
3
|
+
export const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
4
|
+
/** Faux au rendu serveur et à l'hydratation, vrai avant la première image qui suit. */
|
|
5
|
+
export function useHydrated() {
|
|
6
|
+
const [hydrated, setHydrated] = useState(false);
|
|
7
|
+
useIsomorphicLayoutEffect(() => setHydrated(true), []);
|
|
8
|
+
return hydrated;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Le chargement de la zone de contenu : `navigating` après 50 ms, `pending` aussitôt, puis au
|
|
12
|
+
* moins 300 ms. Effets de mise en page : une page qui passe en attente est couverte avant d'être
|
|
13
|
+
* peinte, jamais une image vide entre les deux.
|
|
14
|
+
*/
|
|
15
|
+
export function useContentLoading({ navigating, pending }) {
|
|
16
|
+
const [navigatingLong, setNavigatingLong] = useState(false);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!navigating)
|
|
19
|
+
return undefined;
|
|
20
|
+
const timer = window.setTimeout(() => setNavigatingLong(true), PAGE_PENDING_MS);
|
|
21
|
+
return () => {
|
|
22
|
+
window.clearTimeout(timer);
|
|
23
|
+
setNavigatingLong(false);
|
|
24
|
+
};
|
|
25
|
+
}, [navigating]);
|
|
26
|
+
const active = pending || (navigating && navigatingLong);
|
|
27
|
+
const [visible, setVisible] = useState(false);
|
|
28
|
+
const shownAt = useRef(0);
|
|
29
|
+
useIsomorphicLayoutEffect(() => {
|
|
30
|
+
if (active && !visible) {
|
|
31
|
+
shownAt.current = performance.now();
|
|
32
|
+
setVisible(true);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (!active && visible) {
|
|
36
|
+
const timer = window.setTimeout(() => setVisible(false), Math.max(0, shownAt.current + PAGE_PENDING_MIN_MS - performance.now()));
|
|
37
|
+
return () => window.clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}, [active, visible]);
|
|
41
|
+
return visible;
|
|
42
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Délai entre le clic vers un autre produit et le H : une page prérendue s'affiche avant (une ou
|
|
3
|
+
* deux images), le H n'apparaît alors jamais ; une page à charger le montre aussitôt après.
|
|
4
|
+
*/
|
|
5
|
+
export declare const PRODUCT_LEAVE_DELAY_MS = 100;
|
|
6
|
+
export declare function useProductLeave(): {
|
|
7
|
+
leaving: boolean;
|
|
8
|
+
leave: () => void;
|
|
9
|
+
};
|