@eifi1/ui-kit 0.50.1 → 0.51.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 +11 -9
- package/dist/admin/user-roster.js +31 -12
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/verify-email.d.ts +4 -0
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/components/alert-banner.d.ts +9 -0
- package/dist/components/alert-banner.js +9 -5
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/button-look.d.ts +4 -19
- package/dist/components/button-look.js +3 -30
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-virtual.js +1 -0
- package/dist/components/data-table-virtual.js.map +1 -1
- package/dist/components/field-base.d.ts +31 -1
- package/dist/components/field-base.js +28 -0
- package/dist/components/field-base.js.map +1 -1
- package/dist/components/floating-panel.d.ts +6 -13
- package/dist/components/floating-panel.js +2 -9
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/icon-button.js +3 -5
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/select.js +3 -2
- package/dist/components/select.js.map +1 -1
- package/dist/components/stat-tile.d.ts +8 -1
- package/dist/components/stat-tile.js +7 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/german.js +2 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +2 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +2 -0
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +2 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +2 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +2 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +2 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +2 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/landing/page-seo.d.ts +19 -11
- package/dist/landing/page-seo.js +11 -7
- package/dist/landing/page-seo.js.map +1 -1
- package/dist/landing/public-page.d.ts +57 -0
- package/dist/landing/public-page.js +45 -0
- package/dist/landing/public-page.js.map +1 -0
- package/dist/lib/server-wake.d.ts +3 -2
- package/dist/lib/server-wake.js +19 -13
- package/dist/lib/server-wake.js.map +1 -1
- package/dist/shell/app-shell.d.ts +2 -15
- package/dist/shell/app-shell.js +7 -26
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/dev-menu.d.ts +98 -0
- package/dist/shell/dev-menu.js +133 -0
- package/dist/shell/dev-menu.js.map +1 -0
- package/dist/shell/dev-switchers.d.ts +6 -53
- package/dist/shell/dev-switchers.js +5 -100
- package/dist/shell/dev-switchers.js.map +1 -1
- package/dist/shell/top-bar-actions.d.ts +4 -1
- package/dist/shell/top-bar-actions.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.d.ts +18 -9
- package/dist/shell/top-bar-parts-labels.js +2 -0
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell/topbar-action-menu.d.ts +17 -2
- package/dist/shell/topbar-action-menu.js +3 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +3 -1
- package/dist/shell.js +3 -1
- package/dist/shell.js.map +1 -1
- package/dist/theme/palette-presets.d.ts +0 -3
- package/dist/theme/palette-presets.js +0 -2
- package/dist/theme/palette-presets.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/user-roster.tsx +30 -10
- package/src/auth/verify-email.tsx +4 -0
- package/src/components/alert-banner.tsx +37 -10
- package/src/components/button-look.ts +7 -57
- package/src/components/data-table-fit.ts +4 -1
- package/src/components/data-table-virtual.ts +14 -1
- package/src/components/field-base.tsx +53 -1
- package/src/components/floating-panel.tsx +6 -28
- package/src/components/icon-button.tsx +6 -8
- package/src/components/select.tsx +3 -6
- package/src/components/stat-tile.tsx +27 -9
- package/src/i18n/german.ts +2 -0
- package/src/i18n/kit-labels.tsx +2 -2
- package/src/i18n/locales/es.ts +2 -0
- package/src/i18n/locales/fr.ts +2 -0
- package/src/i18n/locales/hu.ts +2 -0
- package/src/i18n/locales/it.ts +2 -0
- package/src/i18n/locales/zh.ts +2 -0
- package/src/index.ts +4 -1
- package/src/landing/page-seo.ts +28 -16
- package/src/landing/public-page.tsx +106 -0
- package/src/lib/server-wake.ts +41 -17
- package/src/shell/app-shell.tsx +11 -48
- package/src/shell/dev-menu.tsx +259 -0
- package/src/shell/dev-switchers.tsx +13 -174
- package/src/shell/top-bar-actions.tsx +4 -1
- package/src/shell/top-bar-parts-labels.ts +20 -9
- package/src/shell/topbar-action-menu.tsx +28 -2
- package/src/shell.ts +5 -1
- package/src/theme/palette-presets.ts +0 -4
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { useNoIndex } from "../hooks/use-noindex.js";
|
|
5
|
+
import { PublicFooter } from "./landing-sections.js";
|
|
6
|
+
import { usePageSeoIf } from "./page-seo.js";
|
|
7
|
+
const WIDTH = { wide: "max-w-6xl", prose: "max-w-3xl" };
|
|
8
|
+
function PublicPage({
|
|
9
|
+
header,
|
|
10
|
+
footer,
|
|
11
|
+
title,
|
|
12
|
+
description,
|
|
13
|
+
back,
|
|
14
|
+
actions,
|
|
15
|
+
seo,
|
|
16
|
+
width = "wide",
|
|
17
|
+
headingAs: Heading = "h1",
|
|
18
|
+
landmark = true,
|
|
19
|
+
className,
|
|
20
|
+
children,
|
|
21
|
+
...rest
|
|
22
|
+
}) {
|
|
23
|
+
useNoIndex(seo === "noindex");
|
|
24
|
+
usePageSeoIf(seo === "noindex" ? null : seo);
|
|
25
|
+
const Main = landmark ? "main" : "div";
|
|
26
|
+
return /* @__PURE__ */ jsxs("div", { ...rest, "data-slot": "public-page", className: cn("flex min-h-full flex-col", className), children: [
|
|
27
|
+
header,
|
|
28
|
+
/* @__PURE__ */ jsx(Main, { className: "flex-1", children: /* @__PURE__ */ jsxs("div", { "data-slot": "public-page-column", className: cn("mx-auto w-full px-4 py-12", WIDTH[width]), children: [
|
|
29
|
+
back != null && /* @__PURE__ */ jsx("div", { className: "mb-4 text-sm", children: back }),
|
|
30
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between", children: [
|
|
31
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
|
32
|
+
/* @__PURE__ */ jsx(Heading, { className: "text-3xl font-bold tracking-tight break-words text-primary", children: title }),
|
|
33
|
+
description != null && /* @__PURE__ */ jsx("p", { className: "mt-3 max-w-2xl text-lg text-secondary", children: description })
|
|
34
|
+
] }),
|
|
35
|
+
actions != null && /* @__PURE__ */ jsx("div", { "data-slot": "public-page-actions", className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })
|
|
36
|
+
] }),
|
|
37
|
+
/* @__PURE__ */ jsx("div", { className: "mt-8 space-y-8", children })
|
|
38
|
+
] }) }),
|
|
39
|
+
footer === void 0 ? /* @__PURE__ */ jsx(PublicFooter, {}) : footer
|
|
40
|
+
] });
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
PublicPage
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=public-page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/landing/public-page.tsx"],"sourcesContent":["import type { ComponentPropsWithRef, ReactNode } from \"react\";\n\nimport { cn } from \"../lib/cn\";\nimport { useNoIndex } from \"../hooks/use-noindex\";\nimport { PublicFooter } from \"./landing-sections\";\nimport { usePageSeoIf, type PageSeo } from \"./page-seo\";\n\nexport interface PublicPageProps extends Omit<ComponentPropsWithRef<\"div\">, \"title\" | \"children\"> {\n /** The app's public header — the one a signed-out visitor already sees on the landing\n * (`PublicHeader`, keksdose's `LandingHeader`, Kurvenschmiede's). */\n header?: ReactNode;\n /** Default: `<PublicFooter />`, the three legal links (§3.3: one click away from every\n * page a visitor can reach). Pass your own `PublicFooter` for a tagline; `null` for none. */\n footer?: ReactNode;\n /** The page's heading. */\n title: ReactNode;\n /** One or two sentences under the title: what the page is. */\n description?: ReactNode;\n /** Above the title — the app's \"← Back\" link (`<TextLink icon={ArrowLeft}>`). */\n back?: ReactNode;\n /** Beside the title from `sm` up, under it on a phone: the page's own controls (a\n * region and a month picker on keksdose's /prices). */\n actions?: ReactNode;\n /**\n * What search engines are told, and it has no default on purpose: a public page is\n * either one to find — then it names its canonical path (and title, description,\n * structured data), through {@link usePageSeo} — or one to keep out of the index\n * (`\"noindex\"`: a 404, a page a link was sent for). `null` declares nothing, for a page\n * that is not the page: a preview inside another (the showcase).\n */\n seo: PageSeo | \"noindex\" | null;\n /** The column. `\"wide\"` (default) is the landing's sections' width, for tiles and\n * tables; `\"prose\"` the legal pages' reading column. */\n width?: \"wide\" | \"prose\";\n /** Default `h1`; a lower level for a page shown inside another (a preview), as the\n * landing's sections take one. */\n headingAs?: \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\";\n /** `false` draws the column as a `<div>` instead of `<main>`, for a host that has its\n * own `<main>`. Default true. */\n landmark?: boolean;\n children: ReactNode;\n}\n\nconst WIDTH = { wide: \"max-w-6xl\", prose: \"max-w-3xl\" } as const;\n\n/**\n * A public page that is not the landing and not a legal page (0.51, Marcel 2026-10-26):\n * keksdose's regional price snapshot at /prices, and Kurvenschmiede's public pages. The\n * frame every public page has — the app's public header, a column, the public footer — is\n * {@link LegalPage}'s, without the legal parts: no numbered sections, no beta notice, no\n * \"Back to home\" of its own, and indexed unless it says otherwise.\n *\n * keksdose was about to compose it by hand (`PublicHeader` + a column + `PublicFooter` +\n * `usePageSeo`) for /prices, and Kurvenschmiede will have its next public page outside the\n * shell; each app would have drawn the frame its own way. (KS's visitor pages — /dashboard,\n * /control, /dynamics, /steering — are working screens and stay in the app shell, with\n * `VisitorLegalFooter`; this frame is not for them.)\n * The width is the landing's (`max-w-6xl`), so a link from a feature row lands on a page\n * whose edges line up with the one it came from.\n *\n * `seo` is required: a public page is found or it is not, and forgetting the canonical on\n * a page meant to be found is silent. The kit's words are none — the title, the copy and\n * the back link are the app's.\n */\nexport function PublicPage({\n header,\n footer,\n title,\n description,\n back,\n actions,\n seo,\n width = \"wide\",\n headingAs: Heading = \"h1\",\n landmark = true,\n className,\n children,\n ...rest\n}: PublicPageProps) {\n useNoIndex(seo === \"noindex\");\n usePageSeoIf(seo === \"noindex\" ? null : seo);\n const Main = landmark ? \"main\" : \"div\";\n return (\n <div {...rest} data-slot=\"public-page\" className={cn(\"flex min-h-full flex-col\", className)}>\n {header}\n <Main className=\"flex-1\">\n <div data-slot=\"public-page-column\" className={cn(\"mx-auto w-full px-4 py-12\", WIDTH[width])}>\n {back != null && <div className=\"mb-4 text-sm\">{back}</div>}\n <div className=\"flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between\">\n <div className=\"min-w-0\">\n <Heading className=\"text-3xl font-bold tracking-tight break-words text-primary\">{title}</Heading>\n {description != null && <p className=\"mt-3 max-w-2xl text-lg text-secondary\">{description}</p>}\n </div>\n {actions != null && (\n <div data-slot=\"public-page-actions\" className=\"flex shrink-0 flex-wrap items-center gap-2\">\n {actions}\n </div>\n )}\n </div>\n <div className=\"mt-8 space-y-8\">{children}</div>\n </div>\n </Main>\n {footer === undefined ? <PublicFooter /> : footer}\n </div>\n );\n}\n"],"mappings":";AAuF2B,cAEf,YAFe;AArF3B,SAAS,UAAU;AACnB,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAC7B,SAAS,oBAAkC;AAsC3C,MAAM,QAAQ,EAAE,MAAM,aAAa,OAAO,YAAY;AAqB/C,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR,WAAW,UAAU;AAAA,EACrB,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAoB;AAClB,aAAW,QAAQ,SAAS;AAC5B,eAAa,QAAQ,YAAY,OAAO,GAAG;AAC3C,QAAM,OAAO,WAAW,SAAS;AACjC,SACE,qBAAC,SAAK,GAAG,MAAM,aAAU,eAAc,WAAW,GAAG,4BAA4B,SAAS,GACvF;AAAA;AAAA,IACD,oBAAC,QAAK,WAAU,UACd,+BAAC,SAAI,aAAU,sBAAqB,WAAW,GAAG,6BAA6B,MAAM,KAAK,CAAC,GACxF;AAAA,cAAQ,QAAQ,oBAAC,SAAI,WAAU,gBAAgB,gBAAK;AAAA,MACrD,qBAAC,SAAI,WAAU,qEACb;AAAA,6BAAC,SAAI,WAAU,WACb;AAAA,8BAAC,WAAQ,WAAU,8DAA8D,iBAAM;AAAA,UACtF,eAAe,QAAQ,oBAAC,OAAE,WAAU,yCAAyC,uBAAY;AAAA,WAC5F;AAAA,QACC,WAAW,QACV,oBAAC,SAAI,aAAU,uBAAsB,WAAU,8CAC5C,mBACH;AAAA,SAEJ;AAAA,MACA,oBAAC,SAAI,WAAU,kBAAkB,UAAS;AAAA,OAC5C,GACF;AAAA,IACC,WAAW,SAAY,oBAAC,gBAAa,IAAK;AAAA,KAC7C;AAEJ;","names":[]}
|
|
@@ -72,8 +72,9 @@ export interface ServerWakeWatcher {
|
|
|
72
72
|
*/
|
|
73
73
|
start(method?: string, url?: string, request?: ServerWakeRequest): boolean;
|
|
74
74
|
/** A counted request settled — either way: an error still proves the server
|
|
75
|
-
* answered or gave up.
|
|
76
|
-
* the
|
|
75
|
+
* answered or gave up. The stage then follows the oldest request still out (0.51), so
|
|
76
|
+
* the notice goes away when the last one does, or sooner when the rest are recent.
|
|
77
|
+
* Never takes the counter below zero. */
|
|
77
78
|
end(): void;
|
|
78
79
|
/** `start` + `end` around a promise (or a function returning one), for a request
|
|
79
80
|
* that does not go through an adapter. Resolves and rejects as the work does. */
|
package/dist/lib/server-wake.js
CHANGED
|
@@ -11,7 +11,7 @@ function createServerWake({
|
|
|
11
11
|
wakingMs = 7e3,
|
|
12
12
|
shouldWatch = watchReadsAnd()
|
|
13
13
|
} = {}) {
|
|
14
|
-
|
|
14
|
+
const starts = [];
|
|
15
15
|
let stage = "idle";
|
|
16
16
|
let slowTimer = null;
|
|
17
17
|
let wakingTimer = null;
|
|
@@ -27,22 +27,28 @@ function createServerWake({
|
|
|
27
27
|
slowTimer = null;
|
|
28
28
|
wakingTimer = null;
|
|
29
29
|
};
|
|
30
|
+
const arm = () => {
|
|
31
|
+
clearTimers();
|
|
32
|
+
if (starts.length === 0) {
|
|
33
|
+
setStage("idle");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const age = Date.now() - starts[0];
|
|
37
|
+
setStage(age >= wakingMs ? "waking" : age >= slowMs ? "slow" : "idle");
|
|
38
|
+
if (age < slowMs) slowTimer = setTimeout(() => setStage("slow"), slowMs - age);
|
|
39
|
+
if (age < wakingMs) wakingTimer = setTimeout(() => setStage("waking"), wakingMs - age);
|
|
40
|
+
};
|
|
30
41
|
const start = (method, url, request = { method, url }) => {
|
|
31
42
|
if (isOffline()) return false;
|
|
32
43
|
if (!shouldWatch((method ?? "get").toLowerCase(), url ?? "", request)) return false;
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
clearTimers();
|
|
36
|
-
slowTimer = setTimeout(() => setStage("slow"), slowMs);
|
|
37
|
-
wakingTimer = setTimeout(() => setStage("waking"), wakingMs);
|
|
38
|
-
}
|
|
44
|
+
starts.push(Date.now());
|
|
45
|
+
if (starts.length === 1) arm();
|
|
39
46
|
return true;
|
|
40
47
|
};
|
|
41
48
|
const end = () => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
setStage("idle");
|
|
49
|
+
if (starts.length === 0) return;
|
|
50
|
+
starts.shift();
|
|
51
|
+
arm();
|
|
46
52
|
};
|
|
47
53
|
return {
|
|
48
54
|
start,
|
|
@@ -58,7 +64,7 @@ function createServerWake({
|
|
|
58
64
|
return counted ? promise.finally(end) : promise;
|
|
59
65
|
},
|
|
60
66
|
reset() {
|
|
61
|
-
|
|
67
|
+
starts.length = 0;
|
|
62
68
|
clearTimers();
|
|
63
69
|
setStage("idle");
|
|
64
70
|
},
|
|
@@ -69,7 +75,7 @@ function createServerWake({
|
|
|
69
75
|
};
|
|
70
76
|
},
|
|
71
77
|
getStage: () => stage,
|
|
72
|
-
getInFlight: () =>
|
|
78
|
+
getInFlight: () => starts.length
|
|
73
79
|
};
|
|
74
80
|
}
|
|
75
81
|
const serverWake = createServerWake();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/server-wake.ts"],"sourcesContent":["/**\n * \"Is the server asleep, or is the app broken?\" — the cold-start watchdog, lifted\n * from keksdose (feedback #199) so kastlan and Kurvenschmiede get it too.\n *\n * All three backends run on Cloud Run with `--min-instances=0`: after an idle\n * period the container is gone and the first request has to start it, which takes\n * several seconds and occasionally more. Nothing in the UI said so, and none of the\n * axios instances has a timeout, so the honest experience was a spinner that looked\n * stuck. This counts the in-flight requests a cold start would actually delay and\n * escalates in two steps, so a merely slow response says \"still loading\" and only a\n * genuinely long wait blames the sleeping server.\n *\n * Framework-free on purpose (no zustand, no axios, no React): a counter, two timers\n * and a listener set, read by `useServerWakeStage` through `useSyncExternalStore`.\n * The adapters below take the app's axios instance / fetch as an ARGUMENT, so the\n * kit never imports either.\n *\n * WHAT ARMS IT, and why the default is narrow:\n * - reads (GET) — what every page does on open — always;\n * - writes only when the app says so (`watchReadsAnd(/\\/auth\\/login\\b/)`): the\n * auth POSTs that gate the app are the only writes a user waits on before the\n * app exists at all, so a cold start there looks exactly like a broken login.\n * An UPLOAD must never be watched — a statement PDF or a photo takes seconds on a\n * perfectly warm server, and \"the server is waking up\" during a 4 s upload would\n * simply be false. With the default that holds by itself (an upload is a POST);\n * an app widening `shouldWatch` keeps its upload routes out of it.\n *\n * Nor a DOWNLOAD, for the same reason (kastlan 0.18): axios answers only once the\n * whole body is in, so a generated PDF or a large photo — a GET — ran past both\n * thresholds on a warm server, and held the count for every read beside it. The\n * default filter skips a request whose `responseType` is `\"blob\"`, `\"arraybuffer\"` or\n * `\"stream\"`; a custom filter gets the request as its third argument to do the same.\n * (`wrapFetch` ends the wait at the headers, so a download through it is harmless.)\n *\n * It never arms while the browser is offline, whatever `shouldWatch` says: that state\n * has its own, truthful indicator, and a request that never left the device is not\n * evidence of anything about the server.\n */\n\n/** `slow` = longer than a warm server ever takes; `waking` = long enough that a cold\n * start is the only sensible explanation. */\nexport type ServerWakeStage = \"idle\" | \"slow\" | \"waking\";\n\n/** Decides which requests count. `method` arrives lower-cased (`\"get\"` when the\n * request named none — axios's and fetch's default); `url` as the client had it;\n * `request` is what the adapter saw — for axios the request config itself, so\n * `responseType` or the app's own fields can decide. */\nexport type ServerWakeFilter = (method: string, url: string, request: ServerWakeRequest) => boolean;\n\nexport interface ServerWakeOptions {\n /** Past this, a warm round trip is out of the question (a cold database query on a\n * free tier can still take a second, so it is not tighter). Default 2000 ms. */\n slowMs?: number;\n /** Past this, the container was almost certainly scaled to zero. Default 7000 ms. */\n wakingMs?: number;\n /** Which requests count. Default {@link watchReadsAnd}`()` — GETs only, downloads\n * excluded. The offline guard runs before it and cannot be overridden. */\n shouldWatch?: ServerWakeFilter;\n}\n\n/** What a request is, for {@link ServerWakeWatcher.track} and a {@link ServerWakeFilter}. */\nexport interface ServerWakeRequest {\n method?: string;\n url?: string;\n /** axios's `responseType`. `\"blob\"`, `\"arraybuffer\"` and `\"stream\"` mark a download,\n * which the default filter does not count. */\n responseType?: string;\n}\n\nexport interface ServerWakeWatcher {\n /**\n * Count a request that is going out, and arm the escalation if it is the first.\n * Returns whether it was counted — the caller MUST call {@link end} exactly once\n * for every `true`, or the counter never returns to zero and the notice sticks.\n * (The axios adapter stashes it on the request config for that reason.)\n * `request` reaches the filter as its third argument; default `{ method, url }`.\n */\n start(method?: string, url?: string, request?: ServerWakeRequest): boolean;\n /** A counted request settled — either way: an error still proves the server\n * answered or gave up. When the last one does, the notice goes away. Never takes\n * the counter below zero. */\n end(): void;\n /** `start` + `end` around a promise (or a function returning one), for a request\n * that does not go through an adapter. Resolves and rejects as the work does. */\n track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request?: ServerWakeRequest): Promise<T>;\n /** Back to a pristine watchdog — for tests, which share an instance across cases. */\n reset(): void;\n /** `useSyncExternalStore`'s pair. Listeners hear stage changes only. */\n subscribe(listener: () => void): () => void;\n getStage(): ServerWakeStage;\n /** Counted requests currently outstanding. */\n getInFlight(): number;\n}\n\n/** Response types that make a request a download — see the module comment. */\nconst DOWNLOAD_RESPONSE_TYPES = new Set([\"blob\", \"arraybuffer\", \"stream\"]);\n\n/**\n * The usual filter: every GET, plus any other request whose URL matches `writes` —\n * keksdose's `/\\/auth\\/(login|register|demo-session)\\b/` — but never a download\n * (`responseType` `\"blob\"`, `\"arraybuffer\"` or `\"stream\"`). Keep upload routes out of\n * the pattern (see the module comment).\n */\nexport function watchReadsAnd(writes?: RegExp): ServerWakeFilter {\n return (method, url, request) =>\n !DOWNLOAD_RESPONSE_TYPES.has(request?.responseType ?? \"\") &&\n (method === \"get\" || (writes !== undefined && writes.test(url)));\n}\n\nfunction isOffline(): boolean {\n return typeof navigator !== \"undefined\" && navigator.onLine === false;\n}\n\nexport function createServerWake({\n slowMs = 2000,\n wakingMs = 7000,\n shouldWatch = watchReadsAnd(),\n}: ServerWakeOptions = {}): ServerWakeWatcher {\n let inFlight = 0;\n let stage: ServerWakeStage = \"idle\";\n let slowTimer: ReturnType<typeof setTimeout> | null = null;\n let wakingTimer: ReturnType<typeof setTimeout> | null = null;\n const listeners = new Set<() => void>();\n\n const setStage = (next: ServerWakeStage) => {\n if (next === stage) return;\n stage = next;\n for (const listener of listeners) listener();\n };\n\n const clearTimers = () => {\n if (slowTimer !== null) clearTimeout(slowTimer);\n if (wakingTimer !== null) clearTimeout(wakingTimer);\n slowTimer = null;\n wakingTimer = null;\n };\n\n const start = (method?: string, url?: string, request: ServerWakeRequest = { method, url }): boolean => {\n if (isOffline()) return false;\n if (!shouldWatch((method ?? \"get\").toLowerCase(), url ?? \"\", request)) return false;\n inFlight += 1;\n // The clock runs from the FIRST outstanding request: a page firing a second read\n // at 1.9 s is still waiting on the same sleeping server, not starting afresh.\n if (inFlight === 1) {\n clearTimers();\n slowTimer = setTimeout(() => setStage(\"slow\"), slowMs);\n wakingTimer = setTimeout(() => setStage(\"waking\"), wakingMs);\n }\n return true;\n };\n\n const end = () => {\n inFlight = Math.max(0, inFlight - 1);\n if (inFlight > 0) return;\n clearTimers();\n setStage(\"idle\");\n };\n\n return {\n start,\n end,\n track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request: ServerWakeRequest = {}) {\n const counted = start(request.method, request.url, request);\n let promise: Promise<T>;\n try {\n promise = Promise.resolve(typeof work === \"function\" ? work() : work);\n } catch (error) {\n // A thunk that throws before it returns a promise settled too.\n promise = Promise.reject(error);\n }\n return counted ? promise.finally(end) : promise;\n },\n reset() {\n inFlight = 0;\n clearTimers();\n setStage(\"idle\");\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n getStage: () => stage,\n getInFlight: () => inFlight,\n };\n}\n\n/**\n * The shared instance: what `<ServerWakeNotice />`, `useServerWakeStage()`,\n * {@link attachServerWake} and {@link wrapFetch} use when no watcher is passed. GETs\n * only, default timings — an app that also watches its login POST creates its own\n * with {@link createServerWake} and passes it to both ends.\n */\nexport const serverWake: ServerWakeWatcher = createServerWake();\n\n// ── axios ─────────────────────────────────────────────────────────────────────\n\n/** The config fields the watchdog reads — what axios's request config has. The whole\n * config reaches the filter, so an app's filter can read the rest of it. */\nexport interface ServerWakeAxiosConfig {\n method?: string;\n url?: string;\n responseType?: string;\n}\n\n/** The slice of an interceptor manager the adapter uses. A method (not a function\n * property) so axios's wider `use(onFulfilled?, onRejected?, options?)` fits it. */\ninterface InterceptorManagerLike<V> {\n use(onFulfilled: (value: V) => V | Promise<V>, onRejected: (error: unknown) => unknown): number;\n eject(id: number): void;\n}\n\n/** An axios instance, structurally — so the kit types the adapter without importing\n * axios. `axios.create()`'s result fits it as is. */\nexport interface AxiosLikeInstance<C extends ServerWakeAxiosConfig, R extends { config: C }> {\n interceptors: {\n request: InterceptorManagerLike<C>;\n response: InterceptorManagerLike<R>;\n };\n}\n\n/** Whether the watchdog counted this request — the response side owes a matching\n * `end()` only then. On the config, as keksdose's `_wake` was: it travels with the\n * request onto the response and onto an error. Cleared once settled, so a 401 replay\n * of the same config re-enters the request interceptor and is counted afresh — the\n * pairing stays balanced. */\nconst COUNTED = \"_serverWake\";\ntype Marked = { [COUNTED]?: boolean };\n\n/**\n * Drive a watcher from an axios instance's interceptors: `attachServerWake(api)`.\n * Returns a function that ejects both interceptors again.\n *\n * Attach it BEFORE the app's own response interceptors: axios runs those in the order\n * they were added, so the wake one ends the wait before a 401 handler awaits a token\n * refresh — otherwise the notice would keep counting a request that has already been\n * answered. (Request-side order does not matter.)\n */\nexport function attachServerWake<C extends ServerWakeAxiosConfig, R extends { config: C }>(\n instance: AxiosLikeInstance<C, R>,\n watcher: ServerWakeWatcher = serverWake,\n): () => void {\n const settle = (config: unknown) => {\n const marked = config as Marked | undefined;\n if (marked?.[COUNTED]) {\n marked[COUNTED] = false;\n watcher.end();\n }\n };\n const request = instance.interceptors.request.use(\n (config) => {\n (config as C & Marked)[COUNTED] = watcher.start(config.method, config.url, config);\n return config;\n },\n (error) => Promise.reject(error),\n );\n const response = instance.interceptors.response.use(\n (res) => {\n settle(res.config);\n return res;\n },\n (error) => {\n settle((error as { config?: unknown } | null)?.config);\n return Promise.reject(error);\n },\n );\n return () => {\n instance.interceptors.request.eject(request);\n instance.interceptors.response.eject(response);\n };\n}\n\n// ── fetch ─────────────────────────────────────────────────────────────────────\n\n/**\n * A `fetch` that drives a watcher: `const apiFetch = wrapFetch(fetch)`. The wait ends\n * when the response's HEADERS arrive (or the call fails) — the server is awake by then,\n * however long the body takes.\n */\nexport function wrapFetch(\n fetchImpl: typeof fetch = globalThis.fetch,\n watcher: ServerWakeWatcher = serverWake,\n): typeof fetch {\n return (input, init) => {\n const request = typeof Request !== \"undefined\" && input instanceof Request ? input : undefined;\n const method = init?.method ?? request?.method;\n const url = typeof input === \"string\" ? input : input instanceof URL ? input.href : (request?.url ?? \"\");\n return watcher.track(() => fetchImpl(input, init), { method, url });\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/lib/server-wake.ts"],"sourcesContent":["/**\n * \"Is the server asleep, or is the app broken?\" — the cold-start watchdog, lifted\n * from keksdose (feedback #199) so kastlan and Kurvenschmiede get it too.\n *\n * All three backends run on Cloud Run with `--min-instances=0`: after an idle\n * period the container is gone and the first request has to start it, which takes\n * several seconds and occasionally more. Nothing in the UI said so, and none of the\n * axios instances has a timeout, so the honest experience was a spinner that looked\n * stuck. This counts the in-flight requests a cold start would actually delay and\n * escalates in two steps, so a merely slow response says \"still loading\" and only a\n * genuinely long wait blames the sleeping server.\n *\n * Framework-free on purpose (no zustand, no axios, no React): a counter, two timers\n * and a listener set, read by `useServerWakeStage` through `useSyncExternalStore`.\n * The adapters below take the app's axios instance / fetch as an ARGUMENT, so the\n * kit never imports either.\n *\n * WHAT ARMS IT, and why the default is narrow:\n * - reads (GET) — what every page does on open — always;\n * - writes only when the app says so (`watchReadsAnd(/\\/auth\\/login\\b/)`): the\n * auth POSTs that gate the app are the only writes a user waits on before the\n * app exists at all, so a cold start there looks exactly like a broken login.\n * An UPLOAD must never be watched — a statement PDF or a photo takes seconds on a\n * perfectly warm server, and \"the server is waking up\" during a 4 s upload would\n * simply be false. With the default that holds by itself (an upload is a POST);\n * an app widening `shouldWatch` keeps its upload routes out of it.\n *\n * Nor a DOWNLOAD, for the same reason (kastlan 0.18): axios answers only once the\n * whole body is in, so a generated PDF or a large photo — a GET — ran past both\n * thresholds on a warm server, and held the count for every read beside it. The\n * default filter skips a request whose `responseType` is `\"blob\"`, `\"arraybuffer\"` or\n * `\"stream\"`; a custom filter gets the request as its third argument to do the same.\n * (`wrapFetch` ends the wait at the headers, so a download through it is harmless.)\n *\n * It never arms while the browser is offline, whatever `shouldWatch` says: that state\n * has its own, truthful indicator, and a request that never left the device is not\n * evidence of anything about the server.\n */\n\n/** `slow` = longer than a warm server ever takes; `waking` = long enough that a cold\n * start is the only sensible explanation. */\nexport type ServerWakeStage = \"idle\" | \"slow\" | \"waking\";\n\n/** Decides which requests count. `method` arrives lower-cased (`\"get\"` when the\n * request named none — axios's and fetch's default); `url` as the client had it;\n * `request` is what the adapter saw — for axios the request config itself, so\n * `responseType` or the app's own fields can decide. */\nexport type ServerWakeFilter = (method: string, url: string, request: ServerWakeRequest) => boolean;\n\nexport interface ServerWakeOptions {\n /** Past this, a warm round trip is out of the question (a cold database query on a\n * free tier can still take a second, so it is not tighter). Default 2000 ms. */\n slowMs?: number;\n /** Past this, the container was almost certainly scaled to zero. Default 7000 ms. */\n wakingMs?: number;\n /** Which requests count. Default {@link watchReadsAnd}`()` — GETs only, downloads\n * excluded. The offline guard runs before it and cannot be overridden. */\n shouldWatch?: ServerWakeFilter;\n}\n\n/** What a request is, for {@link ServerWakeWatcher.track} and a {@link ServerWakeFilter}. */\nexport interface ServerWakeRequest {\n method?: string;\n url?: string;\n /** axios's `responseType`. `\"blob\"`, `\"arraybuffer\"` and `\"stream\"` mark a download,\n * which the default filter does not count. */\n responseType?: string;\n}\n\nexport interface ServerWakeWatcher {\n /**\n * Count a request that is going out, and arm the escalation if it is the first.\n * Returns whether it was counted — the caller MUST call {@link end} exactly once\n * for every `true`, or the counter never returns to zero and the notice sticks.\n * (The axios adapter stashes it on the request config for that reason.)\n * `request` reaches the filter as its third argument; default `{ method, url }`.\n */\n start(method?: string, url?: string, request?: ServerWakeRequest): boolean;\n /** A counted request settled — either way: an error still proves the server\n * answered or gave up. The stage then follows the oldest request still out (0.51), so\n * the notice goes away when the last one does, or sooner when the rest are recent.\n * Never takes the counter below zero. */\n end(): void;\n /** `start` + `end` around a promise (or a function returning one), for a request\n * that does not go through an adapter. Resolves and rejects as the work does. */\n track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request?: ServerWakeRequest): Promise<T>;\n /** Back to a pristine watchdog — for tests, which share an instance across cases. */\n reset(): void;\n /** `useSyncExternalStore`'s pair. Listeners hear stage changes only. */\n subscribe(listener: () => void): () => void;\n getStage(): ServerWakeStage;\n /** Counted requests currently outstanding. */\n getInFlight(): number;\n}\n\n/** Response types that make a request a download — see the module comment. */\nconst DOWNLOAD_RESPONSE_TYPES = new Set([\"blob\", \"arraybuffer\", \"stream\"]);\n\n/**\n * The usual filter: every GET, plus any other request whose URL matches `writes` —\n * keksdose's `/\\/auth\\/(login|register|demo-session)\\b/` — but never a download\n * (`responseType` `\"blob\"`, `\"arraybuffer\"` or `\"stream\"`). Keep upload routes out of\n * the pattern (see the module comment).\n */\nexport function watchReadsAnd(writes?: RegExp): ServerWakeFilter {\n return (method, url, request) =>\n !DOWNLOAD_RESPONSE_TYPES.has(request?.responseType ?? \"\") &&\n (method === \"get\" || (writes !== undefined && writes.test(url)));\n}\n\nfunction isOffline(): boolean {\n return typeof navigator !== \"undefined\" && navigator.onLine === false;\n}\n\nexport function createServerWake({\n slowMs = 2000,\n wakingMs = 7000,\n shouldWatch = watchReadsAnd(),\n}: ServerWakeOptions = {}): ServerWakeWatcher {\n // When each counted request went out, oldest first; its length is the count.\n //\n // The stage is the age of the OLDEST outstanding request (0.51). It used to be the age of\n // the first request since the count was last zero, so overlapping reads that were each\n // fast — one going out before the last came back, on a busy page — chained past 2 s and\n // showed \"taking longer than usual\" with nothing slow at all (keksdose live #454, found\n // there while chasing a read that really was slow).\n //\n // `end()` names no request, so it retires the OLDEST start. When a newer request comes\n // back first, the start left standing for the older one is later than its own, so the\n // age read is never MORE than the oldest request's real age: a notice can come late,\n // never falsely.\n const starts: number[] = [];\n let stage: ServerWakeStage = \"idle\";\n let slowTimer: ReturnType<typeof setTimeout> | null = null;\n let wakingTimer: ReturnType<typeof setTimeout> | null = null;\n const listeners = new Set<() => void>();\n\n const setStage = (next: ServerWakeStage) => {\n if (next === stage) return;\n stage = next;\n for (const listener of listeners) listener();\n };\n\n const clearTimers = () => {\n if (slowTimer !== null) clearTimeout(slowTimer);\n if (wakingTimer !== null) clearTimeout(wakingTimer);\n slowTimer = null;\n wakingTimer = null;\n };\n\n // The stage for the oldest outstanding request's age, and timers for what is still to\n // come. With none outstanding: idle.\n const arm = () => {\n clearTimers();\n if (starts.length === 0) {\n setStage(\"idle\");\n return;\n }\n const age = Date.now() - starts[0];\n setStage(age >= wakingMs ? \"waking\" : age >= slowMs ? \"slow\" : \"idle\");\n if (age < slowMs) slowTimer = setTimeout(() => setStage(\"slow\"), slowMs - age);\n if (age < wakingMs) wakingTimer = setTimeout(() => setStage(\"waking\"), wakingMs - age);\n };\n\n const start = (method?: string, url?: string, request: ServerWakeRequest = { method, url }): boolean => {\n if (isOffline()) return false;\n if (!shouldWatch((method ?? \"get\").toLowerCase(), url ?? \"\", request)) return false;\n starts.push(Date.now());\n // A request joining others does not move the clock: a page firing a second read at\n // 1.9 s is still waiting on the same sleeping server, not starting afresh.\n if (starts.length === 1) arm();\n return true;\n };\n\n const end = () => {\n if (starts.length === 0) return;\n // The server answered (or gave up on) one of them, so the oldest's clock is over; the\n // next oldest's age decides, which may well put the notice away again.\n starts.shift();\n arm();\n };\n\n return {\n start,\n end,\n track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request: ServerWakeRequest = {}) {\n const counted = start(request.method, request.url, request);\n let promise: Promise<T>;\n try {\n promise = Promise.resolve(typeof work === \"function\" ? work() : work);\n } catch (error) {\n // A thunk that throws before it returns a promise settled too.\n promise = Promise.reject(error);\n }\n return counted ? promise.finally(end) : promise;\n },\n reset() {\n starts.length = 0;\n clearTimers();\n setStage(\"idle\");\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n getStage: () => stage,\n getInFlight: () => starts.length,\n };\n}\n\n/**\n * The shared instance: what `<ServerWakeNotice />`, `useServerWakeStage()`,\n * {@link attachServerWake} and {@link wrapFetch} use when no watcher is passed. GETs\n * only, default timings — an app that also watches its login POST creates its own\n * with {@link createServerWake} and passes it to both ends.\n */\nexport const serverWake: ServerWakeWatcher = createServerWake();\n\n// ── axios ─────────────────────────────────────────────────────────────────────\n\n/** The config fields the watchdog reads — what axios's request config has. The whole\n * config reaches the filter, so an app's filter can read the rest of it. */\nexport interface ServerWakeAxiosConfig {\n method?: string;\n url?: string;\n responseType?: string;\n}\n\n/** The slice of an interceptor manager the adapter uses. A method (not a function\n * property) so axios's wider `use(onFulfilled?, onRejected?, options?)` fits it. */\ninterface InterceptorManagerLike<V> {\n use(onFulfilled: (value: V) => V | Promise<V>, onRejected: (error: unknown) => unknown): number;\n eject(id: number): void;\n}\n\n/** An axios instance, structurally — so the kit types the adapter without importing\n * axios. `axios.create()`'s result fits it as is. */\nexport interface AxiosLikeInstance<C extends ServerWakeAxiosConfig, R extends { config: C }> {\n interceptors: {\n request: InterceptorManagerLike<C>;\n response: InterceptorManagerLike<R>;\n };\n}\n\n/** Whether the watchdog counted this request — the response side owes a matching\n * `end()` only then. On the config, as keksdose's `_wake` was: it travels with the\n * request onto the response and onto an error. Cleared once settled, so a 401 replay\n * of the same config re-enters the request interceptor and is counted afresh — the\n * pairing stays balanced. */\nconst COUNTED = \"_serverWake\";\ntype Marked = { [COUNTED]?: boolean };\n\n/**\n * Drive a watcher from an axios instance's interceptors: `attachServerWake(api)`.\n * Returns a function that ejects both interceptors again.\n *\n * Attach it BEFORE the app's own response interceptors: axios runs those in the order\n * they were added, so the wake one ends the wait before a 401 handler awaits a token\n * refresh — otherwise the notice would keep counting a request that has already been\n * answered. (Request-side order does not matter.)\n */\nexport function attachServerWake<C extends ServerWakeAxiosConfig, R extends { config: C }>(\n instance: AxiosLikeInstance<C, R>,\n watcher: ServerWakeWatcher = serverWake,\n): () => void {\n const settle = (config: unknown) => {\n const marked = config as Marked | undefined;\n if (marked?.[COUNTED]) {\n marked[COUNTED] = false;\n watcher.end();\n }\n };\n const request = instance.interceptors.request.use(\n (config) => {\n (config as C & Marked)[COUNTED] = watcher.start(config.method, config.url, config);\n return config;\n },\n (error) => Promise.reject(error),\n );\n const response = instance.interceptors.response.use(\n (res) => {\n settle(res.config);\n return res;\n },\n (error) => {\n settle((error as { config?: unknown } | null)?.config);\n return Promise.reject(error);\n },\n );\n return () => {\n instance.interceptors.request.eject(request);\n instance.interceptors.response.eject(response);\n };\n}\n\n// ── fetch ─────────────────────────────────────────────────────────────────────\n\n/**\n * A `fetch` that drives a watcher: `const apiFetch = wrapFetch(fetch)`. The wait ends\n * when the response's HEADERS arrive (or the call fails) — the server is awake by then,\n * however long the body takes.\n */\nexport function wrapFetch(\n fetchImpl: typeof fetch = globalThis.fetch,\n watcher: ServerWakeWatcher = serverWake,\n): typeof fetch {\n return (input, init) => {\n const request = typeof Request !== \"undefined\" && input instanceof Request ? input : undefined;\n const method = init?.method ?? request?.method;\n const url = typeof input === \"string\" ? input : input instanceof URL ? input.href : (request?.url ?? \"\");\n return watcher.track(() => fetchImpl(input, init), { method, url });\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -134,12 +134,6 @@ export { DEFAULT_APP_SHELL_MORE_LABELS };
|
|
|
134
134
|
* of Normal-sized room.
|
|
135
135
|
*/
|
|
136
136
|
export declare const DEFAULT_MOBILE_MAX_ITEMS: Readonly<Record<TextSize, number>>;
|
|
137
|
-
/**
|
|
138
|
-
* @deprecated 0.49 — use {@link DEFAULT_MOBILE_MAX_ITEMS} and `AppShell`'s
|
|
139
|
-
* `mobileMaxItems`: the bar is sized in cells, "More" included; removed in 0.51. This is
|
|
140
|
-
* the same default counted as entries before "More" (one fewer).
|
|
141
|
-
*/
|
|
142
|
-
export declare const DEFAULT_MOBILE_BAR_MAX: Readonly<Record<TextSize, number>>;
|
|
143
137
|
/**
|
|
144
138
|
* The phone bar's cells (0.49 KIT-6): every entry when there are at most `maxItems`,
|
|
145
139
|
* else `maxItems − 1` entries and a "More" cell for the rest. Which entries stay is
|
|
@@ -222,7 +216,7 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
222
216
|
*
|
|
223
217
|
* Unset (0.32.2, kastlan's 0.32.1 report): `"wrap"` at Normal and `"scroll"` at Large
|
|
224
218
|
* and Extra large, where a wrapped group of eight pages took six or seven rows and
|
|
225
|
-
* covered the page — the other half of what `
|
|
219
|
+
* covered the page — the other half of what `mobileMaxItems` does for the bar.
|
|
226
220
|
*/
|
|
227
221
|
mobileSubNavLayout?: "wrap" | "scroll";
|
|
228
222
|
/**
|
|
@@ -238,13 +232,6 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
238
232
|
* `{ xlarge: 5 }` replaces it at one size only. Never fewer than one entry beside More.
|
|
239
233
|
*/
|
|
240
234
|
mobileMaxItems?: number | Partial<Record<TextSize, number>>;
|
|
241
|
-
/**
|
|
242
|
-
* @deprecated 0.49 — use `mobileMaxItems`, which counts the bar's cells, "More"
|
|
243
|
-
* included: `mobileBarMax={4}` is `mobileMaxItems={5}`; removed in 0.51. Read as that
|
|
244
|
-
* (one more, per size), with a development warning; ignored when `mobileMaxItems` is
|
|
245
|
-
* given.
|
|
246
|
-
*/
|
|
247
|
-
mobileBarMax?: number | Partial<Record<TextSize, number>>;
|
|
248
235
|
/** The More cell's words over the provider's `appShellMore`. */
|
|
249
236
|
moreLabels?: Partial<AppShellMoreLabels>;
|
|
250
237
|
/**
|
|
@@ -270,4 +257,4 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
270
257
|
* `NavLink`; domain-free — nav items, brand, footer and the top bar's actions
|
|
271
258
|
* are all supplied by the app.
|
|
272
259
|
*/
|
|
273
|
-
export declare function AppShell({ nav, topBar, children, footer, sidebarFooter, sidebarFooterItems, renderLink, collapseStorageKey: collapseStorageKeyProp, collapseLabel, expandLabel, subNav, mobileSubNav, mobileSubNavLayout: mobileSubNavLayoutProp, mobileMaxItems,
|
|
260
|
+
export declare function AppShell({ nav, topBar, children, footer, sidebarFooter, sidebarFooterItems, renderLink, collapseStorageKey: collapseStorageKeyProp, collapseLabel, expandLabel, subNav, mobileSubNav, mobileSubNavLayout: mobileSubNavLayoutProp, mobileMaxItems, moreLabels, toggleGroupLabel, embedded: embeddedProp, className, ref, ...rest }: AppShellProps): import("react").JSX.Element;
|
package/dist/shell/app-shell.js
CHANGED
|
@@ -26,11 +26,6 @@ const DEFAULT_MOBILE_MAX_ITEMS = {
|
|
|
26
26
|
large: 5,
|
|
27
27
|
xlarge: 4
|
|
28
28
|
};
|
|
29
|
-
const DEFAULT_MOBILE_BAR_MAX = {
|
|
30
|
-
normal: DEFAULT_MOBILE_MAX_ITEMS.normal - 1,
|
|
31
|
-
large: DEFAULT_MOBILE_MAX_ITEMS.large - 1,
|
|
32
|
-
xlarge: DEFAULT_MOBILE_MAX_ITEMS.xlarge - 1
|
|
33
|
-
};
|
|
34
29
|
function pickMobileBar(entries, maxItems, priority) {
|
|
35
30
|
const cells = Math.max(1, Math.floor(maxItems));
|
|
36
31
|
if (entries.length <= cells) return { shown: [...entries], hidden: [] };
|
|
@@ -49,15 +44,9 @@ function splitMobileBar(entries, max) {
|
|
|
49
44
|
if (entries.length <= limit + 1) return { shown: [...entries], hidden: [] };
|
|
50
45
|
return { shown: entries.slice(0, limit), hidden: entries.slice(limit) };
|
|
51
46
|
}
|
|
52
|
-
function mobileCells(maxItems,
|
|
53
|
-
if (maxItems
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
if (barMax !== void 0) {
|
|
57
|
-
const entries = typeof barMax === "number" ? barMax : barMax[size];
|
|
58
|
-
if (entries !== void 0) return entries + 1;
|
|
59
|
-
}
|
|
60
|
-
return DEFAULT_MOBILE_MAX_ITEMS[size];
|
|
47
|
+
function mobileCells(maxItems, size) {
|
|
48
|
+
if (maxItems === void 0) return DEFAULT_MOBILE_MAX_ITEMS[size];
|
|
49
|
+
return typeof maxItems === "number" ? maxItems : maxItems[size] ?? DEFAULT_MOBILE_MAX_ITEMS[size];
|
|
61
50
|
}
|
|
62
51
|
function devWarnings() {
|
|
63
52
|
return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
|
|
@@ -77,15 +66,9 @@ function shortLabelNamesLabel(short, label) {
|
|
|
77
66
|
return s === "" || l === "" || l.startsWith(s) || s.startsWith(l);
|
|
78
67
|
}
|
|
79
68
|
const TEXT_SIZE_WORDS = { normal: "Normal", large: "Large", xlarge: "Extra large" };
|
|
80
|
-
function useNavDevWarnings(nav
|
|
69
|
+
function useNavDevWarnings(nav) {
|
|
81
70
|
useEffect(() => {
|
|
82
71
|
if (!devWarnings()) return;
|
|
83
|
-
if (deprecatedBarMax) {
|
|
84
|
-
warnNavOnce(
|
|
85
|
-
"mobileBarMax",
|
|
86
|
-
`[ui-kit] AppShell mobileBarMax is deprecated: use mobileMaxItems, which counts the bar's cells with "More" (mobileBarMax={4} is mobileMaxItems={5}). Removed in 0.51.`
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
72
|
for (const item of nav) {
|
|
90
73
|
if (item.shortLabel === void 0 || item.shortLabelIsAlias) continue;
|
|
91
74
|
if (shortLabelNamesLabel(item.shortLabel, item.label)) continue;
|
|
@@ -96,7 +79,7 @@ function useNavDevWarnings(nav, deprecatedBarMax) {
|
|
|
96
79
|
`[ui-kit] AppShell nav "${item.to}": shortLabel "${item.shortLabel}" does not start with "${item.label.trim().split(/\s+/)[0]}", the first word of its label "${item.label}". The phone bar shows it ${where}, so the page has another name there than in the sidebar and its title. Shorten the label instead, or pass shortLabelIsAlias: true if the other word is meant.`
|
|
97
80
|
);
|
|
98
81
|
}
|
|
99
|
-
}, [nav
|
|
82
|
+
}, [nav]);
|
|
100
83
|
}
|
|
101
84
|
const AppShellNesting = createContext(false);
|
|
102
85
|
function useNavHeightVar(ref, shellRef, embedded) {
|
|
@@ -136,7 +119,6 @@ function AppShell({
|
|
|
136
119
|
mobileSubNav = true,
|
|
137
120
|
mobileSubNavLayout: mobileSubNavLayoutProp,
|
|
138
121
|
mobileMaxItems,
|
|
139
|
-
mobileBarMax,
|
|
140
122
|
moreLabels,
|
|
141
123
|
toggleGroupLabel,
|
|
142
124
|
embedded: embeddedProp,
|
|
@@ -148,13 +130,13 @@ function AppShell({
|
|
|
148
130
|
const { size: textSize } = useTextSize();
|
|
149
131
|
const large = textSize !== "normal";
|
|
150
132
|
const mobileSubNavLayout = mobileSubNavLayoutProp ?? (large ? "scroll" : "wrap");
|
|
151
|
-
const maxItems = mobileCells(mobileMaxItems,
|
|
133
|
+
const maxItems = mobileCells(mobileMaxItems, textSize);
|
|
152
134
|
const { shown: barNav, hidden: moreNav } = pickMobileBar(
|
|
153
135
|
nav.filter((item) => !item.mobileHidden),
|
|
154
136
|
maxItems,
|
|
155
137
|
(item) => item.mobilePriority
|
|
156
138
|
);
|
|
157
|
-
useNavDevWarnings(nav
|
|
139
|
+
useNavDevWarnings(nav);
|
|
158
140
|
const collapseStorageKey = collapseStorageKeyProp ?? (embedded ? null : "appLayout.sidebarCollapsed");
|
|
159
141
|
const labels = useKitLabels("appShell", DEFAULT_APP_SHELL_LABELS, {
|
|
160
142
|
collapse: collapseLabel,
|
|
@@ -838,7 +820,6 @@ function SidebarFlyout({ item, children }) {
|
|
|
838
820
|
export {
|
|
839
821
|
AppShell,
|
|
840
822
|
DEFAULT_APP_SHELL_MORE_LABELS,
|
|
841
|
-
DEFAULT_MOBILE_BAR_MAX,
|
|
842
823
|
DEFAULT_MOBILE_MAX_ITEMS,
|
|
843
824
|
pickMobileBar,
|
|
844
825
|
splitMobileBar
|