@eifi1/ui-kit 0.50.0 → 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 +14 -12
- 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/autocomplete.js +13 -0
- package/dist/components/autocomplete.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/combobox.js +3 -1
- package/dist/components/combobox.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/inline-entity-combobox.js +12 -0
- package/dist/components/inline-entity-combobox.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/quiet-focus.d.ts +4 -0
- package/dist/lib/quiet-focus.js +19 -0
- package/dist/lib/quiet-focus.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/testing/index.d.ts +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/tour.d.ts +31 -0
- package/dist/testing/tour.js +12 -0
- package/dist/testing/tour.js.map +1 -0
- 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/dist/tour/tour-engine.d.ts +12 -0
- package/dist/tour/tour-engine.js +31 -0
- package/dist/tour/tour-engine.js.map +1 -0
- package/dist/tour/tour.d.ts +14 -3
- package/dist/tour/tour.js +60 -45
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-url-sync.d.ts +5 -3
- package/dist/tour/use-tour-url-sync.js +21 -1
- package/dist/tour/use-tour-url-sync.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/autocomplete.tsx +18 -0
- package/src/components/button-look.ts +7 -57
- package/src/components/combobox.tsx +5 -1
- 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/inline-entity-combobox.tsx +17 -0
- 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/quiet-focus.ts +32 -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/testing/index.ts +8 -0
- package/src/testing/tour.ts +41 -0
- package/src/theme/palette-presets.ts +0 -4
- package/src/tour/tour-engine.ts +41 -0
- package/src/tour/tour.tsx +64 -24
- package/src/tour/use-tour-url-sync.ts +33 -4
package/src/lib/server-wake.ts
CHANGED
|
@@ -77,8 +77,9 @@ export interface ServerWakeWatcher {
|
|
|
77
77
|
*/
|
|
78
78
|
start(method?: string, url?: string, request?: ServerWakeRequest): boolean;
|
|
79
79
|
/** A counted request settled — either way: an error still proves the server
|
|
80
|
-
* answered or gave up.
|
|
81
|
-
* the
|
|
80
|
+
* answered or gave up. The stage then follows the oldest request still out (0.51), so
|
|
81
|
+
* the notice goes away when the last one does, or sooner when the rest are recent.
|
|
82
|
+
* Never takes the counter below zero. */
|
|
82
83
|
end(): void;
|
|
83
84
|
/** `start` + `end` around a promise (or a function returning one), for a request
|
|
84
85
|
* that does not go through an adapter. Resolves and rejects as the work does. */
|
|
@@ -116,7 +117,19 @@ export function createServerWake({
|
|
|
116
117
|
wakingMs = 7000,
|
|
117
118
|
shouldWatch = watchReadsAnd(),
|
|
118
119
|
}: ServerWakeOptions = {}): ServerWakeWatcher {
|
|
119
|
-
|
|
120
|
+
// When each counted request went out, oldest first; its length is the count.
|
|
121
|
+
//
|
|
122
|
+
// The stage is the age of the OLDEST outstanding request (0.51). It used to be the age of
|
|
123
|
+
// the first request since the count was last zero, so overlapping reads that were each
|
|
124
|
+
// fast — one going out before the last came back, on a busy page — chained past 2 s and
|
|
125
|
+
// showed "taking longer than usual" with nothing slow at all (keksdose live #454, found
|
|
126
|
+
// there while chasing a read that really was slow).
|
|
127
|
+
//
|
|
128
|
+
// `end()` names no request, so it retires the OLDEST start. When a newer request comes
|
|
129
|
+
// back first, the start left standing for the older one is later than its own, so the
|
|
130
|
+
// age read is never MORE than the oldest request's real age: a notice can come late,
|
|
131
|
+
// never falsely.
|
|
132
|
+
const starts: number[] = [];
|
|
120
133
|
let stage: ServerWakeStage = "idle";
|
|
121
134
|
let slowTimer: ReturnType<typeof setTimeout> | null = null;
|
|
122
135
|
let wakingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -135,25 +148,36 @@ export function createServerWake({
|
|
|
135
148
|
wakingTimer = null;
|
|
136
149
|
};
|
|
137
150
|
|
|
151
|
+
// The stage for the oldest outstanding request's age, and timers for what is still to
|
|
152
|
+
// come. With none outstanding: idle.
|
|
153
|
+
const arm = () => {
|
|
154
|
+
clearTimers();
|
|
155
|
+
if (starts.length === 0) {
|
|
156
|
+
setStage("idle");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const age = Date.now() - starts[0];
|
|
160
|
+
setStage(age >= wakingMs ? "waking" : age >= slowMs ? "slow" : "idle");
|
|
161
|
+
if (age < slowMs) slowTimer = setTimeout(() => setStage("slow"), slowMs - age);
|
|
162
|
+
if (age < wakingMs) wakingTimer = setTimeout(() => setStage("waking"), wakingMs - age);
|
|
163
|
+
};
|
|
164
|
+
|
|
138
165
|
const start = (method?: string, url?: string, request: ServerWakeRequest = { method, url }): boolean => {
|
|
139
166
|
if (isOffline()) return false;
|
|
140
167
|
if (!shouldWatch((method ?? "get").toLowerCase(), url ?? "", request)) return false;
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
if (
|
|
145
|
-
clearTimers();
|
|
146
|
-
slowTimer = setTimeout(() => setStage("slow"), slowMs);
|
|
147
|
-
wakingTimer = setTimeout(() => setStage("waking"), wakingMs);
|
|
148
|
-
}
|
|
168
|
+
starts.push(Date.now());
|
|
169
|
+
// A request joining others does not move the clock: a page firing a second read at
|
|
170
|
+
// 1.9 s is still waiting on the same sleeping server, not starting afresh.
|
|
171
|
+
if (starts.length === 1) arm();
|
|
149
172
|
return true;
|
|
150
173
|
};
|
|
151
174
|
|
|
152
175
|
const end = () => {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
176
|
+
if (starts.length === 0) return;
|
|
177
|
+
// The server answered (or gave up on) one of them, so the oldest's clock is over; the
|
|
178
|
+
// next oldest's age decides, which may well put the notice away again.
|
|
179
|
+
starts.shift();
|
|
180
|
+
arm();
|
|
157
181
|
};
|
|
158
182
|
|
|
159
183
|
return {
|
|
@@ -171,7 +195,7 @@ export function createServerWake({
|
|
|
171
195
|
return counted ? promise.finally(end) : promise;
|
|
172
196
|
},
|
|
173
197
|
reset() {
|
|
174
|
-
|
|
198
|
+
starts.length = 0;
|
|
175
199
|
clearTimers();
|
|
176
200
|
setStage("idle");
|
|
177
201
|
},
|
|
@@ -182,7 +206,7 @@ export function createServerWake({
|
|
|
182
206
|
};
|
|
183
207
|
},
|
|
184
208
|
getStage: () => stage,
|
|
185
|
-
getInFlight: () =>
|
|
209
|
+
getInFlight: () => starts.length,
|
|
186
210
|
};
|
|
187
211
|
}
|
|
188
212
|
|
package/src/shell/app-shell.tsx
CHANGED
|
@@ -164,17 +164,6 @@ export const DEFAULT_MOBILE_MAX_ITEMS: Readonly<Record<TextSize, number>> = {
|
|
|
164
164
|
xlarge: 4,
|
|
165
165
|
};
|
|
166
166
|
|
|
167
|
-
/**
|
|
168
|
-
* @deprecated 0.49 — use {@link DEFAULT_MOBILE_MAX_ITEMS} and `AppShell`'s
|
|
169
|
-
* `mobileMaxItems`: the bar is sized in cells, "More" included; removed in 0.51. This is
|
|
170
|
-
* the same default counted as entries before "More" (one fewer).
|
|
171
|
-
*/
|
|
172
|
-
export const DEFAULT_MOBILE_BAR_MAX: Readonly<Record<TextSize, number>> = {
|
|
173
|
-
normal: DEFAULT_MOBILE_MAX_ITEMS.normal - 1,
|
|
174
|
-
large: DEFAULT_MOBILE_MAX_ITEMS.large - 1,
|
|
175
|
-
xlarge: DEFAULT_MOBILE_MAX_ITEMS.xlarge - 1,
|
|
176
|
-
};
|
|
177
|
-
|
|
178
167
|
/**
|
|
179
168
|
* The phone bar's cells (0.49 KIT-6): every entry when there are at most `maxItems`,
|
|
180
169
|
* else `maxItems − 1` entries and a "More" cell for the rest. Which entries stay is
|
|
@@ -215,21 +204,10 @@ export function splitMobileBar<T>(entries: readonly T[], max: number): { shown:
|
|
|
215
204
|
return { shown: entries.slice(0, limit), hidden: entries.slice(limit) };
|
|
216
205
|
}
|
|
217
206
|
|
|
218
|
-
/** The bar's cell count at `size`: `mobileMaxItems`, else the
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
maxItems:
|
|
222
|
-
barMax: AppShellProps["mobileBarMax"],
|
|
223
|
-
size: TextSize,
|
|
224
|
-
): number {
|
|
225
|
-
if (maxItems !== undefined) {
|
|
226
|
-
return typeof maxItems === "number" ? maxItems : (maxItems[size] ?? DEFAULT_MOBILE_MAX_ITEMS[size]);
|
|
227
|
-
}
|
|
228
|
-
if (barMax !== undefined) {
|
|
229
|
-
const entries = typeof barMax === "number" ? barMax : barMax[size];
|
|
230
|
-
if (entries !== undefined) return entries + 1;
|
|
231
|
-
}
|
|
232
|
-
return DEFAULT_MOBILE_MAX_ITEMS[size];
|
|
207
|
+
/** The bar's cell count at `size`: `mobileMaxItems`, else the default. */
|
|
208
|
+
function mobileCells(maxItems: AppShellProps["mobileMaxItems"], size: TextSize): number {
|
|
209
|
+
if (maxItems === undefined) return DEFAULT_MOBILE_MAX_ITEMS[size];
|
|
210
|
+
return typeof maxItems === "number" ? maxItems : (maxItems[size] ?? DEFAULT_MOBILE_MAX_ITEMS[size]);
|
|
233
211
|
}
|
|
234
212
|
|
|
235
213
|
/** The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a
|
|
@@ -263,17 +241,10 @@ function shortLabelNamesLabel(short: string, label: string): boolean {
|
|
|
263
241
|
const TEXT_SIZE_WORDS: Record<TextSize, string> = { normal: "Normal", large: "Large", xlarge: "Extra large" };
|
|
264
242
|
|
|
265
243
|
/** A development build's notes on `nav` (0.49 KIT-6): a `shortLabel` that is another word
|
|
266
|
-
* than its `label` without `shortLabelIsAlias
|
|
267
|
-
function useNavDevWarnings(nav: AppShellNavItem[]
|
|
244
|
+
* than its `label` without `shortLabelIsAlias`. */
|
|
245
|
+
function useNavDevWarnings(nav: AppShellNavItem[]): void {
|
|
268
246
|
useEffect(() => {
|
|
269
247
|
if (!devWarnings()) return;
|
|
270
|
-
if (deprecatedBarMax) {
|
|
271
|
-
warnNavOnce(
|
|
272
|
-
"mobileBarMax",
|
|
273
|
-
"[ui-kit] AppShell mobileBarMax is deprecated: use mobileMaxItems, which counts the bar's cells with " +
|
|
274
|
-
'"More" (mobileBarMax={4} is mobileMaxItems={5}). Removed in 0.51.',
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
248
|
for (const item of nav) {
|
|
278
249
|
if (item.shortLabel === undefined || item.shortLabelIsAlias) continue;
|
|
279
250
|
if (shortLabelNamesLabel(item.shortLabel, item.label)) continue;
|
|
@@ -287,7 +258,7 @@ function useNavDevWarnings(nav: AppShellNavItem[], deprecatedBarMax: boolean): v
|
|
|
287
258
|
"Shorten the label instead, or pass shortLabelIsAlias: true if the other word is meant.",
|
|
288
259
|
);
|
|
289
260
|
}
|
|
290
|
-
}, [nav
|
|
261
|
+
}, [nav]);
|
|
291
262
|
}
|
|
292
263
|
|
|
293
264
|
/**
|
|
@@ -352,7 +323,7 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
352
323
|
*
|
|
353
324
|
* Unset (0.32.2, kastlan's 0.32.1 report): `"wrap"` at Normal and `"scroll"` at Large
|
|
354
325
|
* and Extra large, where a wrapped group of eight pages took six or seven rows and
|
|
355
|
-
* covered the page — the other half of what `
|
|
326
|
+
* covered the page — the other half of what `mobileMaxItems` does for the bar.
|
|
356
327
|
*/
|
|
357
328
|
mobileSubNavLayout?: "wrap" | "scroll";
|
|
358
329
|
/**
|
|
@@ -368,13 +339,6 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
368
339
|
* `{ xlarge: 5 }` replaces it at one size only. Never fewer than one entry beside More.
|
|
369
340
|
*/
|
|
370
341
|
mobileMaxItems?: number | Partial<Record<TextSize, number>>;
|
|
371
|
-
/**
|
|
372
|
-
* @deprecated 0.49 — use `mobileMaxItems`, which counts the bar's cells, "More"
|
|
373
|
-
* included: `mobileBarMax={4}` is `mobileMaxItems={5}`; removed in 0.51. Read as that
|
|
374
|
-
* (one more, per size), with a development warning; ignored when `mobileMaxItems` is
|
|
375
|
-
* given.
|
|
376
|
-
*/
|
|
377
|
-
mobileBarMax?: number | Partial<Record<TextSize, number>>;
|
|
378
342
|
/** The More cell's words over the provider's `appShellMore`. */
|
|
379
343
|
moreLabels?: Partial<AppShellMoreLabels>;
|
|
380
344
|
/**
|
|
@@ -498,7 +462,6 @@ export function AppShell({
|
|
|
498
462
|
mobileSubNav = true,
|
|
499
463
|
mobileSubNavLayout: mobileSubNavLayoutProp,
|
|
500
464
|
mobileMaxItems,
|
|
501
|
-
mobileBarMax,
|
|
502
465
|
moreLabels,
|
|
503
466
|
toggleGroupLabel,
|
|
504
467
|
embedded: embeddedProp,
|
|
@@ -512,13 +475,13 @@ export function AppShell({
|
|
|
512
475
|
const mobileSubNavLayout = mobileSubNavLayoutProp ?? (large ? "scroll" : "wrap");
|
|
513
476
|
// The bottom bar's cells: every entry the phone does not reach another way, up to the
|
|
514
477
|
// text size's limit, More included; the rest go under More (§10.7, KIT-6).
|
|
515
|
-
const maxItems = mobileCells(mobileMaxItems,
|
|
478
|
+
const maxItems = mobileCells(mobileMaxItems, textSize);
|
|
516
479
|
const { shown: barNav, hidden: moreNav } = pickMobileBar(
|
|
517
480
|
nav.filter((item) => !item.mobileHidden),
|
|
518
481
|
maxItems,
|
|
519
482
|
(item) => item.mobilePriority,
|
|
520
483
|
);
|
|
521
|
-
useNavDevWarnings(nav
|
|
484
|
+
useNavDevWarnings(nav);
|
|
522
485
|
// An embedded shell sharing the default key would overwrite the outer app's sidebar
|
|
523
486
|
// preference with its own — so it persists only when given a key of its own.
|
|
524
487
|
const collapseStorageKey =
|
|
@@ -1079,7 +1042,7 @@ function TourAnchor({ to, dataTour }: { to: string; dataTour?: string }) {
|
|
|
1079
1042
|
|
|
1080
1043
|
/**
|
|
1081
1044
|
* The bar's "More" cell (0.32, docs/text-size-harmonization.md §4, §10.7): the entries
|
|
1082
|
-
* past `
|
|
1045
|
+
* past `mobileMaxItems`, in a sheet — each entry as a link, and under a group its pages,
|
|
1083
1046
|
* since a group the bar no longer shows has no sub-row to reach them by either. Marked
|
|
1084
1047
|
* active while the current page is one of them, as the cell it replaced would be.
|
|
1085
1048
|
*
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { FlaskConical, Undo2 } from "lucide-react";
|
|
4
|
+
import { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
5
|
+
import { TopBarActionMenu, type TopBarMenuEntry } from "./topbar-action-menu";
|
|
6
|
+
import { useTopBarPartsLabels, type DevSwitchersLabels } from "./top-bar-parts-labels";
|
|
7
|
+
|
|
8
|
+
/** Where the role the session started with is kept, unless the app names its own key. */
|
|
9
|
+
export const DEV_ORIGINAL_ROLE_KEY = "ui-kit:dev-original-role";
|
|
10
|
+
|
|
11
|
+
function readKey(key: string): string | null {
|
|
12
|
+
try {
|
|
13
|
+
return sessionStorage.getItem(key);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeKey(key: string, value: string | null): void {
|
|
20
|
+
try {
|
|
21
|
+
if (value === null) sessionStorage.removeItem(key);
|
|
22
|
+
else sessionStorage.setItem(key, value);
|
|
23
|
+
} catch {
|
|
24
|
+
/* storage blocked: "Back to" lasts as long as this page */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One of the app's own groups in the {@link DevMenu} (docs/top-bar-harmonization.md
|
|
30
|
+
* §2.7.1): keksdose's budget role, its "today" override, its resets.
|
|
31
|
+
*
|
|
32
|
+
* The rows are plain {@link TopBarMenuEntry}s, and there is no dev item type beside them
|
|
33
|
+
* (the contract with keksdose, 0.51): a choice of one is radio rows (`checked`), a toggle
|
|
34
|
+
* is a `checkable: "checkbox"` row, an action is a plain row. No form fields — a menu is
|
|
35
|
+
* a `role="menu"`, where a text or date input has no place in the arrow keys; a free
|
|
36
|
+
* value is a "Custom…" row that opens the app's own dialog.
|
|
37
|
+
*/
|
|
38
|
+
export interface DevMenuSection {
|
|
39
|
+
/** Unique among the sections. The rows' keys need be unique only within theirs: the
|
|
40
|
+
* menu prefixes them with this. */
|
|
41
|
+
key: string;
|
|
42
|
+
/** The group's heading, drawn above its rows and announced as the group's name. */
|
|
43
|
+
title: ReactNode;
|
|
44
|
+
/** The rows, as `TopBarActionMenu` takes them. A `divider` ends the group, as there. */
|
|
45
|
+
entries: readonly TopBarMenuEntry[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DevMenuProps<R extends string, P extends string> {
|
|
49
|
+
/**
|
|
50
|
+
* Draw anything at all. The app passes `import.meta.env.DEV` AND its probe of the dev
|
|
51
|
+
* route (§2.7.1): the constant keeps the menu off a production build's screen, the
|
|
52
|
+
* probe off a dev build whose server has the route shut (§6.1) — flasks with nothing
|
|
53
|
+
* behind them. Neither takes code out of the bundle; see `sections`.
|
|
54
|
+
*/
|
|
55
|
+
enabled: boolean;
|
|
56
|
+
roles?: readonly R[];
|
|
57
|
+
/** The role the server enforces now. Named `currentRole`, not `role`, because `role` on
|
|
58
|
+
* a JSX element is the ARIA role (as `RoleChip`'s `value`). */
|
|
59
|
+
currentRole?: R;
|
|
60
|
+
/** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads
|
|
61
|
+
* `/auth/me` (§2.7, §6.2). */
|
|
62
|
+
onRole?: (role: R) => void;
|
|
63
|
+
plans?: readonly P[];
|
|
64
|
+
/** The plan the server enforces now. Left out (not known yet — kastlan's overview is a
|
|
65
|
+
* 404 while billing is off), the plan group still renders, with no row checked; a
|
|
66
|
+
* `GET /dev/switch` answers it. */
|
|
67
|
+
currentPlan?: P;
|
|
68
|
+
/** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */
|
|
69
|
+
onPlan?: (plan: P) => void;
|
|
70
|
+
/**
|
|
71
|
+
* The role to offer "Back to {role}" for. Left out, the kit keeps it itself: the role
|
|
72
|
+
* before the first switch of this browser session, in `sessionStorage` under
|
|
73
|
+
* `storageKey` — the server answers with the NEW role after a switch, so only the
|
|
74
|
+
* browser still knows the old one.
|
|
75
|
+
*/
|
|
76
|
+
originalRole?: R;
|
|
77
|
+
/** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};
|
|
78
|
+
* an app with several accounts on one browser may scope it per user. */
|
|
79
|
+
storageKey?: string;
|
|
80
|
+
/** Billing is off locally: the plan group says plans change nothing, and stays usable
|
|
81
|
+
* (§6.3). */
|
|
82
|
+
billingOff?: boolean;
|
|
83
|
+
/** A role's or a plan's words in the menu. Default: the value itself. */
|
|
84
|
+
roleLabel?: (role: R) => ReactNode;
|
|
85
|
+
planLabel?: (plan: P) => ReactNode;
|
|
86
|
+
/** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */
|
|
87
|
+
labels?: LabelOverride<DevSwitchersLabels>;
|
|
88
|
+
/** 0.51: the app's own dev groups, after the kit's role and plan, in this order. Their
|
|
89
|
+
* DEFINITIONS belong in a module only a dev build imports (§2.7.1): `enabled={false}`
|
|
90
|
+
* hides the menu, but the code of a section built inline stays in the bundle. */
|
|
91
|
+
sections?: readonly DevMenuSection[];
|
|
92
|
+
/**
|
|
93
|
+
* 0.51: an override is on that the developer could forget — a "today" that is not
|
|
94
|
+
* today, a simulated slow server. The flask wears a dot, and its name says so ("Dev
|
|
95
|
+
* tools · Role: ADMIN · Plan: free Overrides on"), since a dot alone tells a screen
|
|
96
|
+
* reader nothing. The app decides what counts; the kit cannot see into its sections.
|
|
97
|
+
*/
|
|
98
|
+
active?: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const flask = <FlaskConical className="size-5 text-warning" />;
|
|
102
|
+
|
|
103
|
+
/** A label's text for a sentence, or the raw value when the label is not text. */
|
|
104
|
+
const textOf = (label: ReactNode, value: string) =>
|
|
105
|
+
typeof label === "string" || typeof label === "number" ? String(label) : value;
|
|
106
|
+
|
|
107
|
+
/** A row someone can pick — what makes a menu worth drawing. */
|
|
108
|
+
const isRow = (entry: TopBarMenuEntry) => entry.kind !== "heading" && entry.kind !== "divider";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The top bar's local-dev menu (docs/top-bar-harmonization.md §2.7, §2.7.1, §6.2, §6.3):
|
|
112
|
+
* ONE amber flask, `RoleSwitcher`'s, over the kit's Account section and the app's own.
|
|
113
|
+
*
|
|
114
|
+
* 0.42's `DevSwitchers` drew a flask per switcher and had no place for anything else,
|
|
115
|
+
* and an app has more dev controls than a role and a plan — keksdose's budget role, a
|
|
116
|
+
* "today" override, resets (agreed with keksdose for 0.51). So the kit's part is now the
|
|
117
|
+
* menu, and the app's rows go in it.
|
|
118
|
+
*
|
|
119
|
+
* - **The Account section** comes first: the role group, ending in "Back to {role}" once
|
|
120
|
+
* the role differs from the one the session started with (the dev route needs only a
|
|
121
|
+
* signed-in user, so a switch to a lesser role can always switch back); then the plan
|
|
122
|
+
* group, which with `billingOff` notes that plans change nothing. Each renders only
|
|
123
|
+
* with its list and handler.
|
|
124
|
+
* - **The app's `sections`** follow, each a group under its own heading.
|
|
125
|
+
* - **`active`** puts a dot on the flask for an override that is easy to forget.
|
|
126
|
+
*
|
|
127
|
+
* Nothing renders unless `enabled`, and nothing with no row to show. The panel scrolls
|
|
128
|
+
* (`TopBarActionMenu`'s `scrollable`): some 25 rows ran off a 390×844 phone.
|
|
129
|
+
*/
|
|
130
|
+
export function DevMenu<R extends string, P extends string>({
|
|
131
|
+
enabled,
|
|
132
|
+
roles,
|
|
133
|
+
currentRole: role,
|
|
134
|
+
onRole,
|
|
135
|
+
plans,
|
|
136
|
+
currentPlan: plan,
|
|
137
|
+
onPlan,
|
|
138
|
+
originalRole,
|
|
139
|
+
storageKey = DEV_ORIGINAL_ROLE_KEY,
|
|
140
|
+
billingOff = false,
|
|
141
|
+
roleLabel = (r) => r,
|
|
142
|
+
planLabel = (p) => p,
|
|
143
|
+
labels: labelsProp,
|
|
144
|
+
sections = [],
|
|
145
|
+
active = false,
|
|
146
|
+
}: DevMenuProps<R, P>) {
|
|
147
|
+
const bar = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS);
|
|
148
|
+
const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;
|
|
149
|
+
const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));
|
|
150
|
+
// 0.43 (keksdose): `enabled` may start false and turn true — an app's GET probe of the
|
|
151
|
+
// dev route — so the kept role is read again then, or "Back to {role}" is lost after a
|
|
152
|
+
// reload. Adjusted during render, React's pattern for state that follows a prop.
|
|
153
|
+
const [wasEnabled, setWasEnabled] = useState(enabled);
|
|
154
|
+
if (wasEnabled !== enabled) {
|
|
155
|
+
setWasEnabled(enabled);
|
|
156
|
+
if (enabled) setKept(readKey(storageKey));
|
|
157
|
+
}
|
|
158
|
+
if (!enabled) return null;
|
|
159
|
+
|
|
160
|
+
const original = (originalRole ?? kept ?? undefined) as R | undefined;
|
|
161
|
+
|
|
162
|
+
const pickRole = (next: R) => {
|
|
163
|
+
if (originalRole === undefined) {
|
|
164
|
+
if (next === original) {
|
|
165
|
+
// Back where the session began: nothing left to go back to.
|
|
166
|
+
writeKey(storageKey, null);
|
|
167
|
+
setKept(null);
|
|
168
|
+
} else if (kept === null && role !== undefined && next !== role) {
|
|
169
|
+
writeKey(storageKey, role);
|
|
170
|
+
setKept(role);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
onRole?.(next);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const roleGroup: TopBarMenuEntry[] =
|
|
177
|
+
roles && roles.length > 0 && role !== undefined && onRole
|
|
178
|
+
? [
|
|
179
|
+
{ kind: "heading", key: "head-role", label: words.roleHeading },
|
|
180
|
+
...roles.map(
|
|
181
|
+
(r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),
|
|
182
|
+
),
|
|
183
|
+
// Behind a rule, which ends the role group: "Back to" is a way out, not a role.
|
|
184
|
+
...(original !== undefined && original !== role
|
|
185
|
+
? ([
|
|
186
|
+
{ kind: "divider", key: "sep-back" },
|
|
187
|
+
{
|
|
188
|
+
key: "back",
|
|
189
|
+
icon: <Undo2 className="size-4" />,
|
|
190
|
+
label: words.backTo(textOf(roleLabel(original), original)),
|
|
191
|
+
onSelect: () => pickRole(original),
|
|
192
|
+
},
|
|
193
|
+
] satisfies TopBarMenuEntry[])
|
|
194
|
+
: []),
|
|
195
|
+
]
|
|
196
|
+
: [];
|
|
197
|
+
|
|
198
|
+
const planGroup: TopBarMenuEntry[] =
|
|
199
|
+
plans && plans.length > 0 && onPlan
|
|
200
|
+
? [
|
|
201
|
+
{
|
|
202
|
+
kind: "heading",
|
|
203
|
+
key: "head-plan",
|
|
204
|
+
// The billing-off note is part of the group's NAME, under the heading. In 0.42's
|
|
205
|
+
// own plan menu it was a trailing `<li>`: not a row, so the arrow keys passed
|
|
206
|
+
// over it and a screen reader moving through the menu never reached it. Named,
|
|
207
|
+
// it is read on the way into the plans. And in one menu a trailing note would
|
|
208
|
+
// sit under the app's sections, not under the plans.
|
|
209
|
+
label: billingOff ? (
|
|
210
|
+
<>
|
|
211
|
+
{words.planHeading}{" "}
|
|
212
|
+
<span
|
|
213
|
+
data-dev-billing-off=""
|
|
214
|
+
className="mt-0.5 block font-normal normal-case tracking-normal text-xs text-muted"
|
|
215
|
+
>
|
|
216
|
+
{words.billingOff}
|
|
217
|
+
</span>
|
|
218
|
+
</>
|
|
219
|
+
) : (
|
|
220
|
+
words.planHeading
|
|
221
|
+
),
|
|
222
|
+
},
|
|
223
|
+
...plans.map(
|
|
224
|
+
(p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),
|
|
225
|
+
),
|
|
226
|
+
]
|
|
227
|
+
: [];
|
|
228
|
+
|
|
229
|
+
// The app's rows keyed under their section: two sections may well both have a "reset".
|
|
230
|
+
const appGroups = sections.flatMap((section): TopBarMenuEntry[] => [
|
|
231
|
+
{ kind: "heading", key: `section:${section.key}`, label: section.title },
|
|
232
|
+
...section.entries.map((entry): TopBarMenuEntry => ({ ...entry, key: `${section.key}:${entry.key}` })),
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
const entries = [...roleGroup, ...planGroup, ...appGroups];
|
|
236
|
+
if (!entries.some(isRow)) return null;
|
|
237
|
+
|
|
238
|
+
// The menu, then the role and the plan, as 0.42's two triggers named them: the bar
|
|
239
|
+
// shows the flask only, so its name (and tooltip) is where they are read.
|
|
240
|
+
const name = [
|
|
241
|
+
words.menu,
|
|
242
|
+
roleGroup.length > 0 && role !== undefined ? bar.role(role) : null,
|
|
243
|
+
planGroup.length > 0 && plan !== undefined ? words.plan(plan) : null,
|
|
244
|
+
]
|
|
245
|
+
.filter((part): part is string => part !== null)
|
|
246
|
+
.join(" · ");
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<TopBarActionMenu
|
|
250
|
+
icon={flask}
|
|
251
|
+
// The badge's words join the trigger's name, as the tours compass's NEW dot does.
|
|
252
|
+
// `danger`, the badge's default: an amber dot would vanish into the amber flask.
|
|
253
|
+
iconBadge={active ? { label: words.overridesOn } : null}
|
|
254
|
+
aria-label={name}
|
|
255
|
+
entries={entries}
|
|
256
|
+
scrollable
|
|
257
|
+
/>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -1,179 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ReactNode } from "react";
|
|
3
|
-
import { FlaskConical, Undo2 } from "lucide-react";
|
|
4
|
-
import { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
5
|
-
import { TopBarActionMenu, type TopBarMenuEntry } from "./topbar-action-menu";
|
|
6
|
-
import { useTopBarPartsLabels, type DevSwitchersLabels } from "./top-bar-parts-labels";
|
|
1
|
+
import { DevMenu, type DevMenuProps } from "./dev-menu";
|
|
7
2
|
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
// 0.42's local-dev switchers (docs/top-bar-harmonization.md §2.7, §6.2, §6.3), since 0.51
|
|
4
|
+
// a `DevMenu` with no app sections (§2.7.1). The props are the same and so is every row,
|
|
5
|
+
// "Back to {role}" and its `sessionStorage` included — but it draws ONE flask where it
|
|
6
|
+
// drew two: the role and the plan are groups of one menu, as in DevMenu.
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} catch {
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function writeKey(key: string, value: string | null): void {
|
|
20
|
-
try {
|
|
21
|
-
if (value === null) sessionStorage.removeItem(key);
|
|
22
|
-
else sessionStorage.setItem(key, value);
|
|
23
|
-
} catch {
|
|
24
|
-
/* storage blocked: "Back to" lasts as long as this page */
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface DevSwitchersProps<R extends string, P extends string> {
|
|
29
|
-
/** Draw anything at all: the app passes `import.meta.env.DEV`, so a production build
|
|
30
|
-
* tree-shakes the switchers with the constant. */
|
|
31
|
-
enabled: boolean;
|
|
32
|
-
roles?: readonly R[];
|
|
33
|
-
/** The role the server enforces now. Named `currentRole`, not `role`, because `role` on
|
|
34
|
-
* a JSX element is the ARIA role (as `RoleChip`'s `value`). */
|
|
35
|
-
currentRole?: R;
|
|
36
|
-
/** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads
|
|
37
|
-
* `/auth/me` (§2.7, §6.2). */
|
|
38
|
-
onRole?: (role: R) => void;
|
|
39
|
-
plans?: readonly P[];
|
|
40
|
-
/** The plan the server enforces now. Left out (not known yet — kastlan's overview is a
|
|
41
|
-
* 404 while billing is off), the plan menu still renders, with no row checked; a
|
|
42
|
-
* `GET /dev/switch` answers it. */
|
|
43
|
-
currentPlan?: P;
|
|
44
|
-
/** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */
|
|
45
|
-
onPlan?: (plan: P) => void;
|
|
46
|
-
/**
|
|
47
|
-
* The role to offer "Back to {role}" for. Left out, the kit keeps it itself: the role
|
|
48
|
-
* before the first switch of this browser session, in `sessionStorage` under
|
|
49
|
-
* `storageKey` — the server answers with the NEW role after a switch, so only the
|
|
50
|
-
* browser still knows the old one.
|
|
51
|
-
*/
|
|
52
|
-
originalRole?: R;
|
|
53
|
-
/** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};
|
|
54
|
-
* an app with several accounts on one browser may scope it per user. */
|
|
55
|
-
storageKey?: string;
|
|
56
|
-
/** Billing is off locally: the plan menu says plans change nothing, and stays usable
|
|
57
|
-
* (§6.3). */
|
|
58
|
-
billingOff?: boolean;
|
|
59
|
-
/** A role's or a plan's words in the menu. Default: the value itself. */
|
|
60
|
-
roleLabel?: (role: R) => ReactNode;
|
|
61
|
-
planLabel?: (plan: P) => ReactNode;
|
|
62
|
-
/** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */
|
|
63
|
-
labels?: LabelOverride<DevSwitchersLabels>;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const flask = <FlaskConical className="size-5 text-warning" />;
|
|
67
|
-
|
|
68
|
-
/** A label's text for a sentence, or the raw value when the label is not text. */
|
|
69
|
-
const textOf = (label: ReactNode, value: string) =>
|
|
70
|
-
typeof label === "string" || typeof label === "number" ? String(label) : value;
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* The top bar's local-dev switchers (docs/top-bar-harmonization.md §2.7, §6.2, §6.3): the
|
|
74
|
-
* role, then the plan, each a menu behind `RoleSwitcher`'s amber flask. Nothing renders
|
|
75
|
-
* unless `enabled`; either menu renders only with its list and handler.
|
|
76
|
-
*
|
|
77
|
-
* The role menu ends in "Back to {role}" once the role differs from the one the session
|
|
78
|
-
* started with: the dev route needs only a signed-in user, so a switch to a lesser role
|
|
79
|
-
* can always switch back. With `billingOff` the plan menu notes that plans change nothing
|
|
80
|
-
* while billing is off.
|
|
81
|
-
*/
|
|
82
|
-
export function DevSwitchers<R extends string, P extends string>({
|
|
83
|
-
enabled,
|
|
84
|
-
roles,
|
|
85
|
-
currentRole: role,
|
|
86
|
-
onRole,
|
|
87
|
-
plans,
|
|
88
|
-
currentPlan: plan,
|
|
89
|
-
onPlan,
|
|
90
|
-
originalRole,
|
|
91
|
-
storageKey = DEV_ORIGINAL_ROLE_KEY,
|
|
92
|
-
billingOff = false,
|
|
93
|
-
roleLabel = (r) => r,
|
|
94
|
-
planLabel = (p) => p,
|
|
95
|
-
labels: labelsProp,
|
|
96
|
-
}: DevSwitchersProps<R, P>) {
|
|
97
|
-
const bar = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS);
|
|
98
|
-
const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;
|
|
99
|
-
const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));
|
|
100
|
-
// 0.43 (keksdose): `enabled` may start false and turn true — an app's GET probe of the
|
|
101
|
-
// dev route — so the kept role is read again then, or "Back to {role}" is lost after a
|
|
102
|
-
// reload. Adjusted during render, React's pattern for state that follows a prop.
|
|
103
|
-
const [wasEnabled, setWasEnabled] = useState(enabled);
|
|
104
|
-
if (wasEnabled !== enabled) {
|
|
105
|
-
setWasEnabled(enabled);
|
|
106
|
-
if (enabled) setKept(readKey(storageKey));
|
|
107
|
-
}
|
|
108
|
-
if (!enabled) return null;
|
|
109
|
-
|
|
110
|
-
const original = (originalRole ?? kept ?? undefined) as R | undefined;
|
|
111
|
-
|
|
112
|
-
const pickRole = (next: R) => {
|
|
113
|
-
if (originalRole === undefined) {
|
|
114
|
-
if (next === original) {
|
|
115
|
-
// Back where the session began: nothing left to go back to.
|
|
116
|
-
writeKey(storageKey, null);
|
|
117
|
-
setKept(null);
|
|
118
|
-
} else if (kept === null && role !== undefined && next !== role) {
|
|
119
|
-
writeKey(storageKey, role);
|
|
120
|
-
setKept(role);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
onRole?.(next);
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
const roleMenu =
|
|
127
|
-
roles && roles.length > 0 && role !== undefined && onRole ? (
|
|
128
|
-
<TopBarActionMenu
|
|
129
|
-
icon={flask}
|
|
130
|
-
aria-label={`${bar.switchRole} · ${bar.role(role)}`}
|
|
131
|
-
entries={[
|
|
132
|
-
{ kind: "heading", key: "head-role", label: words.roleHeading },
|
|
133
|
-
...roles.map(
|
|
134
|
-
(r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),
|
|
135
|
-
),
|
|
136
|
-
...(original !== undefined && original !== role
|
|
137
|
-
? ([
|
|
138
|
-
{ kind: "divider", key: "sep-back" },
|
|
139
|
-
{
|
|
140
|
-
key: "back",
|
|
141
|
-
icon: <Undo2 className="size-4" />,
|
|
142
|
-
label: words.backTo(textOf(roleLabel(original), original)),
|
|
143
|
-
onSelect: () => pickRole(original),
|
|
144
|
-
},
|
|
145
|
-
] satisfies TopBarMenuEntry[])
|
|
146
|
-
: []),
|
|
147
|
-
]}
|
|
148
|
-
/>
|
|
149
|
-
) : null;
|
|
8
|
+
// Where it has always been imported from, until the wrapper goes in 0.52; the key and the
|
|
9
|
+
// "Back to" logic it serves are DevMenu's now.
|
|
10
|
+
export { DEV_ORIGINAL_ROLE_KEY } from "./dev-menu";
|
|
150
11
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<TopBarActionMenu
|
|
154
|
-
icon={flask}
|
|
155
|
-
aria-label={plan !== undefined ? `${words.switchPlan} · ${words.plan(plan)}` : words.switchPlan}
|
|
156
|
-
entries={[
|
|
157
|
-
{ kind: "heading", key: "head-plan", label: words.planHeading },
|
|
158
|
-
...plans.map(
|
|
159
|
-
(p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),
|
|
160
|
-
),
|
|
161
|
-
]}
|
|
162
|
-
>
|
|
163
|
-
{billingOff
|
|
164
|
-
? () => (
|
|
165
|
-
<li data-dev-billing-off="" className="mt-1 max-w-64 border-t border-subtle px-3 py-2 text-xs text-muted">
|
|
166
|
-
{words.billingOff}
|
|
167
|
-
</li>
|
|
168
|
-
)
|
|
169
|
-
: undefined}
|
|
170
|
-
</TopBarActionMenu>
|
|
171
|
-
) : null;
|
|
12
|
+
/** @deprecated 0.51 — use {@link DevMenuProps} (the same props); removed in 0.52. */
|
|
13
|
+
export type DevSwitchersProps<R extends string, P extends string> = Omit<DevMenuProps<R, P>, "sections" | "active">;
|
|
172
14
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
{planMenu}
|
|
177
|
-
</>
|
|
178
|
-
);
|
|
15
|
+
/** @deprecated 0.51 — use {@link DevMenu} (one flask; same props); removed in 0.52. */
|
|
16
|
+
export function DevSwitchers<R extends string, P extends string>(props: DevSwitchersProps<R, P>) {
|
|
17
|
+
return <DevMenu {...props} />;
|
|
179
18
|
}
|