@eifi1/ui-kit 0.40.1 → 0.42.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 +19 -6
- package/dist/admin/admin-area-labels.d.ts +55 -0
- package/dist/admin/admin-area-labels.js +33 -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 +93 -0
- package/dist/admin/admin-overview.js +69 -0
- package/dist/admin/admin-overview.js.map +1 -0
- package/dist/admin/admin-sections.d.ts +63 -0
- package/dist/admin/admin-sections.js +64 -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 +25 -2
- package/dist/billing/billing-banner.js +5 -2
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.d.ts +32 -0
- package/dist/billing/billing-labels.js +9 -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-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/subscription-actions.d.ts +37 -4
- package/dist/billing/subscription-actions.js +10 -3
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/components/account-chips.d.ts +42 -1
- 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/components/nav-pills.d.ts +2 -2
- package/dist/components/nav-pills.js.map +1 -1
- package/dist/components/tabs.d.ts +4 -32
- package/dist/components/tabs.js +30 -66
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/translation-review-parts.d.ts +2 -2
- package/dist/components/translation-review-parts.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-menu-labels.js +0 -2
- package/dist/feedback/feedback-menu-labels.js.map +1 -1
- package/dist/feedback/feedback-menu.d.ts +0 -6
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/i18n/defaults.js +5 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +79 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +9 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +93 -3
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +76 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +75 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +76 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +76 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +75 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +20 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +18 -2
- package/dist/index.js +23 -1
- package/dist/index.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 +46 -0
- package/dist/shell/admin-menu.js +60 -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 +102 -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 +84 -0
- package/dist/shell/top-bar-parts-labels.js +48 -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 +58 -0
- package/dist/shell/tour-menu.js +73 -0
- package/dist/shell/tour-menu.js.map +1 -0
- package/dist/shell.d.ts +14 -0
- package/dist/shell.js +19 -0
- package/dist/shell.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-area-labels.ts +85 -0
- package/src/admin/admin-forbidden.tsx +75 -0
- package/src/admin/admin-overview.tsx +166 -0
- package/src/admin/admin-sections.tsx +137 -0
- package/src/admin/admin-tool-links.tsx +84 -0
- package/src/billing/billing-banner.tsx +32 -4
- package/src/billing/billing-labels.ts +53 -0
- package/src/billing/billing-overview.ts +37 -0
- package/src/billing/billing-standing.ts +8 -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/subscription-actions.tsx +64 -15
- package/src/components/account-chips.tsx +81 -1
- package/src/components/empty-state.tsx +2 -1
- package/src/components/nav-pills.tsx +2 -2
- package/src/components/tabs.tsx +19 -110
- package/src/components/translation-review-parts.tsx +2 -2
- package/src/feedback/feedback-menu-labels.ts +0 -2
- package/src/feedback/feedback-menu.tsx +0 -6
- package/src/i18n/defaults.ts +4 -0
- package/src/i18n/german.ts +92 -2
- package/src/i18n/kit-labels.tsx +9 -0
- package/src/i18n/locales/en.ts +19 -0
- package/src/i18n/locales/es.ts +86 -2
- package/src/i18n/locales/fr.ts +86 -2
- package/src/i18n/locales/hu.ts +84 -2
- package/src/i18n/locales/it.ts +87 -2
- package/src/i18n/locales/zh.ts +79 -2
- package/src/i18n/review.ts +19 -0
- package/src/index.ts +30 -2
- package/src/settings/settings-layout.tsx +58 -34
- package/src/shell/account-menu-language.tsx +116 -0
- package/src/shell/admin-menu.tsx +117 -0
- package/src/shell/app-shell.tsx +44 -3
- package/src/shell/dev-switchers.tsx +171 -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 +133 -0
- package/src/shell/topbar-action-menu.tsx +15 -2
- package/src/shell/topbar-controls.tsx +20 -3
- package/src/shell/tour-menu.tsx +144 -0
- package/src/shell.ts +24 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ComponentType } from "react";
|
|
3
|
+
import { Check, Cloud, CloudAlert, CloudOff, RefreshCw, UploadCloud } from "lucide-react";
|
|
4
|
+
import type { LucideProps } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { IconButton } from "../components/icon-button";
|
|
7
|
+
import { Tooltip } from "../components/tooltip";
|
|
8
|
+
import { useBillingLabels } from "../billing/billing-labels";
|
|
9
|
+
import { useKitLocale, type LabelOverride } from "../i18n/kit-labels";
|
|
10
|
+
import { useTopBarPartsLabels, type SyncStatusLabels } from "./top-bar-parts-labels";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the indicator shows, fed by the app's sync engine (docs/top-bar-harmonization.md
|
|
14
|
+
* §2.6). Small on purpose: keksdose's sync-status store, kastlan's handover controller and
|
|
15
|
+
* Kurvenschmiede's online state + `useIsMutating` all fill it.
|
|
16
|
+
*/
|
|
17
|
+
export interface SyncStatus {
|
|
18
|
+
/** What the engine is doing. `offline` is the engine's own verdict; `online: false` is
|
|
19
|
+
* the browser's — either shows the offline state. */
|
|
20
|
+
phase: "idle" | "syncing" | "offline" | "error";
|
|
21
|
+
/** The browser's `navigator.onLine` (or the app's better guess). */
|
|
22
|
+
online: boolean;
|
|
23
|
+
/** Writes kept on this device and not yet sent. */
|
|
24
|
+
pending: number;
|
|
25
|
+
/** Writes this device cannot send at all (keksdose: no key for the budget). Ranked first. */
|
|
26
|
+
blocked?: number;
|
|
27
|
+
/** Writes the server refused as read-only and keeps until a plan (billing §12.5). */
|
|
28
|
+
waitingForPlan?: number;
|
|
29
|
+
/** When the last good round ended — epoch ms, an ISO string or a `Date`. */
|
|
30
|
+
lastSyncedAt?: number | string | Date | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The seven states, in their ranking: the first that holds wins. */
|
|
34
|
+
export type SyncState = "blocked" | "waitingForPlan" | "offline" | "error" | "syncing" | "pending" | "synced";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which of the seven states a {@link SyncStatus} is in — keksdose's ranking
|
|
38
|
+
* (sync-status-indicator.tsx): a write that cannot be sent at all ranks above everything,
|
|
39
|
+
* because waiting cannot help it; then one waiting for a plan; then offline; then a refused
|
|
40
|
+
* round; then a round under way; then writes waiting; else in sync. `syncing` also holds
|
|
41
|
+
* while a tap's own round runs (`refreshing`), before the engine publishes its phase.
|
|
42
|
+
*/
|
|
43
|
+
export function syncState(status: SyncStatus, refreshing = false): SyncState {
|
|
44
|
+
if ((status.blocked ?? 0) > 0) return "blocked";
|
|
45
|
+
if ((status.waitingForPlan ?? 0) > 0) return "waitingForPlan";
|
|
46
|
+
if (!status.online || status.phase === "offline") return "offline";
|
|
47
|
+
if (status.phase === "error") return "error";
|
|
48
|
+
if (status.phase === "syncing" || refreshing) return "syncing";
|
|
49
|
+
if (status.pending > 0) return "pending";
|
|
50
|
+
return "synced";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Tone = "danger" | "warning" | "info" | "success";
|
|
54
|
+
|
|
55
|
+
const LOOK: Record<SyncState, { Icon: ComponentType<LucideProps>; tone: Tone }> = {
|
|
56
|
+
// The one the user must act on: the kit's danger, where keksdose had rose.
|
|
57
|
+
blocked: { Icon: CloudAlert, tone: "danger" },
|
|
58
|
+
// Safe, and a payment sends them: amber, the kit's warning.
|
|
59
|
+
waitingForPlan: { Icon: CloudAlert, tone: "warning" },
|
|
60
|
+
offline: { Icon: CloudOff, tone: "warning" },
|
|
61
|
+
// Usually resolves by itself (a deploy, a read-only view): amber, not danger.
|
|
62
|
+
error: { Icon: CloudAlert, tone: "warning" },
|
|
63
|
+
syncing: { Icon: RefreshCw, tone: "info" },
|
|
64
|
+
pending: { Icon: UploadCloud, tone: "warning" },
|
|
65
|
+
synced: { Icon: Cloud, tone: "success" },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const TEXT_TONE: Record<Tone, string> = {
|
|
69
|
+
danger: "text-danger",
|
|
70
|
+
warning: "text-warning",
|
|
71
|
+
info: "text-info",
|
|
72
|
+
success: "text-success",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The scope words of an indicator that covers only part of the app (§6.10). */
|
|
76
|
+
export interface SyncStatusScope {
|
|
77
|
+
/** Put before every state in the tooltip: "Handovers on this device" →
|
|
78
|
+
* "Handovers on this device: 3 changes waiting to sync". */
|
|
79
|
+
name: string;
|
|
80
|
+
/** The whole offline sentence instead of the kit's, when offline means something
|
|
81
|
+
* narrower: "Offline: handovers keep working on this device; everything else needs a
|
|
82
|
+
* connection". */
|
|
83
|
+
offline?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SyncStatusIndicatorProps {
|
|
87
|
+
status: SyncStatus;
|
|
88
|
+
/**
|
|
89
|
+
* Run a round now (push, then pull, then re-read what is on screen). With it the
|
|
90
|
+
* indicator is a "sync now" button, disabled offline and mid-round but keeping its
|
|
91
|
+
* colour; a returned promise keeps the spin until it settles. Without it, a status only
|
|
92
|
+
* (§6.9).
|
|
93
|
+
*/
|
|
94
|
+
onSync?: () => void | Promise<unknown>;
|
|
95
|
+
/** Render nothing while in sync — kastlan and Kurvenschmiede show it only while offline,
|
|
96
|
+
* syncing or with changes waiting, held or refused (§2.6). */
|
|
97
|
+
hideWhenIdle?: boolean;
|
|
98
|
+
/** The app's words for its part of the app (§6.10). */
|
|
99
|
+
scope?: SyncStatusScope;
|
|
100
|
+
/**
|
|
101
|
+
* Where the app keeps a change before the server has it. `"device"` (the default): an
|
|
102
|
+
* offline-first app (keksdose, kastlan's handovers) — offline means "kept here, sent
|
|
103
|
+
* later". `"none"`: an online-only app that keeps nothing on the device (Kurvenschmiede,
|
|
104
|
+
* build review B1) — offline reads "changes can't be saved until you reconnect", a
|
|
105
|
+
* round reads "Saving…", and `pending`, `blocked` and `waitingForPlan` are ignored,
|
|
106
|
+
* since there is nowhere for a change to wait. "Up to date" when settled.
|
|
107
|
+
*/
|
|
108
|
+
storage?: "device" | "none";
|
|
109
|
+
/** Short words beside the cloud ("Offline", "Syncing…", "3 waiting") while not in sync. */
|
|
110
|
+
showLabel?: boolean;
|
|
111
|
+
/** Prop > `<UiKitProvider labels={{ topBarParts: { sync } }}>` > English. */
|
|
112
|
+
labels?: LabelOverride<SyncStatusLabels>;
|
|
113
|
+
/** The clock's language for "Synced at 14:05". Default: the provider's locale. */
|
|
114
|
+
locale?: string;
|
|
115
|
+
className?: string;
|
|
116
|
+
/** `data-*` attributes go on the trigger — a tour's anchor (`data-tour="sync-status"`). */
|
|
117
|
+
[key: `data-${string}`]: string | number | boolean | undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The `data-*` attributes of a rest object, for a trigger. */
|
|
121
|
+
function dataAttributes(rest: Record<string, unknown>): Record<string, unknown> {
|
|
122
|
+
return Object.fromEntries(Object.entries(rest).filter(([k]) => k.startsWith("data-")));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The top bar's sync status (docs/top-bar-harmonization.md §2.6), lifted from keksdose's
|
|
127
|
+
* `SyncStatusIndicator`: the seven states and their ranking ({@link syncState}), one cloud
|
|
128
|
+
* per state, a ✓ badge when in sync and a spin while syncing. With `onSync` it is also the
|
|
129
|
+
* "sync now" button, and its name says both what it is and what a tap does.
|
|
130
|
+
*
|
|
131
|
+
* Colours are the kit's roles — danger for a write that cannot be sent, warning for
|
|
132
|
+
* anything waiting, info while syncing, success when in sync — where keksdose had raw
|
|
133
|
+
* rose/amber/sky/emerald pairs.
|
|
134
|
+
*/
|
|
135
|
+
export function SyncStatusIndicator({
|
|
136
|
+
status,
|
|
137
|
+
onSync,
|
|
138
|
+
hideWhenIdle = false,
|
|
139
|
+
scope,
|
|
140
|
+
showLabel = false,
|
|
141
|
+
labels: labelsProp,
|
|
142
|
+
locale: localeProp,
|
|
143
|
+
className,
|
|
144
|
+
storage = "device",
|
|
145
|
+
...rest
|
|
146
|
+
}: SyncStatusIndicatorProps) {
|
|
147
|
+
const words = useTopBarPartsLabels(labelsProp ? { sync: labelsProp } : undefined).sync;
|
|
148
|
+
const billing = useBillingLabels();
|
|
149
|
+
const locale = useKitLocale(localeProp);
|
|
150
|
+
// The tap's own round, local rather than in the app's store: the store's phase belongs
|
|
151
|
+
// to the sync engine, and a second writer would race the round itself.
|
|
152
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
153
|
+
const mounted = useRef(true);
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
mounted.current = true;
|
|
156
|
+
return () => {
|
|
157
|
+
mounted.current = false;
|
|
158
|
+
};
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const onlineOnly = storage === "none";
|
|
162
|
+
// Online-only: nothing waits on the device, so only offline, error, saving and settled.
|
|
163
|
+
const state = syncState(
|
|
164
|
+
onlineOnly ? { ...status, pending: 0, blocked: 0, waitingForPlan: 0 } : status,
|
|
165
|
+
refreshing,
|
|
166
|
+
);
|
|
167
|
+
const data = dataAttributes(rest);
|
|
168
|
+
if (hideWhenIdle && state === "synced") return null;
|
|
169
|
+
|
|
170
|
+
const { Icon, tone } = LOOK[state];
|
|
171
|
+
const spin = state === "syncing";
|
|
172
|
+
let text: string;
|
|
173
|
+
switch (state) {
|
|
174
|
+
case "blocked":
|
|
175
|
+
text = words.blocked(status.blocked ?? 0);
|
|
176
|
+
break;
|
|
177
|
+
case "waitingForPlan":
|
|
178
|
+
text = billing.waitingChanges(status.waitingForPlan ?? 0);
|
|
179
|
+
break;
|
|
180
|
+
case "offline":
|
|
181
|
+
text = onlineOnly ? words.offlineUnsaved : words.offline;
|
|
182
|
+
break;
|
|
183
|
+
case "error":
|
|
184
|
+
text = words.refused;
|
|
185
|
+
break;
|
|
186
|
+
case "syncing":
|
|
187
|
+
text = onlineOnly ? words.saving : words.syncing;
|
|
188
|
+
break;
|
|
189
|
+
case "pending":
|
|
190
|
+
text = words.pending(status.pending);
|
|
191
|
+
break;
|
|
192
|
+
default:
|
|
193
|
+
text = !onlineOnly && status.lastSyncedAt
|
|
194
|
+
? words.syncedAt(
|
|
195
|
+
// A clock time in the UI's language, never the browser's (keksdose live #246).
|
|
196
|
+
new Date(status.lastSyncedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
|
|
197
|
+
)
|
|
198
|
+
: words.idle;
|
|
199
|
+
}
|
|
200
|
+
if (scope) {
|
|
201
|
+
text = state === "offline" && scope.offline ? scope.offline : words.scoped(scope.name, text);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const short =
|
|
205
|
+
state === "offline"
|
|
206
|
+
? words.offlineShort
|
|
207
|
+
: state === "syncing"
|
|
208
|
+
? onlineOnly
|
|
209
|
+
? words.saving
|
|
210
|
+
: words.syncingShort
|
|
211
|
+
: state === "error"
|
|
212
|
+
? words.refusedShort
|
|
213
|
+
: state === "pending" || state === "blocked" || state === "waitingForPlan"
|
|
214
|
+
? words.waitingShort(
|
|
215
|
+
state === "pending"
|
|
216
|
+
? status.pending
|
|
217
|
+
: state === "blocked"
|
|
218
|
+
? (status.blocked ?? 0)
|
|
219
|
+
: (status.waitingForPlan ?? 0),
|
|
220
|
+
)
|
|
221
|
+
: null;
|
|
222
|
+
|
|
223
|
+
const offline = state === "offline";
|
|
224
|
+
const canSync = !!onSync && status.online && status.phase !== "offline" && !spin;
|
|
225
|
+
const name = onSync
|
|
226
|
+
? canSync
|
|
227
|
+
? words.withHint(text, words.syncHint)
|
|
228
|
+
: offline
|
|
229
|
+
? words.withHint(text, words.offlineHint)
|
|
230
|
+
: text
|
|
231
|
+
: text;
|
|
232
|
+
const badge = state === "synced" ? <Check className="stroke-[3]" /> : undefined;
|
|
233
|
+
const glyph = <Icon aria-hidden className={spin ? "animate-spin" : undefined} />;
|
|
234
|
+
|
|
235
|
+
const sync = async () => {
|
|
236
|
+
if (!canSync || !onSync) return;
|
|
237
|
+
setRefreshing(true);
|
|
238
|
+
try {
|
|
239
|
+
await onSync();
|
|
240
|
+
} finally {
|
|
241
|
+
if (mounted.current) setRefreshing(false);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const control = onSync ? (
|
|
246
|
+
<IconButton
|
|
247
|
+
{...data}
|
|
248
|
+
data-sync-state={state}
|
|
249
|
+
size="2xs"
|
|
250
|
+
glyphSize={20}
|
|
251
|
+
shape="round"
|
|
252
|
+
// A status that is also an action: disabled (offline, mid-round) is exactly when
|
|
253
|
+
// the state matters most, so it keeps its colour.
|
|
254
|
+
disabledStyle="keep"
|
|
255
|
+
tone={tone}
|
|
256
|
+
quiet={false}
|
|
257
|
+
label={name}
|
|
258
|
+
labelVisible={false}
|
|
259
|
+
// Below: the bar is at the top edge, where a bubble above has no room.
|
|
260
|
+
tooltipSide="bottom"
|
|
261
|
+
onClick={() => void sync()}
|
|
262
|
+
disabled={!canSync}
|
|
263
|
+
// It announces itself as a status: a reader hears the state change.
|
|
264
|
+
aria-live="polite"
|
|
265
|
+
badge={badge}
|
|
266
|
+
>
|
|
267
|
+
{glyph}
|
|
268
|
+
</IconButton>
|
|
269
|
+
) : (
|
|
270
|
+
<Tooltip label={name} side="bottom">
|
|
271
|
+
<span
|
|
272
|
+
{...data}
|
|
273
|
+
role="status"
|
|
274
|
+
data-sync-state={state}
|
|
275
|
+
className={cn("relative inline-flex size-9 shrink-0 items-center justify-center [&>svg]:size-5", TEXT_TONE[tone])}
|
|
276
|
+
>
|
|
277
|
+
{glyph}
|
|
278
|
+
<span className="sr-only">{name}</span>
|
|
279
|
+
{badge && (
|
|
280
|
+
<span
|
|
281
|
+
aria-hidden
|
|
282
|
+
className="pointer-events-none absolute bottom-1.5 end-1.5 inline-flex rounded-full bg-surface [&_svg]:size-3"
|
|
283
|
+
>
|
|
284
|
+
{badge}
|
|
285
|
+
</span>
|
|
286
|
+
)}
|
|
287
|
+
</span>
|
|
288
|
+
</Tooltip>
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (!showLabel || !short) return className ? <span className={cn("inline-flex", className)}>{control}</span> : control;
|
|
292
|
+
return (
|
|
293
|
+
<span className={cn("inline-flex items-center gap-1", className)}>
|
|
294
|
+
{control}
|
|
295
|
+
{/* The eye's short words; the name above already says it in full. */}
|
|
296
|
+
<span aria-hidden className={cn("whitespace-nowrap text-xs font-medium", TEXT_TONE[tone])}>
|
|
297
|
+
{short}
|
|
298
|
+
</span>
|
|
299
|
+
</span>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Fragment } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The top bar's items by name (docs/top-bar-harmonization.md §2.1, §6.6). Each is the
|
|
6
|
+
* app's own node — a kit part or its own — and any may be left out.
|
|
7
|
+
*/
|
|
8
|
+
export interface TopBarActionsProps {
|
|
9
|
+
/** kastlan's `CompanySwitcher`: the context, first. */
|
|
10
|
+
companySwitcher?: ReactNode;
|
|
11
|
+
/** `GlobalSearch` (⌘K). */
|
|
12
|
+
search?: ReactNode;
|
|
13
|
+
/** `ThemeToggle`. */
|
|
14
|
+
theme?: ReactNode;
|
|
15
|
+
/** `FeedbackMenu`. */
|
|
16
|
+
feedback?: ReactNode;
|
|
17
|
+
/** `TourMenu`. */
|
|
18
|
+
tours?: ReactNode;
|
|
19
|
+
/** kastlan's assistant, beside the tours while it is on. */
|
|
20
|
+
assistant?: ReactNode;
|
|
21
|
+
/** `SyncStatusIndicator`. */
|
|
22
|
+
sync?: ReactNode;
|
|
23
|
+
/** `DevSwitchers` (role, then plan). */
|
|
24
|
+
dev?: ReactNode;
|
|
25
|
+
/** kastlan's platform button. */
|
|
26
|
+
platform?: ReactNode;
|
|
27
|
+
/** The account menu. When it is set the signed-out slots are not drawn. */
|
|
28
|
+
account?: ReactNode;
|
|
29
|
+
/** Signed out (§6.6): the bar's `LanguageMenu`, since there is no account menu to hold
|
|
30
|
+
* the language. Drawn only without `account`. */
|
|
31
|
+
language?: ReactNode;
|
|
32
|
+
/** Signed out (§6.6): the "Sign in" button, where the account menu would be. Drawn only
|
|
33
|
+
* without `account`. */
|
|
34
|
+
signIn?: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** §2.1's order, then the signed-out pair in the account menu's place. */
|
|
38
|
+
export const TOP_BAR_ACTION_ORDER = [
|
|
39
|
+
"companySwitcher",
|
|
40
|
+
"search",
|
|
41
|
+
"theme",
|
|
42
|
+
"feedback",
|
|
43
|
+
"tours",
|
|
44
|
+
"assistant",
|
|
45
|
+
"sync",
|
|
46
|
+
"dev",
|
|
47
|
+
"platform",
|
|
48
|
+
"account",
|
|
49
|
+
"language",
|
|
50
|
+
"signIn",
|
|
51
|
+
] as const satisfies readonly (keyof TopBarActionsProps)[];
|
|
52
|
+
|
|
53
|
+
const present = (node: ReactNode) => node !== undefined && node !== null && node !== false && node !== "";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The top bar's actions in the one order every app shares (§2.1): company switcher ·
|
|
57
|
+
* Search · Theme · Feedback · Tours (· Assistant) · Sync · dev role · dev plan · platform ·
|
|
58
|
+
* account menu — and, signed out, Language · Sign in where the account menu goes (§6.6).
|
|
59
|
+
* The app fills the slots it has; an empty slot is skipped, so it leaves no gap.
|
|
60
|
+
*
|
|
61
|
+
* It draws a fragment, no box of its own: hand it to `TopBar`'s `actions`, whose row
|
|
62
|
+
* spaces the items.
|
|
63
|
+
*
|
|
64
|
+
* ```tsx
|
|
65
|
+
* <TopBar brand={…} actions={<TopBarActions search={…} theme={…} tours={…} account={…} />} />
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function TopBarActions(props: TopBarActionsProps) {
|
|
69
|
+
const signedOut = !present(props.account);
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
{TOP_BAR_ACTION_ORDER.map((slot) => {
|
|
73
|
+
if ((slot === "language" || slot === "signIn") && !signedOut) return null;
|
|
74
|
+
const node = props[slot];
|
|
75
|
+
return present(node) ? <Fragment key={slot}>{node}</Fragment> : null;
|
|
76
|
+
})}
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `topBarParts` namespace (0.42, docs/top-bar-harmonization.md §3.1): the words of the
|
|
5
|
+
* top bar's shared parts — `TourMenu`, `SyncStatusIndicator` and `DevSwitchers`. A module
|
|
6
|
+
* of words with no component code, like every `*-labels.ts` (0.34, KS U8). The language
|
|
7
|
+
* sub-list (`AccountMenuLanguage`) names itself with `topBar.language`, and the dev role
|
|
8
|
+
* switcher's name and tooltip are `topBar.switchRole` / `topBar.role`, as `RoleSwitcher`'s.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** `TourMenu`'s words (§2.4). */
|
|
12
|
+
export interface TourMenuLabels {
|
|
13
|
+
/** The compass's name, and the menu's. */
|
|
14
|
+
menu: string;
|
|
15
|
+
/** The NEW dot on the compass, for a reader: it joins the trigger's name. */
|
|
16
|
+
newDot: string;
|
|
17
|
+
/** The heading over the tours that start on the open page. */
|
|
18
|
+
thisPage: string;
|
|
19
|
+
/** The heading over the untaken new tours. */
|
|
20
|
+
newTours: string;
|
|
21
|
+
/** The last row, the way to the app's tours page (only with `href`). */
|
|
22
|
+
allTours: string;
|
|
23
|
+
/** The chip on a new tour's row. */
|
|
24
|
+
badgeNew: string;
|
|
25
|
+
/** What the ✓ on a taken tour's row says to a reader. */
|
|
26
|
+
completed: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `SyncStatusIndicator`'s words (§2.6). The "waiting for a plan" state is
|
|
30
|
+
* `billing.waitingChanges`, the kit's words for it since billing §12.5. */
|
|
31
|
+
export interface SyncStatusLabels {
|
|
32
|
+
/** Writes this device cannot send (keksdose: no key for the budget). Ranked first. */
|
|
33
|
+
blocked: (count: number) => string;
|
|
34
|
+
offline: string;
|
|
35
|
+
/** The server refused the last round. */
|
|
36
|
+
refused: string;
|
|
37
|
+
syncing: string;
|
|
38
|
+
pending: (count: number) => string;
|
|
39
|
+
/** Settled, with the time of the last good round (already formatted). */
|
|
40
|
+
syncedAt: (time: string) => string;
|
|
41
|
+
/** Settled, before the first round. */
|
|
42
|
+
idle: string;
|
|
43
|
+
/** Joined to the state when a tap syncs: what the tap does. */
|
|
44
|
+
syncHint: string;
|
|
45
|
+
/** Joined to the state while offline, when a tap can do nothing. */
|
|
46
|
+
offlineHint: string;
|
|
47
|
+
/** The state and what a tap does, as one tooltip: "Offline — nothing to sync…". */
|
|
48
|
+
withHint: (state: string, hint: string) => string;
|
|
49
|
+
/** The state with the app's `scope` words before it: "Handovers on this device: …". */
|
|
50
|
+
scoped: (scope: string, state: string) => string;
|
|
51
|
+
/** The short words beside the cloud (`showLabel`): offline. */
|
|
52
|
+
offlineShort: string;
|
|
53
|
+
/** The short words while syncing. */
|
|
54
|
+
syncingShort: string;
|
|
55
|
+
/** The short words while changes wait (pending, held for a plan, or blocked). */
|
|
56
|
+
waitingShort: (count: number) => string;
|
|
57
|
+
/** The short words after a refused round. */
|
|
58
|
+
refusedShort: string;
|
|
59
|
+
/** Offline in an online-only app (`storage="none"`): nothing is kept on the device. */
|
|
60
|
+
offlineUnsaved: string;
|
|
61
|
+
/** A round under way in an online-only app — it saves, it doesn't sync. Also its short
|
|
62
|
+
* words beside the cloud. */
|
|
63
|
+
saving: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `DevSwitchers`' words (§2.7, §6.2, §6.3). */
|
|
67
|
+
export interface DevSwitchersLabels {
|
|
68
|
+
/** The heading of the role menu. */
|
|
69
|
+
roleHeading: string;
|
|
70
|
+
/** The plan switcher's name. */
|
|
71
|
+
switchPlan: string;
|
|
72
|
+
/** The plan switcher's tooltip, given the active plan. */
|
|
73
|
+
plan: (value: string) => string;
|
|
74
|
+
/** The heading of the plan menu. */
|
|
75
|
+
planHeading: string;
|
|
76
|
+
/** The row back to the role the session started with. */
|
|
77
|
+
backTo: (role: string) => string;
|
|
78
|
+
/** The plan menu's note while billing is off locally. */
|
|
79
|
+
billingOff: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TopBarPartsLabels {
|
|
83
|
+
tours: TourMenuLabels;
|
|
84
|
+
sync: SyncStatusLabels;
|
|
85
|
+
dev: DevSwitchersLabels;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const DEFAULT_TOP_BAR_PARTS_LABELS: TopBarPartsLabels = {
|
|
89
|
+
tours: {
|
|
90
|
+
menu: "Guided tours",
|
|
91
|
+
newDot: "New tours",
|
|
92
|
+
thisPage: "On this page",
|
|
93
|
+
newTours: "New tours",
|
|
94
|
+
allTours: "All tours…",
|
|
95
|
+
badgeNew: "New",
|
|
96
|
+
completed: "Completed",
|
|
97
|
+
},
|
|
98
|
+
sync: {
|
|
99
|
+
blocked: (count) =>
|
|
100
|
+
count === 1
|
|
101
|
+
? "1 change cannot be sent from this device"
|
|
102
|
+
: `${count} changes cannot be sent from this device`,
|
|
103
|
+
offline: "Offline — changes are saved on this device and will sync when you reconnect",
|
|
104
|
+
refused: "The server refused the last sync",
|
|
105
|
+
syncing: "Syncing…",
|
|
106
|
+
pending: (count) => (count === 1 ? "1 change waiting to sync" : `${count} changes waiting to sync`),
|
|
107
|
+
syncedAt: (time) => `Synced at ${time}`,
|
|
108
|
+
idle: "Up to date",
|
|
109
|
+
syncHint: "Tap to sync now",
|
|
110
|
+
offlineHint: "nothing to sync while offline",
|
|
111
|
+
withHint: (state, hint) => `${state} — ${hint}`,
|
|
112
|
+
scoped: (scope, state) => `${scope}: ${state}`,
|
|
113
|
+
offlineShort: "Offline",
|
|
114
|
+
syncingShort: "Syncing…",
|
|
115
|
+
waitingShort: (count) => `${count} waiting`,
|
|
116
|
+
refusedShort: "Not synced",
|
|
117
|
+
offlineUnsaved: "Offline — changes can't be saved until you reconnect",
|
|
118
|
+
saving: "Saving…",
|
|
119
|
+
},
|
|
120
|
+
dev: {
|
|
121
|
+
roleHeading: "Dev · role",
|
|
122
|
+
switchPlan: "Switch plan",
|
|
123
|
+
plan: (value) => `Plan: ${value}`,
|
|
124
|
+
planHeading: "Dev · plan",
|
|
125
|
+
backTo: (role) => `Back to ${role}`,
|
|
126
|
+
billingOff: "Billing is off locally: plans change nothing",
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** The `topBarParts` namespace, resolved — English, then the provider, then `labels`. */
|
|
131
|
+
export function useTopBarPartsLabels(labels?: LabelOverride<TopBarPartsLabels>): TopBarPartsLabels {
|
|
132
|
+
return useKitLabels("topBarParts", DEFAULT_TOP_BAR_PARTS_LABELS, labels);
|
|
133
|
+
}
|
|
@@ -206,11 +206,13 @@ export function TopBarActionMenu({
|
|
|
206
206
|
heading,
|
|
207
207
|
header,
|
|
208
208
|
entries,
|
|
209
|
+
leading,
|
|
209
210
|
children,
|
|
210
211
|
footer,
|
|
211
212
|
footerLabel,
|
|
212
213
|
align,
|
|
213
214
|
panelClassName,
|
|
215
|
+
triggerAttributes,
|
|
214
216
|
}: {
|
|
215
217
|
/** The trigger's face for the usual icon button. Ignored when `trigger` is given. */
|
|
216
218
|
icon?: ReactNode;
|
|
@@ -260,6 +262,13 @@ export function TopBarActionMenu({
|
|
|
260
262
|
* `close` shuts the menu.
|
|
261
263
|
*/
|
|
262
264
|
children?: (close: () => void) => ReactNode;
|
|
265
|
+
/**
|
|
266
|
+
* Rows like {@link children}, but BEFORE `entries`, under the header — the account
|
|
267
|
+
* menu's `AccountMenuLanguage`, which goes first after the app's context rows (§6.5)
|
|
268
|
+
* in a menu whose other rows are all entries (kastlan, build review B1). Render `<li>`s;
|
|
269
|
+
* `close` shuts the menu.
|
|
270
|
+
*/
|
|
271
|
+
leading?: (close: () => void) => ReactNode;
|
|
263
272
|
/**
|
|
264
273
|
* Free content below the rows, behind a rule — the legal links. Its links and
|
|
265
274
|
* buttons are NOT menu rows: the arrow keys skip them and Tab reaches them. Pass a
|
|
@@ -272,6 +281,9 @@ export function TopBarActionMenu({
|
|
|
272
281
|
* direction (see `HoverMenu`, whose type this is). Default `end`. */
|
|
273
282
|
align?: HoverMenuProps["align"];
|
|
274
283
|
panelClassName?: string;
|
|
284
|
+
/** `data-*` attributes for the trigger itself — a tour's anchor (`data-tour`), a test
|
|
285
|
+
* hook. The parts built on this menu (`TourMenu`) pass theirs through here. */
|
|
286
|
+
triggerAttributes?: { [key: `data-${string}`]: string | number | boolean | undefined };
|
|
275
287
|
}) {
|
|
276
288
|
// Names the groups' headings for `aria-labelledby`. Positional rather than from the
|
|
277
289
|
// entry's `key`, which is the caller's text and may hold a space — and a space in
|
|
@@ -286,11 +298,11 @@ export function TopBarActionMenu({
|
|
|
286
298
|
// kit's button. Same classes and face either way.
|
|
287
299
|
const control = (className: string, children: ReactNode, label?: string) =>
|
|
288
300
|
href ? (
|
|
289
|
-
<Link to={href} onClick={close} aria-label={label} className={className}>
|
|
301
|
+
<Link {...triggerAttributes} to={href} onClick={close} aria-label={label} className={className}>
|
|
290
302
|
{children}
|
|
291
303
|
</Link>
|
|
292
304
|
) : (
|
|
293
|
-
<button type="button" onClick={toggle} aria-label={label} className={className}>
|
|
305
|
+
<button {...triggerAttributes} type="button" onClick={toggle} aria-label={label} className={className}>
|
|
294
306
|
{children}
|
|
295
307
|
</button>
|
|
296
308
|
);
|
|
@@ -380,6 +392,7 @@ export function TopBarActionMenu({
|
|
|
380
392
|
{heading}
|
|
381
393
|
</li>
|
|
382
394
|
)}
|
|
395
|
+
{leading?.(close)}
|
|
383
396
|
{toBlocks(entries).map((block, i, blocks) => {
|
|
384
397
|
if (block.kind === "loose") {
|
|
385
398
|
const entry = block.entry;
|
|
@@ -25,16 +25,30 @@ function MenuHeading({ children }: { children: string }) {
|
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Sun/Moon toggle for the light/dark theme. Controlled: the app owns the mode, and the
|
|
30
|
+
* toggle keeps no storage key of its own (docs/top-bar-harmonization.md §6.4).
|
|
31
|
+
*
|
|
32
|
+
* `mode` is the theme ON SCREEN — the resolved one, so with a "system" preference it is
|
|
33
|
+
* whatever the OS gives. A tap asks for the opposite of that: `onChange("light")` while
|
|
34
|
+
* dark is shown, `onChange("dark")` while light is. The app writes it as its theme
|
|
35
|
+
* preference (an explicit light or dark), where Settings → Appearance keeps the three-way
|
|
36
|
+
* choice. `onToggle` is the older way to hear the tap, without the next mode; both fire.
|
|
37
|
+
*/
|
|
29
38
|
export function ThemeToggle({
|
|
30
39
|
mode,
|
|
31
40
|
onToggle,
|
|
41
|
+
onChange,
|
|
32
42
|
"aria-label": ariaLabel,
|
|
33
43
|
title,
|
|
34
44
|
className,
|
|
35
45
|
}: {
|
|
46
|
+
/** The resolved light/dark on screen (`useThemeStore`'s `mode`, not its preference). */
|
|
36
47
|
mode: ThemeMode;
|
|
37
|
-
|
|
48
|
+
/** Hears a tap. Kept for 0.41's callers; `onChange` says what to switch to. */
|
|
49
|
+
onToggle?: () => void;
|
|
50
|
+
/** 0.42: the mode to switch to — always the opposite of `mode`. */
|
|
51
|
+
onChange?: (next: ThemeMode) => void;
|
|
38
52
|
/** Default: `topBar.theme` from the {@link UiKitProvider}, else English. */
|
|
39
53
|
"aria-label"?: string;
|
|
40
54
|
title?: string;
|
|
@@ -48,7 +62,10 @@ export function ThemeToggle({
|
|
|
48
62
|
<Tooltip label={title} portal>
|
|
49
63
|
<button
|
|
50
64
|
type="button"
|
|
51
|
-
onClick={
|
|
65
|
+
onClick={() => {
|
|
66
|
+
onChange?.(mode === "dark" ? "light" : "dark");
|
|
67
|
+
onToggle?.();
|
|
68
|
+
}}
|
|
52
69
|
aria-label={labels.theme}
|
|
53
70
|
className={cn(TOPBAR_TRIGGER_CLASS, className)}
|
|
54
71
|
>
|