@eifi1/ui-kit 0.41.0 → 0.43.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 +26 -7
- package/dist/admin/admin-area-labels.d.ts +59 -0
- package/dist/admin/admin-area-labels.js +35 -0
- package/dist/admin/admin-area-labels.js.map +1 -0
- package/dist/admin/admin-forbidden.d.ts +35 -0
- package/dist/admin/admin-forbidden.js +36 -0
- package/dist/admin/admin-forbidden.js.map +1 -0
- package/dist/admin/admin-overview.d.ts +110 -0
- package/dist/admin/admin-overview.js +92 -0
- package/dist/admin/admin-overview.js.map +1 -0
- package/dist/admin/admin-sections.d.ts +94 -0
- package/dist/admin/admin-sections.js +81 -0
- package/dist/admin/admin-sections.js.map +1 -0
- package/dist/admin/admin-tool-links.d.ts +48 -0
- package/dist/admin/admin-tool-links.js +31 -0
- package/dist/admin/admin-tool-links.js.map +1 -0
- package/dist/billing/billing-banner.d.ts +33 -2
- package/dist/billing/billing-banner.js +29 -3
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.d.ts +36 -0
- package/dist/billing/billing-labels.js +10 -0
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/billing-overview.d.ts +33 -0
- package/dist/billing/billing-overview.js +6 -0
- package/dist/billing/billing-overview.js.map +1 -1
- package/dist/billing/billing-standing.d.ts +8 -0
- package/dist/billing/billing-standing.js.map +1 -1
- package/dist/billing/cancel-request.d.ts +53 -0
- package/dist/billing/cancel-request.js +21 -0
- package/dist/billing/cancel-request.js.map +1 -0
- package/dist/billing/cancel-subscription.d.ts +46 -0
- package/dist/billing/cancel-subscription.js +57 -0
- package/dist/billing/cancel-subscription.js.map +1 -0
- package/dist/billing/plan-card.d.ts +45 -1
- package/dist/billing/plan-card.js +53 -2
- package/dist/billing/plan-card.js.map +1 -1
- package/dist/billing/plan-switch.d.ts +10 -1
- package/dist/billing/plan-switch.js +22 -13
- package/dist/billing/plan-switch.js.map +1 -1
- package/dist/billing/provider-page.d.ts +40 -0
- package/dist/billing/provider-page.js +33 -0
- package/dist/billing/provider-page.js.map +1 -0
- package/dist/billing/subscription-actions.d.ts +68 -4
- package/dist/billing/subscription-actions.js +65 -5
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/components/account-chips.d.ts +50 -2
- package/dist/components/account-chips.js +33 -0
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/empty-state.d.ts +2 -1
- package/dist/components/empty-state.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-menu.d.ts +7 -4
- package/dist/feedback/feedback-menu.js +4 -2
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/hooks/use-search-param-state.js +4 -1
- package/dist/hooks/use-search-param-state.js.map +1 -1
- package/dist/i18n/defaults.js +7 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +113 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +12 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +142 -1
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +110 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +109 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +110 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +110 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +109 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +34 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +24 -2
- package/dist/index.js +29 -1
- package/dist/index.js.map +1 -1
- package/dist/search/global-search.d.ts +3 -0
- package/dist/search/global-search.js +3 -1
- package/dist/search/global-search.js.map +1 -1
- package/dist/settings/settings-catalogue.d.ts +9 -1
- package/dist/settings/settings-catalogue.js +1 -1
- package/dist/settings/settings-catalogue.js.map +1 -1
- package/dist/settings/settings-layout.d.ts +7 -1
- package/dist/settings/settings-layout.js +35 -27
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/shell/account-menu-language.d.ts +46 -0
- package/dist/shell/account-menu-language.js +64 -0
- package/dist/shell/account-menu-language.js.map +1 -0
- package/dist/shell/admin-menu.d.ts +69 -0
- package/dist/shell/admin-menu.js +64 -0
- package/dist/shell/admin-menu.js.map +1 -0
- package/dist/shell/app-shell.d.ts +10 -0
- package/dist/shell/app-shell.js +22 -4
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/dev-switchers.d.ts +53 -0
- package/dist/shell/dev-switchers.js +107 -0
- package/dist/shell/dev-switchers.js.map +1 -0
- package/dist/shell/sync-status-indicator.d.ts +86 -0
- package/dist/shell/sync-status-indicator.js +170 -0
- package/dist/shell/sync-status-indicator.js.map +1 -0
- package/dist/shell/top-bar-actions.d.ts +49 -0
- package/dist/shell/top-bar-actions.js +31 -0
- package/dist/shell/top-bar-actions.js.map +1 -0
- package/dist/shell/top-bar-parts-labels.d.ts +90 -0
- package/dist/shell/top-bar-parts-labels.js +51 -0
- package/dist/shell/top-bar-parts-labels.js.map +1 -0
- package/dist/shell/topbar-action-menu.d.ts +13 -1
- package/dist/shell/topbar-action-menu.js +5 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +16 -3
- package/dist/shell/topbar-controls.js +5 -1
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell/tour-menu.d.ts +67 -0
- package/dist/shell/tour-menu.js +87 -0
- package/dist/shell/tour-menu.js.map +1 -0
- package/dist/shell.d.ts +16 -0
- package/dist/shell.js +21 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour-catalogue-labels.d.ts +44 -0
- package/dist/tour/tour-catalogue-labels.js +32 -0
- package/dist/tour/tour-catalogue-labels.js.map +1 -0
- package/dist/tour/tour-catalogue.d.ts +68 -0
- package/dist/tour/tour-catalogue.js +189 -0
- package/dist/tour/tour-catalogue.js.map +1 -0
- package/dist/tour/tour-info.d.ts +112 -0
- package/dist/tour/tour-info.js +36 -0
- package/dist/tour/tour-info.js.map +1 -0
- package/dist/tour/use-tour-progress.d.ts +61 -0
- package/dist/tour/use-tour-progress.js +116 -0
- package/dist/tour/use-tour-progress.js.map +1 -0
- package/dist/tour/use-tour-url-sync.d.ts +58 -0
- package/dist/tour/use-tour-url-sync.js +110 -0
- package/dist/tour/use-tour-url-sync.js.map +1 -0
- package/dist/tour.d.ts +8 -0
- package/dist/tour.js +18 -0
- package/dist/tour.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-area-labels.ts +91 -0
- package/src/admin/admin-forbidden.tsx +75 -0
- package/src/admin/admin-overview.tsx +217 -0
- package/src/admin/admin-sections.tsx +198 -0
- package/src/admin/admin-tool-links.tsx +84 -0
- package/src/billing/billing-banner.tsx +57 -6
- package/src/billing/billing-labels.ts +59 -0
- package/src/billing/billing-overview.ts +37 -0
- package/src/billing/billing-standing.ts +8 -0
- package/src/billing/cancel-request.ts +75 -0
- package/src/billing/cancel-subscription.tsx +102 -0
- package/src/billing/plan-card.tsx +97 -1
- package/src/billing/plan-switch.tsx +16 -0
- package/src/billing/provider-page.ts +75 -0
- package/src/billing/subscription-actions.tsx +155 -20
- package/src/components/account-chips.tsx +89 -2
- package/src/components/empty-state.tsx +2 -1
- package/src/feedback/feedback-menu.tsx +8 -3
- package/src/hooks/use-search-param-state.ts +6 -1
- package/src/i18n/defaults.ts +6 -0
- package/src/i18n/german.ts +127 -0
- package/src/i18n/kit-labels.tsx +12 -0
- package/src/i18n/locales/en.ts +32 -0
- package/src/i18n/locales/es.ts +121 -0
- package/src/i18n/locales/fr.ts +121 -0
- package/src/i18n/locales/hu.ts +119 -0
- package/src/i18n/locales/it.ts +122 -0
- package/src/i18n/locales/zh.ts +114 -0
- package/src/i18n/review.ts +33 -0
- package/src/index.ts +38 -2
- package/src/search/global-search.tsx +5 -0
- package/src/settings/settings-catalogue.ts +10 -2
- package/src/settings/settings-layout.tsx +58 -34
- package/src/shell/account-menu-language.tsx +116 -0
- package/src/shell/admin-menu.tsx +146 -0
- package/src/shell/app-shell.tsx +44 -3
- package/src/shell/dev-switchers.tsx +179 -0
- package/src/shell/sync-status-indicator.tsx +301 -0
- package/src/shell/top-bar-actions.tsx +79 -0
- package/src/shell/top-bar-parts-labels.ts +142 -0
- package/src/shell/topbar-action-menu.tsx +15 -2
- package/src/shell/topbar-controls.tsx +20 -3
- package/src/shell/tour-menu.tsx +179 -0
- package/src/shell.ts +27 -0
- package/src/tour/tour-catalogue-labels.ts +75 -0
- package/src/tour/tour-catalogue.tsx +316 -0
- package/src/tour/tour-info.ts +144 -0
- package/src/tour/use-tour-progress.ts +198 -0
- package/src/tour/use-tour-url-sync.ts +221 -0
- package/src/tour.ts +11 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Link } from "react-router";
|
|
2
|
+
import { ShieldCheck } from "lucide-react";
|
|
3
|
+
import { Chip } from "../components/chip";
|
|
4
|
+
import { MenuItem } from "../components/menu-item";
|
|
5
|
+
import type { LabelOverride } from "../i18n/kit-labels";
|
|
6
|
+
import { DEFAULT_ADMIN_AREA_LABELS, useAdminAreaLabels } from "../admin/admin-area-labels";
|
|
7
|
+
import type { AdminAreaLabels } from "../admin/admin-area-labels";
|
|
8
|
+
import type { TopBarMenuEntry } from "./topbar-action-menu";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The account menu's "Administration" row (0.42.0, docs/admin-harmonization.md §2.1, §6.6,
|
|
12
|
+
* §7): the way into the admin area everyone finds, in every app, for an admin only. Two
|
|
13
|
+
* forms of one row — {@link adminMenuEntry} for `TopBarActionMenu`'s `entries`, and
|
|
14
|
+
* {@link AdminMenuRow} for a menu built from `HoverMenu` children (Kurvenschmiede's).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The badge's text: nothing for none or 0, "99+" past 99. */
|
|
18
|
+
function badgeText(badge: number | null | undefined): string | null {
|
|
19
|
+
if (typeof badge !== "number" || !(badge > 0)) return null;
|
|
20
|
+
return badge > 99 ? "99+" : String(Math.floor(badge));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The waiting count at the row's end: the `solid` count pill of {@link Chip}, in the
|
|
25
|
+
* pending count's tone, with the number for the eye and `adminArea.waiting` for a reader
|
|
26
|
+
* ("Administration, 3 waiting"). `null` for no count.
|
|
27
|
+
*/
|
|
28
|
+
function WaitingBadge({ badge, labels }: { badge: number | null | undefined; labels: AdminAreaLabels }) {
|
|
29
|
+
const text = badgeText(badge);
|
|
30
|
+
if (text === null) return null;
|
|
31
|
+
return (
|
|
32
|
+
<Chip size="sm" variant="solid" tone="warning" data-slot="admin-badge">
|
|
33
|
+
<span aria-hidden>{text}</span>
|
|
34
|
+
{/* The comma parts it from the row's label in the name: "Administration, 3 waiting". */}
|
|
35
|
+
<span className="sr-only-fixed">, {labels.waiting(badge as number)}</span>
|
|
36
|
+
</Chip>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AdminMenuEntryOptions {
|
|
41
|
+
/** The admin area. Default `/admin`. */
|
|
42
|
+
href?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The waiting count (§2.4 decision 15, §6.3): only work that waits on this admin —
|
|
45
|
+
* `adminWaitingCount(attention, […])`. `0`, `null` or none shows no badge.
|
|
46
|
+
*/
|
|
47
|
+
badge?: number | null;
|
|
48
|
+
/**
|
|
49
|
+
* The resolved `adminArea` words — pass `useAdminAreaLabels()`, or the row is ENGLISH
|
|
50
|
+
* in every language: a plain function reads no provider (kastlan, 0.42 adoption). Builds
|
|
51
|
+
* no hook, so the entry can be made in the list of entries. {@link useAdminMenuEntry}
|
|
52
|
+
* (0.43) is the same entry with the provider's words read for you.
|
|
53
|
+
*/
|
|
54
|
+
labels?: AdminAreaLabels;
|
|
55
|
+
/** The entry's `key`. Default `"administration"`. */
|
|
56
|
+
key?: string;
|
|
57
|
+
/** The page already open: `aria-current` and a check mark. */
|
|
58
|
+
current?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The "Administration" {@link TopBarMenuEntry}: a link row with the `ShieldCheck` icon,
|
|
63
|
+
* the kit's word and the waiting count as its badge. Put it in the entries for an admin
|
|
64
|
+
* only — the menu closes on the way, as any link row:
|
|
65
|
+
*
|
|
66
|
+
* const labels = useAdminAreaLabels();
|
|
67
|
+
* entries={[…, ...(isAdmin ? [adminMenuEntry({ badge: waiting, labels })] : []), …]}
|
|
68
|
+
*
|
|
69
|
+
* A function, not a hook: it reads NO provider, so pass `labels` (`useAdminAreaLabels()`)
|
|
70
|
+
* or the row says "Administration" in every language — or call {@link useAdminMenuEntry}
|
|
71
|
+
* at the component's top.
|
|
72
|
+
*/
|
|
73
|
+
export function adminMenuEntry({
|
|
74
|
+
href = "/admin",
|
|
75
|
+
badge,
|
|
76
|
+
labels = DEFAULT_ADMIN_AREA_LABELS,
|
|
77
|
+
key = "administration",
|
|
78
|
+
current,
|
|
79
|
+
}: AdminMenuEntryOptions = {}): TopBarMenuEntry {
|
|
80
|
+
return {
|
|
81
|
+
kind: "link",
|
|
82
|
+
key,
|
|
83
|
+
to: href,
|
|
84
|
+
icon: <ShieldCheck className="size-4" aria-hidden />,
|
|
85
|
+
label: labels.title,
|
|
86
|
+
trailing: badgeText(badge) === null ? undefined : <WaitingBadge badge={badge} labels={labels} />,
|
|
87
|
+
current,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface UseAdminMenuEntryOptions extends Omit<AdminMenuEntryOptions, "labels"> {
|
|
92
|
+
/** Over the provider's `adminArea` words: prop > `<UiKitProvider labels={{ adminArea }}>`
|
|
93
|
+
* > English. */
|
|
94
|
+
labels?: LabelOverride<AdminAreaLabels>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* {@link adminMenuEntry} with the provider's words (0.43, kastlan): the hook reads
|
|
99
|
+
* `useAdminAreaLabels()` itself, so the row is in the app's language without a `labels`
|
|
100
|
+
* to remember. Call it at the component's top, then put it in the entries:
|
|
101
|
+
*
|
|
102
|
+
* const admin = useAdminMenuEntry({ badge: waiting });
|
|
103
|
+
* entries={[…, ...(isAdmin ? [admin] : []), …]}
|
|
104
|
+
*/
|
|
105
|
+
export function useAdminMenuEntry({ labels: labelsProp, ...options }: UseAdminMenuEntryOptions = {}): TopBarMenuEntry {
|
|
106
|
+
const labels = useAdminAreaLabels(labelsProp);
|
|
107
|
+
return adminMenuEntry({ ...options, labels });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface AdminMenuRowProps {
|
|
111
|
+
/** The admin area. Default `/admin`. */
|
|
112
|
+
href?: string;
|
|
113
|
+
/** As {@link AdminMenuEntryOptions.badge}. */
|
|
114
|
+
badge?: number | null;
|
|
115
|
+
/** Runs on the way — `HoverMenu`'s `close`. */
|
|
116
|
+
onNavigate?: () => void;
|
|
117
|
+
current?: boolean;
|
|
118
|
+
/** Prop > `<UiKitProvider labels={{ adminArea }}>` > English. */
|
|
119
|
+
labels?: LabelOverride<AdminAreaLabels>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The same row as {@link adminMenuEntry} for a menu drawn from `HoverMenu` children: the
|
|
124
|
+
* `<li>` holding the router-link {@link MenuItem} `TopBarActionMenu` draws for a link
|
|
125
|
+
* entry, so the two menus look alike.
|
|
126
|
+
*
|
|
127
|
+
* {(close) => <ul>… {isAdmin && <AdminMenuRow badge={waiting} onNavigate={close} />} …</ul>}
|
|
128
|
+
*/
|
|
129
|
+
export function AdminMenuRow({ href = "/admin", badge, onNavigate, current, labels: labelsProp }: AdminMenuRowProps) {
|
|
130
|
+
// The provider's words, as every kit part (0.43: through the namespace's own hook).
|
|
131
|
+
const labels = useAdminAreaLabels(labelsProp);
|
|
132
|
+
return (
|
|
133
|
+
<li>
|
|
134
|
+
<MenuItem
|
|
135
|
+
href={href}
|
|
136
|
+
renderLink={({ href: to, ...p }) => <Link to={to} {...p} />}
|
|
137
|
+
onClick={onNavigate ? () => onNavigate() : undefined}
|
|
138
|
+
leading={<ShieldCheck className="size-4" aria-hidden />}
|
|
139
|
+
trailing={badgeText(badge) === null ? undefined : <WaitingBadge badge={badge} labels={labels} />}
|
|
140
|
+
current={current}
|
|
141
|
+
>
|
|
142
|
+
{labels.title}
|
|
143
|
+
</MenuItem>
|
|
144
|
+
</li>
|
|
145
|
+
);
|
|
146
|
+
}
|
package/src/shell/app-shell.tsx
CHANGED
|
@@ -91,6 +91,16 @@ export type AppShellSidebarFooterItem =
|
|
|
91
91
|
hint?: string;
|
|
92
92
|
/** Leaves the app: a plain `<a target="_blank" rel="noopener noreferrer">`. */
|
|
93
93
|
external?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* 0.42.0 (docs/admin-harmonization.md §6.9): a count of what waits behind the link —
|
|
96
|
+
* kastlan's "Administration" with its To-triage count. A pill at the end of the
|
|
97
|
+
* expanded link, a corner dot on the collapsed icon; `0`, `null` or none shows
|
|
98
|
+
* nothing, and above 99 it reads "99+". The count joins the link's name, in
|
|
99
|
+
* `badgeLabel`'s words when given ("Administration, 3 waiting").
|
|
100
|
+
*/
|
|
101
|
+
badge?: number | null;
|
|
102
|
+
/** The badge's words for a reader — `adminArea.waiting(count)`. Default: the number. */
|
|
103
|
+
badgeLabel?: string;
|
|
94
104
|
}
|
|
95
105
|
| {
|
|
96
106
|
kind: "links";
|
|
@@ -589,6 +599,17 @@ const plainLink: KitLinkComponent = ({ children, ...p }) => <a {...p}>{children}
|
|
|
589
599
|
const FOOTER_ICON_LINK =
|
|
590
600
|
"flex min-h-8 items-center justify-center rounded-md text-muted hover:bg-hover hover:text-secondary";
|
|
591
601
|
|
|
602
|
+
/** A footer link's count as drawn: nothing for none, "99+" past 99. */
|
|
603
|
+
function footerBadgeText(badge: number | null | undefined): string | null {
|
|
604
|
+
if (typeof badge !== "number" || !(badge > 0)) return null;
|
|
605
|
+
return badge > 99 ? "99+" : String(Math.floor(badge));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The count's pill — the solid "count" look of `Chip` (warning, the pending count's
|
|
609
|
+
* tone, as `FloatingActionGroup`'s badge), sized for the footer's small text. */
|
|
610
|
+
const FOOTER_BADGE =
|
|
611
|
+
"inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-warning px-1 text-micro font-semibold leading-4 tabular-nums text-warning-contrast";
|
|
612
|
+
|
|
592
613
|
/** Calls the link renderer as a component, so a router link's hooks are its own. */
|
|
593
614
|
function RenderedLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {
|
|
594
615
|
return render(props);
|
|
@@ -627,12 +648,23 @@ function SidebarFooterItems({
|
|
|
627
648
|
const to = item.kind === "links" ? (item.to ?? item.links[0]?.to) : item.to;
|
|
628
649
|
if (!to) return null;
|
|
629
650
|
const external = item.kind === "links" ? item.links.find((l) => l.to === to)?.external : item.external;
|
|
651
|
+
const count = item.kind === "links" ? null : footerBadgeText(item.badge);
|
|
652
|
+
const spoken = count === null || item.kind === "links" ? null : (item.badgeLabel ?? count);
|
|
630
653
|
return (
|
|
631
654
|
<Tooltip key={item.key} label={item.label} side="end" portal className="block">
|
|
632
655
|
{linkTo(to, external, {
|
|
633
|
-
"aria-label": item.label,
|
|
634
|
-
className: FOOTER_ICON_LINK,
|
|
635
|
-
children:
|
|
656
|
+
"aria-label": spoken === null ? item.label : `${item.label}, ${spoken}`,
|
|
657
|
+
className: cn(FOOTER_ICON_LINK, count !== null && "relative"),
|
|
658
|
+
children: (
|
|
659
|
+
<>
|
|
660
|
+
<item.icon className="size-4" aria-hidden />
|
|
661
|
+
{count !== null && (
|
|
662
|
+
<span aria-hidden data-badge="" className={cn(FOOTER_BADGE, "absolute -top-0.5 end-0.5")}>
|
|
663
|
+
{count}
|
|
664
|
+
</span>
|
|
665
|
+
)}
|
|
666
|
+
</>
|
|
667
|
+
),
|
|
636
668
|
})}
|
|
637
669
|
</Tooltip>
|
|
638
670
|
);
|
|
@@ -655,6 +687,7 @@ function SidebarFooterItems({
|
|
|
655
687
|
</nav>
|
|
656
688
|
);
|
|
657
689
|
}
|
|
690
|
+
const count = footerBadgeText(item.badge);
|
|
658
691
|
const link = linkTo(item.to, item.external, {
|
|
659
692
|
className: cn(FOOTER_ICON_LINK, "gap-1.5 text-xs font-medium"),
|
|
660
693
|
children: (
|
|
@@ -663,6 +696,14 @@ function SidebarFooterItems({
|
|
|
663
696
|
{/* Wraps from Large up (text size §10.17, round 0.34 T1): the link is
|
|
664
697
|
`min-h-8`, so it grows a line rather than cut "Feedb…". */}
|
|
665
698
|
<span className="min-w-0 truncate-until-large">{item.text ?? item.label}</span>
|
|
699
|
+
{count !== null && (
|
|
700
|
+
<>
|
|
701
|
+
<span aria-hidden data-badge="" className={FOOTER_BADGE}>
|
|
702
|
+
{count}
|
|
703
|
+
</span>
|
|
704
|
+
<span className="sr-only">, {item.badgeLabel ?? count}</span>
|
|
705
|
+
</>
|
|
706
|
+
)}
|
|
666
707
|
</>
|
|
667
708
|
),
|
|
668
709
|
});
|
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
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;
|
|
150
|
+
|
|
151
|
+
const planMenu =
|
|
152
|
+
plans && plans.length > 0 && onPlan ? (
|
|
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;
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<>
|
|
175
|
+
{roleMenu}
|
|
176
|
+
{planMenu}
|
|
177
|
+
</>
|
|
178
|
+
);
|
|
179
|
+
}
|