@hgdc/ui 0.37.0 → 0.39.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 -0
- package/dist/account-menu.js +6 -1
- package/dist/app-shell.d.ts +15 -4
- package/dist/app-shell.js +53 -17
- package/dist/family-loader.js +2 -1
- package/dist/org-switcher.js +9 -0
- package/dist/product-rail.d.ts +18 -1
- package/dist/product-rail.js +66 -6
- package/dist/product-switch.d.ts +25 -6
- package/dist/product-switch.js +68 -17
- package/dist/products.d.ts +16 -1
- package/dist/products.js +37 -9
- package/dist/shell-frame.d.ts +21 -0
- package/dist/shell-frame.js +33 -0
- package/dist/shell-memory.d.ts +65 -0
- package/dist/shell-memory.js +169 -0
- package/package.json +1 -1
- package/styles.css +147 -10
package/dist/product-switch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
/*
|
|
3
|
-
* Passage d'un produit à l'autre (ADR 0016) : la barre des produits pointe vers
|
|
3
|
+
* Passage d'un produit à l'autre (ADR 0016, 0020) : la barre des produits pointe vers
|
|
4
4
|
* `/auth/hgdc?refresh=background&next=/`. Avec une session du produit ouverte, l'entrée de
|
|
5
5
|
* `@hgdc/auth` va droit à la page et pose un cookie « relire les organisations » ; la coque le lit
|
|
6
6
|
* ici et relit les organisations dans un cadre caché, sans faire attendre la personne.
|
|
@@ -15,13 +15,33 @@ export const ORG_SYNC_COOKIE = "hgdc-org-sync";
|
|
|
15
15
|
export const BACKGROUND_REFRESH_PATH = "/auth/hgdc?refresh=1&frame=1";
|
|
16
16
|
/** Abandon du cadre s'il ne s'est pas chargé : Account injoignable, réseau coupé. */
|
|
17
17
|
export const BACKGROUND_REFRESH_TIMEOUT_MS = 20_000;
|
|
18
|
+
/** Marque d'un lien de la barre vers un autre produit servi sous la même origine : prérendu au survol. */
|
|
19
|
+
export const PRODUCT_PRERENDER_ATTRIBUTE = "data-hgdc-prerender";
|
|
18
20
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* Règles de spéculation posées par la coque (Chrome et Edge ; ignorées ailleurs) :
|
|
22
|
+
*
|
|
23
|
+
* - **prérendu** au survol (`eagerness: "moderate"`, environ 200 ms) des liens marqués
|
|
24
|
+
* `PRODUCT_PRERENDER_ATTRIBUTE` vers l'entrée `refresh=background` d'un produit de la même
|
|
25
|
+
* origine (`app.hgdc.ch/<produit>`) : le motif est relatif, il ne vaut que pour l'origine de la
|
|
26
|
+
* page. L'entrée ne rend que la redirection d'une session ouverte ; sans session, elle répond 503
|
|
27
|
+
* à une requête `Sec-Purpose: prefetch;prerender`, le prérendu est jeté et le clic repart
|
|
28
|
+
* normalement. La page prérendue attend d'être affichée pour relire les organisations.
|
|
29
|
+
* - **préchargement** seul de l'entrée d'un produit encore sur son sous-domaine (même site, les
|
|
30
|
+
* cookies partent), comme avant (ADR 0016).
|
|
23
31
|
*/
|
|
24
|
-
export const
|
|
32
|
+
export const PRODUCT_SPECULATION_RULES = JSON.stringify({
|
|
33
|
+
prerender: [
|
|
34
|
+
{
|
|
35
|
+
source: "document",
|
|
36
|
+
where: {
|
|
37
|
+
and: [
|
|
38
|
+
{ selector_matches: `a[${PRODUCT_PRERENDER_ATTRIBUTE}]` },
|
|
39
|
+
{ href_matches: "/*/auth/hgdc?refresh=background*" },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
eagerness: "moderate",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
25
45
|
prefetch: [
|
|
26
46
|
{
|
|
27
47
|
source: "document",
|
|
@@ -30,24 +50,53 @@ export const PRODUCT_PREFETCH_RULES = JSON.stringify({
|
|
|
30
50
|
},
|
|
31
51
|
],
|
|
32
52
|
});
|
|
33
|
-
/**
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
53
|
+
/** @deprecated Remplacé par `PRODUCT_SPECULATION_RULES` (prérendu sous la même origine, 0.38). */
|
|
54
|
+
export const PRODUCT_PREFETCH_RULES = PRODUCT_SPECULATION_RULES;
|
|
55
|
+
/** Base de chemin d'un produit (`/analytics`), telle que l'entrée la pose dans le cookie. */
|
|
56
|
+
const BASE_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/;
|
|
57
|
+
/**
|
|
58
|
+
* Lit et efface le cookie de relecture. Rend la base de chemin du produit (`""` à la racine de son
|
|
59
|
+
* origine, `"/analytics"` sous `app.hgdc.ch/analytics`), `null` s'il n'était pas posé. Sous une
|
|
60
|
+
* base, l'entrée le limite à ce chemin : un autre produit de la même origine ne le voit pas.
|
|
61
|
+
*/
|
|
62
|
+
export function takeOrgSync(doc = document) {
|
|
63
|
+
let base = null;
|
|
64
|
+
for (const part of doc.cookie.split(";")) {
|
|
65
|
+
const [name, value] = part.trim().split("=", 2);
|
|
66
|
+
if (name !== ORG_SYNC_COOKIE || value === undefined)
|
|
67
|
+
continue;
|
|
68
|
+
if (value === "1")
|
|
69
|
+
base = "";
|
|
70
|
+
else if (BASE_PATH.test(value))
|
|
71
|
+
base = value;
|
|
72
|
+
if (base !== null)
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
if (base !== null) {
|
|
37
76
|
const secure = doc.location.protocol === "https:" ? "; Secure" : "";
|
|
38
|
-
doc.cookie = `${ORG_SYNC_COOKIE}=; Path
|
|
77
|
+
doc.cookie = `${ORG_SYNC_COOKIE}=; Path=${base || "/"}; Max-Age=0; SameSite=Lax${secure}`;
|
|
39
78
|
}
|
|
40
|
-
return
|
|
79
|
+
return base;
|
|
80
|
+
}
|
|
81
|
+
/** Lit et efface le cookie de relecture. Vrai s'il était posé. */
|
|
82
|
+
export function takeOrgSyncCookie(doc = document) {
|
|
83
|
+
return takeOrgSync(doc) !== null;
|
|
41
84
|
}
|
|
42
85
|
/**
|
|
43
86
|
* Relit les organisations dans un cadre caché, une fois, si l'entrée l'a demandé. Le cadre n'a
|
|
44
87
|
* ni script ni formulaire (`sandbox`), il suit seulement les redirections d'Account ; la nouvelle
|
|
45
88
|
* session du produit recopie les organisations, et les requêtes Convex abonnées se mettent à jour.
|
|
46
89
|
* Si la session d'Account a expiré, Account refuse d'être affiché dans un cadre : rien ne se passe,
|
|
47
|
-
* la session du produit reste celle d'avant.
|
|
90
|
+
* la session du produit reste celle d'avant. Une page prérendue au survol attend d'être affichée :
|
|
91
|
+
* un survol sans clic ne relit rien.
|
|
48
92
|
*/
|
|
49
93
|
export function startBackgroundRefresh(doc = document) {
|
|
50
|
-
if (
|
|
94
|
+
if (doc.prerendering) {
|
|
95
|
+
doc.addEventListener("prerenderingchange", () => startBackgroundRefresh(doc), { once: true });
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const base = takeOrgSync(doc);
|
|
99
|
+
if (base === null)
|
|
51
100
|
return null;
|
|
52
101
|
const frame = doc.createElement("iframe");
|
|
53
102
|
frame.hidden = true;
|
|
@@ -63,7 +112,7 @@ export function startBackgroundRefresh(doc = document) {
|
|
|
63
112
|
};
|
|
64
113
|
const timer = window.setTimeout(done, BACKGROUND_REFRESH_TIMEOUT_MS);
|
|
65
114
|
frame.addEventListener("load", done, { once: true });
|
|
66
|
-
frame.src = BACKGROUND_REFRESH_PATH
|
|
115
|
+
frame.src = `${base}${BACKGROUND_REFRESH_PATH}`;
|
|
67
116
|
doc.body.append(frame);
|
|
68
117
|
return frame;
|
|
69
118
|
}
|
|
@@ -80,7 +129,9 @@ export function useBackgroundOrganizationRefresh() {
|
|
|
80
129
|
}
|
|
81
130
|
/**
|
|
82
131
|
* Dans la coque (`AppShell` le fait) : la relecture ci-dessus si la racine ne l'a pas déjà faite
|
|
83
|
-
* (le cookie ne sert qu'une fois), et le préchargement au survol des autres
|
|
132
|
+
* (le cookie ne sert qu'une fois), et le prérendu ou le préchargement au survol des autres
|
|
133
|
+
* produits. Les règles sont un `<script type="speculationrules">` en ligne : une CSP qui pose
|
|
134
|
+
* `script-src` doit y autoriser `'inline-speculation-rules'` (README).
|
|
84
135
|
*/
|
|
85
136
|
export function useProductSwitch(enabled = true) {
|
|
86
137
|
useEffect(() => {
|
|
@@ -95,7 +146,7 @@ export function useProductSwitch(enabled = true) {
|
|
|
95
146
|
// Créé par le DOM, pas par React : un script inséré par `innerHTML` n'est jamais lu.
|
|
96
147
|
const script = document.createElement("script");
|
|
97
148
|
script.type = "speculationrules";
|
|
98
|
-
script.textContent =
|
|
149
|
+
script.textContent = PRODUCT_SPECULATION_RULES;
|
|
99
150
|
document.head.append(script);
|
|
100
151
|
return () => script.remove();
|
|
101
152
|
}, [enabled]);
|
package/dist/products.d.ts
CHANGED
|
@@ -96,6 +96,11 @@ export declare function productMenuEntries(options?: {
|
|
|
96
96
|
accountHref?: string | undefined;
|
|
97
97
|
catalog?: ReadonlyArray<HgdcCatalogProduct> | undefined;
|
|
98
98
|
}): Array<ProductMenuEntry>;
|
|
99
|
+
/**
|
|
100
|
+
* Chemin de `href` s'il est servi sous `pageOrigin` (`/mail/auth/hgdc?…` depuis
|
|
101
|
+
* `https://app.hgdc.ch`), sinon `null`. Sans origine connue (rendu serveur), `null`.
|
|
102
|
+
*/
|
|
103
|
+
export declare function sameOriginPath(href: string, pageOrigin: string | undefined): string | null;
|
|
99
104
|
/** Une entrée de la barre des produits d'`AppShell`. */
|
|
100
105
|
export type ProductRailEntry = {
|
|
101
106
|
name: string;
|
|
@@ -105,17 +110,27 @@ export type ProductRailEntry = {
|
|
|
105
110
|
current?: boolean;
|
|
106
111
|
/** Texte discret sous le nom : « Préversion ». */
|
|
107
112
|
hint?: string;
|
|
113
|
+
/**
|
|
114
|
+
* Produit servi sous la même origine que la page : lien relatif, prérendu au survol
|
|
115
|
+
* (`PRODUCT_PRERENDER_ATTRIBUTE` de `@hgdc/ui/product-switch`) et transition de vue.
|
|
116
|
+
*/
|
|
117
|
+
sameOrigin?: boolean;
|
|
108
118
|
};
|
|
109
119
|
/**
|
|
110
120
|
* Les entrées de la barre des produits : les produits ouverts du catalogue et Account, affiché en
|
|
111
121
|
* tête (le compte commun, pas un produit). Chaque produit pointe vers son entrée
|
|
112
122
|
* `/auth/hgdc?refresh=background&next=/` (ADR 0010, 0013, 0016), Account vers sa racine (`accountHref` en
|
|
113
|
-
* développement). Le produit courant pointe vers
|
|
123
|
+
* développement). Le produit courant pointe vers sa racine, sans détour par l'entrée.
|
|
124
|
+
*
|
|
125
|
+
* `pageOrigin` (`location.origin`, absent au rendu serveur) : un produit servi sous cette origine
|
|
126
|
+
* (`PRODUCT_ORIGINS`, `app.hgdc.ch/<produit>`) reçoit un lien relatif et `sameOrigin` (ADR 0020),
|
|
127
|
+
* et le produit courant sa base (`/mail/`) plutôt que `/`.
|
|
114
128
|
*/
|
|
115
129
|
export declare function productRailEntries(options?: {
|
|
116
130
|
current?: string | undefined;
|
|
117
131
|
accountHref?: string | undefined;
|
|
118
132
|
catalog?: ReadonlyArray<HgdcCatalogProduct> | undefined;
|
|
133
|
+
pageOrigin?: string | undefined;
|
|
119
134
|
}): {
|
|
120
135
|
products: Array<ProductRailEntry>;
|
|
121
136
|
account: ProductRailEntry;
|
package/dist/products.js
CHANGED
|
@@ -34,6 +34,7 @@ export const PRODUCT_ORIGINS = {
|
|
|
34
34
|
analytics: { origin: "https://app.hgdc.ch", basePath: "/analytics" },
|
|
35
35
|
mail: { origin: "https://app.hgdc.ch", basePath: "/mail" },
|
|
36
36
|
host: { origin: "https://app.hgdc.ch", basePath: "/host" },
|
|
37
|
+
docs: { origin: "https://app.hgdc.ch", basePath: "/docs" },
|
|
37
38
|
};
|
|
38
39
|
/** Adresse de l'app d'un produit : celle de `PRODUCT_ORIGINS`, sinon `https://<mot>.hgdc.ch`. */
|
|
39
40
|
export function productOrigin(word, origins = PRODUCT_ORIGINS) {
|
|
@@ -104,22 +105,49 @@ export function productMenuEntries(options = {}) {
|
|
|
104
105
|
...(item.stage === "preview" ? { hint: PRODUCT_STAGE_LABEL.preview } : {}),
|
|
105
106
|
}));
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Chemin de `href` s'il est servi sous `pageOrigin` (`/mail/auth/hgdc?…` depuis
|
|
110
|
+
* `https://app.hgdc.ch`), sinon `null`. Sans origine connue (rendu serveur), `null`.
|
|
111
|
+
*/
|
|
112
|
+
export function sameOriginPath(href, pageOrigin) {
|
|
113
|
+
if (!pageOrigin)
|
|
114
|
+
return null;
|
|
115
|
+
try {
|
|
116
|
+
const url = new URL(href);
|
|
117
|
+
return url.origin === pageOrigin ? `${url.pathname}${url.search}${url.hash}` : null;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
107
123
|
/**
|
|
108
124
|
* Les entrées de la barre des produits : les produits ouverts du catalogue et Account, affiché en
|
|
109
125
|
* tête (le compte commun, pas un produit). Chaque produit pointe vers son entrée
|
|
110
126
|
* `/auth/hgdc?refresh=background&next=/` (ADR 0010, 0013, 0016), Account vers sa racine (`accountHref` en
|
|
111
|
-
* développement). Le produit courant pointe vers
|
|
127
|
+
* développement). Le produit courant pointe vers sa racine, sans détour par l'entrée.
|
|
128
|
+
*
|
|
129
|
+
* `pageOrigin` (`location.origin`, absent au rendu serveur) : un produit servi sous cette origine
|
|
130
|
+
* (`PRODUCT_ORIGINS`, `app.hgdc.ch/<produit>`) reçoit un lien relatif et `sameOrigin` (ADR 0020),
|
|
131
|
+
* et le produit courant sa base (`/mail/`) plutôt que `/`.
|
|
112
132
|
*/
|
|
113
133
|
export function productRailEntries(options = {}) {
|
|
114
134
|
const current = options.current?.toLowerCase();
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
const { pageOrigin } = options;
|
|
136
|
+
const entry = (item, href, product) => {
|
|
137
|
+
const base = {
|
|
138
|
+
name: item.name,
|
|
139
|
+
badge: item.badge,
|
|
140
|
+
...(item.stage === "preview" ? { hint: PRODUCT_STAGE_LABEL.preview } : {}),
|
|
141
|
+
};
|
|
142
|
+
if (item.badge === current) {
|
|
143
|
+
const app = productOrigin(item.badge);
|
|
144
|
+
return { ...base, href: pageOrigin === app.origin ? `${app.basePath}/` : "/", current: true };
|
|
145
|
+
}
|
|
146
|
+
const path = product ? sameOriginPath(href, pageOrigin) : null;
|
|
147
|
+
return path === null ? { ...base, href } : { ...base, href: path, sameOrigin: true };
|
|
148
|
+
};
|
|
121
149
|
return {
|
|
122
|
-
products: (options.catalog ?? HGDC_PRODUCTS).filter(isProductOpen).map((item) => entry(item, item.entryHref)),
|
|
123
|
-
account: entry(HGDC_ACCOUNT, options.accountHref ?? HGDC_ACCOUNT.entryHref),
|
|
150
|
+
products: (options.catalog ?? HGDC_PRODUCTS).filter(isProductOpen).map((item) => entry(item, item.entryHref, true)),
|
|
151
|
+
account: entry(HGDC_ACCOUNT, options.accountHref ?? HGDC_ACCOUNT.entryHref, false),
|
|
124
152
|
};
|
|
125
153
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type FamilyProduct } from "./family-svg.ts";
|
|
2
|
+
/** Nom affiché d'un produit de la famille (« Mail »), Account compris. */
|
|
3
|
+
export declare function familyProductName(product: FamilyProduct): string;
|
|
4
|
+
/** Haut de la barre latérale (marque du produit) : le même dans la coque et dans le cadre. */
|
|
5
|
+
export declare const SIDEBAR_HEAD = "mt-2 flex h-14 shrink-0 items-center gap-1 px-3";
|
|
6
|
+
export declare const SIDEBAR_BRAND = "flex min-w-0 flex-1 items-center gap-2.5 pl-2.25";
|
|
7
|
+
export declare const SIDEBAR_BRAND_NAME = "truncate font-heading text-[0.9375rem] font-semibold";
|
|
8
|
+
/** Même boîte qu'une entrée de la barre en rail : 40 × 36 px. */
|
|
9
|
+
export declare const SIDEBAR_RAIL_BOX = "grid h-9 w-10 shrink-0 place-items-center rounded-xl";
|
|
10
|
+
/**
|
|
11
|
+
* Le cadre de la coque, dessiné par le chargeur du document (`FamilyLoaderRoot`) quand une page
|
|
12
|
+
* attend hors de la coque : barre des produits complète (organisation retenue, pastilles, produit
|
|
13
|
+
* courant), barre latérale du produit (sa marque, la navigation en squelette à sa hauteur
|
|
14
|
+
* retenue), carte du contenu et sa barre du haut. Mêmes boîtes que la coque, au pixel : la
|
|
15
|
+
* transition de vue d'un produit à l'autre n'a rien à faire bouger, et la coque qui monte ensuite
|
|
16
|
+
* prend la place sans saut.
|
|
17
|
+
*
|
|
18
|
+
* Le produit vient de l'adresse (`<html data-hgdc-product>`, `sidebarInitScript`) : le rendu
|
|
19
|
+
* serveur dessine toutes les marques, le CSS montre la bonne.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ShellFrame(): React.JSX.Element;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { FAMILY_PRODUCTS } from "./family-svg.js";
|
|
3
|
+
import { ProductBadge } from "./product-badge.js";
|
|
4
|
+
import { ProductRailFrame } from "./product-rail.js";
|
|
5
|
+
import { HGDC_ACCOUNT, HGDC_PRODUCTS } from "./products.js";
|
|
6
|
+
/** Nom affiché d'un produit de la famille (« Mail »), Account compris. */
|
|
7
|
+
export function familyProductName(product) {
|
|
8
|
+
if (product === HGDC_ACCOUNT.badge)
|
|
9
|
+
return HGDC_ACCOUNT.name;
|
|
10
|
+
return HGDC_PRODUCTS.find((item) => item.badge === product)?.name ?? `HGDC ${product}`;
|
|
11
|
+
}
|
|
12
|
+
/** Haut de la barre latérale (marque du produit) : le même dans la coque et dans le cadre. */
|
|
13
|
+
export const SIDEBAR_HEAD = "mt-2 flex h-14 shrink-0 items-center gap-1 px-3";
|
|
14
|
+
export const SIDEBAR_BRAND = "flex min-w-0 flex-1 items-center gap-2.5 pl-2.25";
|
|
15
|
+
export const SIDEBAR_BRAND_NAME = "truncate font-heading text-[0.9375rem] font-semibold";
|
|
16
|
+
/** Même boîte qu'une entrée de la barre en rail : 40 × 36 px. */
|
|
17
|
+
export const SIDEBAR_RAIL_BOX = "grid h-9 w-10 shrink-0 place-items-center rounded-xl";
|
|
18
|
+
/** Lignes de squelette de la navigation, coupées à la hauteur retenue (`--hgdc-nav-height`). */
|
|
19
|
+
const SKELETON_ROWS = 12;
|
|
20
|
+
/**
|
|
21
|
+
* Le cadre de la coque, dessiné par le chargeur du document (`FamilyLoaderRoot`) quand une page
|
|
22
|
+
* attend hors de la coque : barre des produits complète (organisation retenue, pastilles, produit
|
|
23
|
+
* courant), barre latérale du produit (sa marque, la navigation en squelette à sa hauteur
|
|
24
|
+
* retenue), carte du contenu et sa barre du haut. Mêmes boîtes que la coque, au pixel : la
|
|
25
|
+
* transition de vue d'un produit à l'autre n'a rien à faire bouger, et la coque qui monte ensuite
|
|
26
|
+
* prend la place sans saut.
|
|
27
|
+
*
|
|
28
|
+
* Le produit vient de l'adresse (`<html data-hgdc-product>`, `sidebarInitScript`) : le rendu
|
|
29
|
+
* serveur dessine toutes les marques, le CSS montre la bonne.
|
|
30
|
+
*/
|
|
31
|
+
export function ShellFrame() {
|
|
32
|
+
return (_jsxs("div", { "data-slot": "shell-frame", "aria-hidden": true, className: "hidden h-full lg:flex", children: [_jsx(ProductRailFrame, {}), _jsxs("div", { "data-slot": "shell-frame-sidebar", className: "flex w-64 shrink-0 flex-col overflow-hidden rail:w-16", children: [_jsx("div", { className: SIDEBAR_HEAD, children: FAMILY_PRODUCTS.map((product) => (_jsxs("div", { "data-product": product, className: "contents", children: [_jsxs("div", { className: `${SIDEBAR_BRAND} rail:hidden`, children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: SIDEBAR_BRAND_NAME, children: familyProductName(product) })] }), _jsx("div", { className: `${SIDEBAR_RAIL_BOX} hidden rail:grid`, children: _jsx(ProductBadge, { product: product, className: "size-6" }) })] }, product))) }), _jsx("div", { className: "px-3 py-2", children: _jsx("div", { "data-slot": "shell-frame-nav", className: "grid gap-0.5 overflow-hidden", children: Array.from({ length: SKELETON_ROWS }, (_, index) => (_jsxs("div", { className: "flex h-9 shrink-0 items-center gap-2.5 px-3 rail:justify-center rail:px-0", children: [_jsx("span", { className: "size-[1.125rem] shrink-0 rounded-md bg-shell-hover" }), _jsx("span", { className: "h-2.5 w-24 rounded-full bg-shell-hover rail:hidden" })] }, index))) }) })] }), _jsx("div", { className: "min-w-0 flex-1 py-2 pr-2", children: _jsx("div", { className: "h-full overflow-hidden rounded-xl bg-background", children: _jsx("div", { "data-slot": "shell-frame-header", className: "min-h-14 border-b border-foreground/5 bg-background" }) }) })] }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Organisation affichée en dernier : `{ initials, logo? }`, en `localStorage`. */
|
|
2
|
+
export declare const SHELL_ORG_KEY = "hgdc-shell:org";
|
|
3
|
+
/** Hauteur en pixels de la navigation d'un produit : `hgdc-shell:nav:<produit>`. */
|
|
4
|
+
export declare const SHELL_NAV_KEY_PREFIX = "hgdc-shell:nav:";
|
|
5
|
+
/** Passage d'un produit à l'autre (horodatage), en `sessionStorage`. */
|
|
6
|
+
export declare const SHELL_SWITCH_KEY = "hgdc-shell:switch";
|
|
7
|
+
/** Au-delà, un passage noté n'est plus celui qu'on vit (onglet laissé, retour bien plus tard). */
|
|
8
|
+
export declare const SHELL_SWITCH_MAX_AGE_MS = 10000;
|
|
9
|
+
export type ShellOrganization = {
|
|
10
|
+
initials: string;
|
|
11
|
+
logo?: string;
|
|
12
|
+
};
|
|
13
|
+
export type ShellBootConfig = {
|
|
14
|
+
/** Clé du rail de la barre latérale (`sidebar`), valeur `rail`. */
|
|
15
|
+
railKey: string;
|
|
16
|
+
orgKey: string;
|
|
17
|
+
navKeyPrefix: string;
|
|
18
|
+
/** Les produits de la famille, par mot en minuscules. */
|
|
19
|
+
products: ReadonlyArray<string>;
|
|
20
|
+
/** `[produit, origine, base]` des produits servis sous une base de chemin (`PRODUCT_ORIGINS`). */
|
|
21
|
+
origins: ReadonlyArray<readonly [string, string, string]>;
|
|
22
|
+
/** Domaine des produits sur leur sous-domaine (`hgdc.ch` : `mail.hgdc.ch` est Mail). */
|
|
23
|
+
domain: string;
|
|
24
|
+
};
|
|
25
|
+
type BootWindow = {
|
|
26
|
+
document: {
|
|
27
|
+
documentElement: HTMLElement;
|
|
28
|
+
};
|
|
29
|
+
location: {
|
|
30
|
+
origin: string;
|
|
31
|
+
hostname: string;
|
|
32
|
+
pathname: string;
|
|
33
|
+
};
|
|
34
|
+
localStorage: Pick<Storage, "getItem">;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Applique la mémoire de la coque sur `<html>` : `data-sidebar="rail"`, `data-hgdc-product`
|
|
38
|
+
* (le produit de l'adresse), `data-hgdc-org` et les variables `--hgdc-org-initials`,
|
|
39
|
+
* `--hgdc-org-logo`, `--hgdc-nav-height` que lit `styles.css`. Autonome : sa source est recopiée
|
|
40
|
+
* telle quelle dans le script en ligne du `<head>`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function shellBoot(config: ShellBootConfig, win: BootWindow): void;
|
|
43
|
+
/** Configuration de la famille : les produits du kit et `PRODUCT_ORIGINS`. */
|
|
44
|
+
export declare const SHELL_BOOT_CONFIG: ShellBootConfig;
|
|
45
|
+
/** Le script en ligne du `<head>` : `shellBoot` et sa configuration, sans dépendance. */
|
|
46
|
+
export declare function shellBootScript(config?: ShellBootConfig): string;
|
|
47
|
+
/** Ce que la coque retient d'une organisation : ses initiales et son logo (`https:` seulement). */
|
|
48
|
+
export declare function shellOrganization(org: {
|
|
49
|
+
name: string;
|
|
50
|
+
logo?: string | undefined;
|
|
51
|
+
}): ShellOrganization;
|
|
52
|
+
/** Retient l'organisation affichée (`OrgSwitcher` le fait) et l'applique à la page. */
|
|
53
|
+
export declare function rememberShellOrganization(org: {
|
|
54
|
+
name: string;
|
|
55
|
+
logo?: string | undefined;
|
|
56
|
+
}): void;
|
|
57
|
+
/** Oublie l'organisation (déconnexion) : la page suivante montre un squelette. */
|
|
58
|
+
export declare function forgetShellOrganization(): void;
|
|
59
|
+
/** Retient la hauteur de la navigation d'un produit (`AppShell` le fait après l'affichage). */
|
|
60
|
+
export declare function rememberShellNavHeight(product: string, height: number): void;
|
|
61
|
+
/** Note le départ vers un autre produit (clic dans la barre des produits). */
|
|
62
|
+
export declare function markProductSwitch(now?: number): void;
|
|
63
|
+
/** Lit et efface la note : vrai si la page vient d'un passage depuis la barre, il y a peu. */
|
|
64
|
+
export declare function takeProductSwitch(now?: number): boolean;
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { initials } from "./initials.js";
|
|
2
|
+
import { FAMILY_PRODUCTS } from "./family-svg.js";
|
|
3
|
+
import { PRODUCT_ORIGINS } from "./products.js";
|
|
4
|
+
/*
|
|
5
|
+
* Mémoire de la coque, partagée par les produits servis sous la même origine (`app.hgdc.ch/<produit>`,
|
|
6
|
+
* ADR 0020) : ce qu'il faut pour que la première image d'un produit ait déjà la barre des produits
|
|
7
|
+
* complète et la barre latérale à sa taille, avant que ses données n'arrivent.
|
|
8
|
+
*
|
|
9
|
+
* - l'organisation affichée en dernier : ses initiales et son logo (rien d'autre, pas le nom) ;
|
|
10
|
+
* - la hauteur de la navigation de chaque produit, pour un squelette à la bonne taille ;
|
|
11
|
+
* - le passage en cours d'un produit à l'autre (session de l'onglet), pour que la barre d'arrivée
|
|
12
|
+
* ne s'élargisse pas sous une souris restée immobile.
|
|
13
|
+
*
|
|
14
|
+
* `shellBoot` applique la mémoire sur `<html>` avant la première peinture : il tourne en ligne
|
|
15
|
+
* dans le `<head>` (`sidebarInitScript`), donc il ne dépend de rien d'autre que de ses arguments.
|
|
16
|
+
*/
|
|
17
|
+
/** Organisation affichée en dernier : `{ initials, logo? }`, en `localStorage`. */
|
|
18
|
+
export const SHELL_ORG_KEY = "hgdc-shell:org";
|
|
19
|
+
/** Hauteur en pixels de la navigation d'un produit : `hgdc-shell:nav:<produit>`. */
|
|
20
|
+
export const SHELL_NAV_KEY_PREFIX = "hgdc-shell:nav:";
|
|
21
|
+
/** Passage d'un produit à l'autre (horodatage), en `sessionStorage`. */
|
|
22
|
+
export const SHELL_SWITCH_KEY = "hgdc-shell:switch";
|
|
23
|
+
/** Au-delà, un passage noté n'est plus celui qu'on vit (onglet laissé, retour bien plus tard). */
|
|
24
|
+
export const SHELL_SWITCH_MAX_AGE_MS = 10_000;
|
|
25
|
+
/**
|
|
26
|
+
* Applique la mémoire de la coque sur `<html>` : `data-sidebar="rail"`, `data-hgdc-product`
|
|
27
|
+
* (le produit de l'adresse), `data-hgdc-org` et les variables `--hgdc-org-initials`,
|
|
28
|
+
* `--hgdc-org-logo`, `--hgdc-nav-height` que lit `styles.css`. Autonome : sa source est recopiée
|
|
29
|
+
* telle quelle dans le script en ligne du `<head>`.
|
|
30
|
+
*/
|
|
31
|
+
export function shellBoot(config, win) {
|
|
32
|
+
// Ni fonction imbriquée ni import : un outil de construction n'a rien à y injecter.
|
|
33
|
+
const root = win.document.documentElement;
|
|
34
|
+
let rail = null;
|
|
35
|
+
let rawOrg = null;
|
|
36
|
+
try {
|
|
37
|
+
rail = win.localStorage.getItem(config.railKey);
|
|
38
|
+
rawOrg = win.localStorage.getItem(config.orgKey);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Stockage bloqué : rien à reprendre.
|
|
42
|
+
}
|
|
43
|
+
if (rail === "rail")
|
|
44
|
+
root.dataset.sidebar = "rail";
|
|
45
|
+
let product = "";
|
|
46
|
+
for (const [word, origin, base] of config.origins) {
|
|
47
|
+
const path = win.location.pathname;
|
|
48
|
+
if (win.location.origin === origin && (path === base || path.startsWith(`${base}/`)))
|
|
49
|
+
product = word;
|
|
50
|
+
}
|
|
51
|
+
const host = win.location.hostname;
|
|
52
|
+
const suffix = `.${config.domain}`;
|
|
53
|
+
if (!product && host.endsWith(suffix)) {
|
|
54
|
+
const word = host.slice(0, -suffix.length);
|
|
55
|
+
if (config.products.includes(word))
|
|
56
|
+
product = word;
|
|
57
|
+
}
|
|
58
|
+
// La coque, une fois montée, sait mieux que l'adresse : on ne réécrit pas son produit.
|
|
59
|
+
if (product && !root.dataset.hgdcProduct)
|
|
60
|
+
root.dataset.hgdcProduct = product;
|
|
61
|
+
product = root.dataset.hgdcProduct ?? "";
|
|
62
|
+
let org = null;
|
|
63
|
+
try {
|
|
64
|
+
org = JSON.parse(rawOrg ?? "null");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
org = null;
|
|
68
|
+
}
|
|
69
|
+
const letters = org && typeof org.initials === "string" ? org.initials.replace(/[^\p{L}\p{N}]/gu, "").slice(0, 4) : "";
|
|
70
|
+
const logo = org && typeof org.logo === "string" && org.logo.length < 2048 && /^https:\/\/[^\s"'()\\]+$/.test(org.logo)
|
|
71
|
+
? org.logo
|
|
72
|
+
: "";
|
|
73
|
+
if (letters || logo) {
|
|
74
|
+
root.dataset.hgdcOrg = "";
|
|
75
|
+
root.style.setProperty("--hgdc-org-initials", logo ? '""' : `"${letters}"`);
|
|
76
|
+
if (logo)
|
|
77
|
+
root.style.setProperty("--hgdc-org-logo", `url("${logo}")`);
|
|
78
|
+
else
|
|
79
|
+
root.style.removeProperty("--hgdc-org-logo");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
delete root.dataset.hgdcOrg;
|
|
83
|
+
root.style.removeProperty("--hgdc-org-initials");
|
|
84
|
+
root.style.removeProperty("--hgdc-org-logo");
|
|
85
|
+
}
|
|
86
|
+
let height = 0;
|
|
87
|
+
try {
|
|
88
|
+
height = product ? Number(win.localStorage.getItem(config.navKeyPrefix + product)) : 0;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
height = 0;
|
|
92
|
+
}
|
|
93
|
+
if (height > 0 && height < 2000)
|
|
94
|
+
root.style.setProperty("--hgdc-nav-height", `${Math.round(height)}px`);
|
|
95
|
+
}
|
|
96
|
+
/** Configuration de la famille : les produits du kit et `PRODUCT_ORIGINS`. */
|
|
97
|
+
export const SHELL_BOOT_CONFIG = {
|
|
98
|
+
railKey: "sidebar",
|
|
99
|
+
orgKey: SHELL_ORG_KEY,
|
|
100
|
+
navKeyPrefix: SHELL_NAV_KEY_PREFIX,
|
|
101
|
+
products: FAMILY_PRODUCTS,
|
|
102
|
+
origins: Object.entries(PRODUCT_ORIGINS).map(([word, { origin, basePath }]) => [word, origin, basePath]),
|
|
103
|
+
domain: "hgdc.ch",
|
|
104
|
+
};
|
|
105
|
+
/** Le script en ligne du `<head>` : `shellBoot` et sa configuration, sans dépendance. */
|
|
106
|
+
export function shellBootScript(config = SHELL_BOOT_CONFIG) {
|
|
107
|
+
return `try{(${shellBoot.toString()})(${JSON.stringify(config)},window)}catch(e){}`;
|
|
108
|
+
}
|
|
109
|
+
function browser() {
|
|
110
|
+
return typeof window === "undefined" ? null : window;
|
|
111
|
+
}
|
|
112
|
+
function storageSet(storage, key, value) {
|
|
113
|
+
try {
|
|
114
|
+
if (value === null)
|
|
115
|
+
window[storage].removeItem(key);
|
|
116
|
+
else
|
|
117
|
+
window[storage].setItem(key, value);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Stockage bloqué : la première image d'un autre produit montrera un squelette, rien de plus.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Ce que la coque retient d'une organisation : ses initiales et son logo (`https:` seulement). */
|
|
124
|
+
export function shellOrganization(org) {
|
|
125
|
+
return { initials: initials(org.name), ...(org.logo ? { logo: org.logo } : {}) };
|
|
126
|
+
}
|
|
127
|
+
/** Retient l'organisation affichée (`OrgSwitcher` le fait) et l'applique à la page. */
|
|
128
|
+
export function rememberShellOrganization(org) {
|
|
129
|
+
const win = browser();
|
|
130
|
+
if (!win)
|
|
131
|
+
return;
|
|
132
|
+
storageSet("localStorage", SHELL_ORG_KEY, JSON.stringify(shellOrganization(org)));
|
|
133
|
+
shellBoot(SHELL_BOOT_CONFIG, win);
|
|
134
|
+
}
|
|
135
|
+
/** Oublie l'organisation (déconnexion) : la page suivante montre un squelette. */
|
|
136
|
+
export function forgetShellOrganization() {
|
|
137
|
+
const win = browser();
|
|
138
|
+
if (!win)
|
|
139
|
+
return;
|
|
140
|
+
storageSet("localStorage", SHELL_ORG_KEY, null);
|
|
141
|
+
shellBoot(SHELL_BOOT_CONFIG, win);
|
|
142
|
+
}
|
|
143
|
+
/** Retient la hauteur de la navigation d'un produit (`AppShell` le fait après l'affichage). */
|
|
144
|
+
export function rememberShellNavHeight(product, height) {
|
|
145
|
+
if (!browser() || !(height > 0))
|
|
146
|
+
return;
|
|
147
|
+
storageSet("localStorage", SHELL_NAV_KEY_PREFIX + product, String(Math.round(height)));
|
|
148
|
+
}
|
|
149
|
+
/** Note le départ vers un autre produit (clic dans la barre des produits). */
|
|
150
|
+
export function markProductSwitch(now = Date.now()) {
|
|
151
|
+
if (!browser())
|
|
152
|
+
return;
|
|
153
|
+
storageSet("sessionStorage", SHELL_SWITCH_KEY, String(now));
|
|
154
|
+
}
|
|
155
|
+
/** Lit et efface la note : vrai si la page vient d'un passage depuis la barre, il y a peu. */
|
|
156
|
+
export function takeProductSwitch(now = Date.now()) {
|
|
157
|
+
if (!browser())
|
|
158
|
+
return false;
|
|
159
|
+
let value = null;
|
|
160
|
+
try {
|
|
161
|
+
value = window.sessionStorage.getItem(SHELL_SWITCH_KEY);
|
|
162
|
+
window.sessionStorage.removeItem(SHELL_SWITCH_KEY);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const at = Number(value);
|
|
168
|
+
return value !== null && at > 0 && now - at >= 0 && now - at < SHELL_SWITCH_MAX_AGE_MS;
|
|
169
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.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",
|