@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
|
@@ -20,7 +20,10 @@ export interface TopBarActionsProps {
|
|
|
20
20
|
assistant?: ReactNode;
|
|
21
21
|
/** `SyncStatusIndicator`. */
|
|
22
22
|
sync?: ReactNode;
|
|
23
|
-
/** `
|
|
23
|
+
/** `DevMenu` (0.51, docs/top-bar-harmonization.md §2.7.1): the one flask — the role,
|
|
24
|
+
* the plan, then the app's own dev sections. Fill it only behind
|
|
25
|
+
* `import.meta.env.DEV`, so a production build drops the branch. (0.42's
|
|
26
|
+
* `DevSwitchers`, deprecated, is a DevMenu with no sections.) */
|
|
24
27
|
dev?: ReactNode;
|
|
25
28
|
/** kastlan's platform button. */
|
|
26
29
|
platform?: ReactNode;
|
|
@@ -2,10 +2,11 @@ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
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 `
|
|
5
|
+
* top bar's shared parts — `TourMenu`, `SyncStatusIndicator` and `DevMenu` (0.42's
|
|
6
|
+
* `DevSwitchers`, whose words these still are, §2.7.1). A module
|
|
6
7
|
* 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
|
|
8
|
-
*
|
|
8
|
+
* sub-list (`AccountMenuLanguage`) names itself with `topBar.language`, and the role in the
|
|
9
|
+
* dev flask's name is `topBar.role`, as `RoleSwitcher`'s tooltip.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
/** `TourMenu`'s words (§2.4). */
|
|
@@ -69,19 +70,27 @@ export interface SyncStatusLabels {
|
|
|
69
70
|
saving: string;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* `DevMenu`'s words (§2.7, §2.7.1, §6.2, §6.3). Still named for 0.42's `DevSwitchers`: the
|
|
75
|
+
* namespace is the same `topBarParts.dev`, and an app's label tree keeps compiling.
|
|
76
|
+
*/
|
|
73
77
|
export interface DevSwitchersLabels {
|
|
74
|
-
/**
|
|
78
|
+
/** 0.51: the flask's name, and the menu's — the role and the plan follow it. */
|
|
79
|
+
menu: string;
|
|
80
|
+
/** 0.51: what the dot on the flask means while `active`, joined to its name — an
|
|
81
|
+
* override the developer could forget is on (a "today", a slow server). */
|
|
82
|
+
overridesOn: string;
|
|
83
|
+
/** The heading of the role group. */
|
|
75
84
|
roleHeading: string;
|
|
76
|
-
/**
|
|
85
|
+
/** @deprecated 0.51 — DevMenu's one flask is named by `menu`; removed in 0.52. */
|
|
77
86
|
switchPlan: string;
|
|
78
|
-
/** The plan
|
|
87
|
+
/** The plan in the flask's name, given the active plan. */
|
|
79
88
|
plan: (value: string) => string;
|
|
80
|
-
/** The heading of the plan
|
|
89
|
+
/** The heading of the plan group. */
|
|
81
90
|
planHeading: string;
|
|
82
91
|
/** The row back to the role the session started with. */
|
|
83
92
|
backTo: (role: string) => string;
|
|
84
|
-
/** The plan
|
|
93
|
+
/** The plan group's note while billing is off locally. */
|
|
85
94
|
billingOff: string;
|
|
86
95
|
}
|
|
87
96
|
|
|
@@ -127,6 +136,8 @@ export const DEFAULT_TOP_BAR_PARTS_LABELS: TopBarPartsLabels = {
|
|
|
127
136
|
saving: "Saving…",
|
|
128
137
|
},
|
|
129
138
|
dev: {
|
|
139
|
+
menu: "Dev tools",
|
|
140
|
+
overridesOn: "Overrides on",
|
|
130
141
|
roleHeading: "Dev · role",
|
|
131
142
|
switchPlan: "Switch plan",
|
|
132
143
|
plan: (value) => `Plan: ${value}`,
|
|
@@ -135,6 +135,16 @@ export interface TopBarMenuHeader {
|
|
|
135
135
|
const SECTION_CONTROLS = "a[href]:not([role]),button:not([role])";
|
|
136
136
|
const TABBABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]';
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* `scrollable`'s cap (0.51, DevMenu): the glass under the bar. Under `TopBar` the panel
|
|
140
|
+
* starts about 50px down — the `size-9` trigger centred in the `h-12` bar ends at 42px,
|
|
141
|
+
* and `HoverMenu` hangs the panel `pt-2` below it — so 4rem (64px) leaves some 14px clear
|
|
142
|
+
* under the last row. `dvh`, not `vh`: on a phone `100vh` is the viewport with the
|
|
143
|
+
* browser's toolbars put away, taller than the glass while they show.
|
|
144
|
+
* `overscroll-contain`, so a fling past the last row does not scroll the page behind.
|
|
145
|
+
*/
|
|
146
|
+
const SCROLLABLE_PANEL = "max-h-[calc(100dvh-4rem)] overflow-y-auto overscroll-contain";
|
|
147
|
+
|
|
138
148
|
/**
|
|
139
149
|
* A header or footer inside the menu panel.
|
|
140
150
|
*
|
|
@@ -195,7 +205,8 @@ function keepTabInsidePanel(e: ReactKeyboardEvent<HTMLUListElement>) {
|
|
|
195
205
|
* A long menu is grouped by topic with `{ kind: "heading" }` entries (0.23) — see
|
|
196
206
|
* {@link TopBarMenuEntry}. There is no separate phone layout: the panel is the same
|
|
197
207
|
* hover panel at 390px, kept inside the viewport by `HoverMenu`, and a heading wraps
|
|
198
|
-
* rather than widening it.
|
|
208
|
+
* rather than widening it. A menu that can grow taller than a phone's glass passes
|
|
209
|
+
* `scrollable` (0.51), since that clamp is sideways only.
|
|
199
210
|
*/
|
|
200
211
|
export function TopBarActionMenu({
|
|
201
212
|
icon,
|
|
@@ -213,6 +224,7 @@ export function TopBarActionMenu({
|
|
|
213
224
|
footerLabel,
|
|
214
225
|
align,
|
|
215
226
|
panelClassName,
|
|
227
|
+
scrollable = false,
|
|
216
228
|
triggerAttributes,
|
|
217
229
|
}: {
|
|
218
230
|
/** The trigger's face for the usual icon button. Ignored when `trigger` is given. */
|
|
@@ -282,6 +294,20 @@ export function TopBarActionMenu({
|
|
|
282
294
|
* direction (see `HoverMenu`, whose type this is). Default `end`. */
|
|
283
295
|
align?: HoverMenuProps["align"];
|
|
284
296
|
panelClassName?: string;
|
|
297
|
+
/**
|
|
298
|
+
* Cap the panel at the glass under the top bar and scroll the rows inside it (0.51,
|
|
299
|
+
* `DevMenu`). `HoverMenu` keeps a panel inside the viewport sideways only, so a tall
|
|
300
|
+
* one ran off the bottom of the screen: DevMenu's ~25 rows on a 390×844 phone, with
|
|
301
|
+
* the last ones out of reach — the page cannot scroll them up, the panel hangs from
|
|
302
|
+
* the sticky bar.
|
|
303
|
+
*
|
|
304
|
+
* Off by default: a menu that fits keeps its natural height and has no scroll
|
|
305
|
+
* container (`TourMenu` keeps its own `max-h-[70vh]`). The keyboard needs nothing
|
|
306
|
+
* extra: `HoverMenu`'s roving focus is a plain `focus()`, which the browser follows by
|
|
307
|
+
* scrolling the panel to the row, so ↓, ↑, Home and End reach a row below the cap. The
|
|
308
|
+
* header and the footer are inside the list and scroll with the rows.
|
|
309
|
+
*/
|
|
310
|
+
scrollable?: boolean;
|
|
285
311
|
/** `data-*` attributes for the trigger itself — a tour's anchor (`data-tour`), a test
|
|
286
312
|
* hook. The parts built on this menu (`TourMenu`) pass theirs through here. */
|
|
287
313
|
triggerAttributes?: { [key: `data-${string}`]: string | number | boolean | undefined };
|
|
@@ -389,7 +415,7 @@ export function TopBarActionMenu({
|
|
|
389
415
|
);
|
|
390
416
|
return (
|
|
391
417
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a Tab bubbling up from a row or footer link, see keepTabInsidePanel
|
|
392
|
-
<ul className={cn("py-1", panelClassName)} onKeyDown={keepTabInsidePanel}>
|
|
418
|
+
<ul className={cn("py-1", scrollable && SCROLLABLE_PANEL, panelClassName)} onKeyDown={keepTabInsidePanel}>
|
|
393
419
|
{header && (
|
|
394
420
|
<MenuSection className="mb-1 border-b border-subtle px-3 py-2">
|
|
395
421
|
<div className="truncate text-sm font-medium text-primary">{header.title}</div>
|
package/src/shell.ts
CHANGED
|
@@ -89,7 +89,11 @@ export { TourMenu } from "./shell/tour-menu";
|
|
|
89
89
|
export type { TourMenuProps, TourMenuTour } from "./shell/tour-menu";
|
|
90
90
|
export { SyncStatusIndicator, syncState } from "./shell/sync-status-indicator";
|
|
91
91
|
export type { SyncStatus, SyncState, SyncStatusScope, SyncStatusIndicatorProps } from "./shell/sync-status-indicator";
|
|
92
|
-
|
|
92
|
+
// 0.51 (§2.7.1, keksdose): one flask, the kit's Account section and the app's own dev
|
|
93
|
+
// sections; `DevSwitchers` is a deprecated wrapper over it until 0.52.
|
|
94
|
+
export { DevMenu, DEV_ORIGINAL_ROLE_KEY } from "./shell/dev-menu";
|
|
95
|
+
export type { DevMenuProps, DevMenuSection } from "./shell/dev-menu";
|
|
96
|
+
export { DevSwitchers } from "./shell/dev-switchers";
|
|
93
97
|
export type { DevSwitchersProps } from "./shell/dev-switchers";
|
|
94
98
|
export { DEFAULT_TOP_BAR_PARTS_LABELS, useTopBarPartsLabels } from "./shell/top-bar-parts-labels";
|
|
95
99
|
export type {
|
package/src/testing/index.ts
CHANGED
|
@@ -24,3 +24,11 @@ export {
|
|
|
24
24
|
type StubbedBoxSize,
|
|
25
25
|
type ViewportOptions,
|
|
26
26
|
} from "./viewport";
|
|
27
|
+
// 0.50.1 (keksdose N4): a valid `TourStepContext` for a test that calls a step's
|
|
28
|
+
// `beforeStep` or `when` itself — `beforeStep!(tourStepContext())`.
|
|
29
|
+
export {
|
|
30
|
+
tourStepContext,
|
|
31
|
+
type TestTourStepContext,
|
|
32
|
+
type TourStepContextOptions,
|
|
33
|
+
type TourStepVia,
|
|
34
|
+
} from "./tour";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.50.1 (keksdose N4): a `TourStepContext` for a test that calls a step's `beforeStep` or
|
|
3
|
+
* `when` itself. 0.50 gave both the context, so `beforeStep!()` is a type error;
|
|
4
|
+
* `beforeStep!(tourStepContext())` is the drop-in.
|
|
5
|
+
*
|
|
6
|
+
* Spelled out here rather than imported from the tour module, so `@eifi1/ui-kit/testing`
|
|
7
|
+
* still reaches no package (the tour module reaches React); the kit's own test pins that it
|
|
8
|
+
* is assignable to `TourStepContext`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How a step is entered: `TourStepContext["via"]`. */
|
|
12
|
+
export type TourStepVia = "start" | "at" | "next" | "back" | "goto";
|
|
13
|
+
|
|
14
|
+
/** The shape of `TourStepContext` (`@eifi1/ui-kit/tour`). */
|
|
15
|
+
export interface TestTourStepContext {
|
|
16
|
+
gate(key: string): boolean | undefined;
|
|
17
|
+
via: TourStepVia;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TourStepContextOptions {
|
|
21
|
+
/** How the step is entered. Default `"start"`. */
|
|
22
|
+
via?: TourStepVia;
|
|
23
|
+
/**
|
|
24
|
+
* The published gates, as the form would publish them: `{ "txn.transfer": true }`. A key
|
|
25
|
+
* left out answers `undefined` (nobody publishes it). Default: none.
|
|
26
|
+
*/
|
|
27
|
+
gates?: Readonly<Record<string, boolean | undefined>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A valid step context: `via: "start"` and no gates published, unless `options` say
|
|
32
|
+
* otherwise. `step.beforeStep!(tourStepContext())`, `step.when!(tourStepContext({ gates:
|
|
33
|
+
* { "txn.transfer": true } }))`, `tourStepContext({ via: "at" })` for a "Guide me" entry.
|
|
34
|
+
*/
|
|
35
|
+
export function tourStepContext(options: TourStepContextOptions = {}): TestTourStepContext {
|
|
36
|
+
const gates = options.gates ?? {};
|
|
37
|
+
return {
|
|
38
|
+
gate: (key) => (Object.prototype.hasOwnProperty.call(gates, key) ? gates[key] : undefined),
|
|
39
|
+
via: options.via ?? "start",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -211,10 +211,6 @@ export const PAUL_TOL_PRESET: PalettePreset = {
|
|
|
211
211
|
},
|
|
212
212
|
};
|
|
213
213
|
|
|
214
|
-
/** @deprecated 0.49 — use `DEFAULT_PRESET` (Imprint IS the default now); removed in 0.51.
|
|
215
|
-
* A persisted `"imprint"` id resolves to it through `presetById`'s fallback. */
|
|
216
|
-
export const IMPRINT_PRESET: PalettePreset = DEFAULT_PRESET;
|
|
217
|
-
|
|
218
214
|
// The full-theme switcher from #307 stayed retired (feedback #328) — the default
|
|
219
215
|
// is still the single source of truth for surfaces, brand and money (its five
|
|
220
216
|
// alternatives are gone, see the header). What IS selectable is
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.50.1 (keksdose N1): the tour engine a launcher is handed as its fourth argument — one
|
|
3
|
+
* object for the life of the provider (or of `useTourUrlSync`) whose fields always read the
|
|
4
|
+
* engine's LATEST render: `engine.active`, `engine.index` after an `await` are current, and
|
|
5
|
+
* `engine.stop()` mid-run stops the run that is up now. Internal: the type is `TourEngine`.
|
|
6
|
+
*
|
|
7
|
+
* `set` is called from a layout effect with each render's engine; `engine` reads it.
|
|
8
|
+
*/
|
|
9
|
+
export function liveEngine<T extends object>(
|
|
10
|
+
keys: readonly (keyof T & string)[],
|
|
11
|
+
initial: T | null = null,
|
|
12
|
+
): { engine: T; set: (current: T) => void } {
|
|
13
|
+
let current = initial;
|
|
14
|
+
const read = () => {
|
|
15
|
+
if (!current)
|
|
16
|
+
throw new Error(
|
|
17
|
+
"[ui-kit] The tour engine was read before its provider rendered.",
|
|
18
|
+
);
|
|
19
|
+
return current;
|
|
20
|
+
};
|
|
21
|
+
const engine = {} as T;
|
|
22
|
+
for (const key of keys) {
|
|
23
|
+
Object.defineProperty(engine, key, {
|
|
24
|
+
enumerable: true,
|
|
25
|
+
get: () => {
|
|
26
|
+
const value = read()[key];
|
|
27
|
+
// A method is looked up when it is CALLED, so a kept `engine.stop` is the latest too.
|
|
28
|
+
return typeof value === "function"
|
|
29
|
+
? (...args: unknown[]) =>
|
|
30
|
+
(read()[key] as (...a: unknown[]) => unknown)(...args)
|
|
31
|
+
: value;
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
engine,
|
|
37
|
+
set: (next) => {
|
|
38
|
+
current = next;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/tour/tour.tsx
CHANGED
|
@@ -20,11 +20,13 @@ import { useDismissLayer } from "../hooks/use-dismiss";
|
|
|
20
20
|
import { sameRect } from "../hooks/use-anchored-rect";
|
|
21
21
|
import { dirOf, horizontalStep } from "../lib/direction";
|
|
22
22
|
import { isComposing } from "../lib/ime";
|
|
23
|
+
import { focusQuietly } from "../lib/quiet-focus";
|
|
23
24
|
import type { Direction } from "../lib/direction";
|
|
24
25
|
import { DEFAULT_TOUR_LABELS } from "./tour-labels";
|
|
25
26
|
import type { TourInfo } from "./tour-info";
|
|
26
27
|
import { completeStored, rememberAccount, rememberMigrate, type UseTourProgressOptions } from "./use-tour-progress";
|
|
27
28
|
import { createGateRegistry, devWarnings, TourInternalContext, type GateRegistry } from "./tour-gates";
|
|
29
|
+
import { liveEngine } from "./tour-engine";
|
|
28
30
|
import { DOCK_MAX_WIDTH, dockEdge, dockStyle, readBand, scrollIntoFreeHalf, type DockEdge } from "./tour-dock";
|
|
29
31
|
|
|
30
32
|
export { useTourGate, useTourGateRelease } from "./tour-gates";
|
|
@@ -193,15 +195,42 @@ export type TourStart = TourContextValue["start"];
|
|
|
193
195
|
* `useTourUrlSync`.
|
|
194
196
|
*
|
|
195
197
|
* `start` is the provider's own `start` (keksdose N4), so the launcher needs no ref to a
|
|
196
|
-
* component below the provider and may be defined above it. `
|
|
197
|
-
*
|
|
198
|
+
* component below the provider and may be defined above it. `engine` (0.50.1, keksdose N1)
|
|
199
|
+
* is the rest of the engine — `stop`, `next`, `active`, … — read live, see {@link TourEngine}.
|
|
200
|
+
* `useTourUrlSync` passes both (under a provider). A launcher taking fewer arguments still
|
|
201
|
+
* works.
|
|
198
202
|
*/
|
|
199
203
|
export type TourLauncher = (
|
|
200
204
|
id: string,
|
|
201
205
|
opts: { startIndex?: number; at?: TourAt },
|
|
202
206
|
start: TourStart,
|
|
207
|
+
engine: TourEngine,
|
|
203
208
|
) => void | Promise<unknown>;
|
|
204
209
|
|
|
210
|
+
/**
|
|
211
|
+
* 0.50.1 (keksdose N1): the whole engine `useTour()` returns, handed to a launcher as its
|
|
212
|
+
* FOURTH argument by `TourProvider` and `useTourUrlSync`, so a tour built above the provider
|
|
213
|
+
* can also `stop`, `next` or read `active` with no `useTour()` registered from below. One
|
|
214
|
+
* object per provider whose fields read the latest render (`engine.active` after an
|
|
215
|
+
* `await` is current); its methods are the provider's own. `start` (the third argument) is
|
|
216
|
+
* `engine.start`.
|
|
217
|
+
*/
|
|
218
|
+
export type TourEngine = TourContextValue;
|
|
219
|
+
|
|
220
|
+
const ENGINE_KEYS = [
|
|
221
|
+
"active",
|
|
222
|
+
"activeId",
|
|
223
|
+
"start",
|
|
224
|
+
"stop",
|
|
225
|
+
"next",
|
|
226
|
+
"prev",
|
|
227
|
+
"goToStep",
|
|
228
|
+
"index",
|
|
229
|
+
"total",
|
|
230
|
+
"position",
|
|
231
|
+
"count",
|
|
232
|
+
] as const satisfies readonly (keyof TourContextValue)[];
|
|
233
|
+
|
|
205
234
|
interface StartOptions {
|
|
206
235
|
/** Stable id of the tour, surfaced as `activeId` so the app can mirror it to
|
|
207
236
|
* the URL (deep-link / evaluation tracking) and record completion. Defaults to
|
|
@@ -463,8 +492,11 @@ export function TourProvider({
|
|
|
463
492
|
},
|
|
464
493
|
[arrive, forward, backward],
|
|
465
494
|
);
|
|
466
|
-
// The provider's own `start
|
|
495
|
+
// The provider's own `start` and the live engine, handed to `launch` as its third and
|
|
496
|
+
// fourth arguments (keksdose N4, N1).
|
|
467
497
|
const ownStartRef = useRef<TourContextValue["start"] | null>(null);
|
|
498
|
+
const [live] = useState(() => liveEngine<TourEngine>(ENGINE_KEYS));
|
|
499
|
+
const engine = live.engine;
|
|
468
500
|
const start = useCallback(
|
|
469
501
|
(what: TourStep[] | string, opts?: StartOptions | { at?: TourAt }) => {
|
|
470
502
|
if (typeof what !== "string") {
|
|
@@ -476,11 +508,11 @@ export function TourProvider({
|
|
|
476
508
|
if (devWarnings()) console.warn(`[ui-kit] start("${what}") needs TourProvider's \`launch\` prop.`);
|
|
477
509
|
return;
|
|
478
510
|
}
|
|
479
|
-
void Promise.resolve(run(what, { at: opts?.at }, ownStartRef.current
|
|
511
|
+
void Promise.resolve(run(what, { at: opts?.at }, ownStartRef.current!, engine)).catch((error: unknown) =>
|
|
480
512
|
console.error(error),
|
|
481
513
|
);
|
|
482
514
|
},
|
|
483
|
-
[startSteps],
|
|
515
|
+
[startSteps, engine],
|
|
484
516
|
) as TourContextValue["start"];
|
|
485
517
|
useLayoutEffect(() => {
|
|
486
518
|
ownStartRef.current = start;
|
|
@@ -573,23 +605,27 @@ export function TourProvider({
|
|
|
573
605
|
});
|
|
574
606
|
}, [active, gates, arrive, forward, finish]);
|
|
575
607
|
|
|
608
|
+
const value: TourContextValue = {
|
|
609
|
+
active,
|
|
610
|
+
activeId,
|
|
611
|
+
start,
|
|
612
|
+
stop,
|
|
613
|
+
next,
|
|
614
|
+
prev,
|
|
615
|
+
goToStep,
|
|
616
|
+
index,
|
|
617
|
+
total,
|
|
618
|
+
position: active ? nav.position : 0,
|
|
619
|
+
count: active ? nav.count : 0,
|
|
620
|
+
};
|
|
621
|
+
// Read by the live engine (`launch`'s fourth argument) after this render commits.
|
|
622
|
+
useLayoutEffect(() => {
|
|
623
|
+
live.set(value);
|
|
624
|
+
});
|
|
625
|
+
|
|
576
626
|
return (
|
|
577
627
|
<TourInternalContext.Provider value={internals}>
|
|
578
|
-
<TourContext.Provider
|
|
579
|
-
value={{
|
|
580
|
-
active,
|
|
581
|
-
activeId,
|
|
582
|
-
start,
|
|
583
|
-
stop,
|
|
584
|
-
next,
|
|
585
|
-
prev,
|
|
586
|
-
goToStep,
|
|
587
|
-
index,
|
|
588
|
-
total,
|
|
589
|
-
position: active ? nav.position : 0,
|
|
590
|
-
count: active ? nav.count : 0,
|
|
591
|
-
}}
|
|
592
|
-
>
|
|
628
|
+
<TourContext.Provider value={value}>
|
|
593
629
|
{children}
|
|
594
630
|
{active && (
|
|
595
631
|
<TourOverlay
|
|
@@ -1104,11 +1140,14 @@ function TourOverlay({
|
|
|
1104
1140
|
if (!ready || !guided || !step.target) return;
|
|
1105
1141
|
const el = queryVisibleTarget(step.target);
|
|
1106
1142
|
const control = el ? firstFocusable(el) : null;
|
|
1143
|
+
// Described BEFORE it is focused, so a screen reader reads the step with the field's
|
|
1144
|
+
// name. Focused quietly (0.50.1, keksdose B1): a picker that opens on focus stays
|
|
1145
|
+
// closed, or its list would cover the card; ArrowDown, typing or a click opens it.
|
|
1146
|
+
const previous = control ? control.getAttribute("aria-describedby") : null;
|
|
1147
|
+
if (control) control.setAttribute("aria-describedby", previous ? `${previous} ${bodyId}` : bodyId);
|
|
1107
1148
|
if (coarsePointer() || !control) cardRef.current?.focus();
|
|
1108
|
-
else if (!el!.contains(document.activeElement)) control
|
|
1149
|
+
else if (!el!.contains(document.activeElement)) focusQuietly(control);
|
|
1109
1150
|
if (!control) return;
|
|
1110
|
-
const previous = control.getAttribute("aria-describedby");
|
|
1111
|
-
control.setAttribute("aria-describedby", previous ? `${previous} ${bodyId}` : bodyId);
|
|
1112
1151
|
return () => {
|
|
1113
1152
|
if (previous === null) control.removeAttribute("aria-describedby");
|
|
1114
1153
|
else control.setAttribute("aria-describedby", previous);
|
|
@@ -1238,7 +1277,8 @@ function TourOverlay({
|
|
|
1238
1277
|
const lockedNext = useCallback(() => {
|
|
1239
1278
|
const el = step.target ? queryVisibleTarget(step.target) : null;
|
|
1240
1279
|
const control = el ? firstFocusable(el) : null;
|
|
1241
|
-
|
|
1280
|
+
// Quietly, as on arrival: the hint is read with the card in view, not under a list.
|
|
1281
|
+
if (control) focusQuietly(control);
|
|
1242
1282
|
announce(gateHint);
|
|
1243
1283
|
}, [step.target, announce, gateHint]);
|
|
1244
1284
|
const tryNext = awaitsRelease ? NOOP : locked ? lockedNext : onNext;
|
|
@@ -2,7 +2,8 @@ import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState
|
|
|
2
2
|
import * as ReactRouter from "react-router";
|
|
3
3
|
import { useLocation, useNavigate, type Navigator } from "react-router";
|
|
4
4
|
import { useSearchParamsState } from "../hooks/use-search-param-state";
|
|
5
|
-
import { useTourOptional, type TourStart } from "./tour";
|
|
5
|
+
import { useTourOptional, type TourEngine, type TourStart } from "./tour";
|
|
6
|
+
import { liveEngine } from "./tour-engine";
|
|
6
7
|
import { devWarnings } from "./tour-gates";
|
|
7
8
|
|
|
8
9
|
/* ── useTourUrlSync ─────────────────────────────────────────────────────────── */
|
|
@@ -14,9 +15,16 @@ export interface UseTourUrlSyncOptions {
|
|
|
14
15
|
* toast) to `useTour().start`. An unknown id should do nothing. May return a promise:
|
|
15
16
|
* the parameters are kept until it settles. Under a `TourProvider` the third argument is
|
|
16
17
|
* that provider's `start` (0.50, as `TourProvider launch` gets it), so one launcher serves
|
|
17
|
-
* both
|
|
18
|
+
* both; the fourth is its whole engine, read live (0.50.1, `TourEngine`: `stop`, `next`,
|
|
19
|
+
* `active`, …). Without a provider `start` and the engine's methods do nothing (a
|
|
20
|
+
* development build warns) and the engine reads as idle.
|
|
18
21
|
*/
|
|
19
|
-
launch: (
|
|
22
|
+
launch: (
|
|
23
|
+
id: string,
|
|
24
|
+
options: { startIndex: number },
|
|
25
|
+
start: TourStart,
|
|
26
|
+
engine: TourEngine,
|
|
27
|
+
) => void | Promise<unknown>;
|
|
20
28
|
/**
|
|
21
29
|
* Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the
|
|
22
30
|
* caches a branching tour reads)? A deep link waits for it, keeping its parameters, so
|
|
@@ -124,10 +132,12 @@ export function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTo
|
|
|
124
132
|
|
|
125
133
|
const launchRef = useRef(launch);
|
|
126
134
|
const startRef = useRef<TourStart>(engine?.start ?? noProviderStart);
|
|
135
|
+
const [live] = useState(() => liveEngine<TourEngine>(ENGINE_KEYS, NO_PROVIDER_ENGINE));
|
|
127
136
|
const activeIdRef = useRef(activeId);
|
|
128
137
|
useLayoutEffect(() => {
|
|
129
138
|
launchRef.current = launch;
|
|
130
139
|
startRef.current = engine?.start ?? noProviderStart;
|
|
140
|
+
live.set(engine ?? NO_PROVIDER_ENGINE);
|
|
131
141
|
activeIdRef.current = active ? activeId : null;
|
|
132
142
|
});
|
|
133
143
|
/** The `tour` value last seen in the URL: a tour starts when it CHANGES to an id. */
|
|
@@ -153,7 +163,7 @@ export function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTo
|
|
|
153
163
|
pendingRef.current = null;
|
|
154
164
|
launchingRef.current = true;
|
|
155
165
|
void Promise.resolve()
|
|
156
|
-
.then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current))
|
|
166
|
+
.then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current, live.engine))
|
|
157
167
|
.catch(() => undefined)
|
|
158
168
|
.finally(() => {
|
|
159
169
|
launchingRef.current = false;
|
|
@@ -193,6 +203,25 @@ const noProviderStart: TourStart = () => {
|
|
|
193
203
|
if (devWarnings()) console.warn("[ui-kit] useTourUrlSync: the launcher's start needs a TourProvider above it.");
|
|
194
204
|
};
|
|
195
205
|
|
|
206
|
+
const noProviderCall = () => {
|
|
207
|
+
if (devWarnings()) console.warn("[ui-kit] useTourUrlSync: the launcher's engine needs a TourProvider above it.");
|
|
208
|
+
};
|
|
209
|
+
/** The engine for a launcher run with no `TourProvider` above `useTourUrlSync`: idle. */
|
|
210
|
+
const NO_PROVIDER_ENGINE: TourEngine = {
|
|
211
|
+
active: false,
|
|
212
|
+
activeId: null,
|
|
213
|
+
start: noProviderStart,
|
|
214
|
+
stop: noProviderCall,
|
|
215
|
+
next: noProviderCall,
|
|
216
|
+
prev: noProviderCall,
|
|
217
|
+
goToStep: noProviderCall,
|
|
218
|
+
index: 0,
|
|
219
|
+
total: 0,
|
|
220
|
+
position: 0,
|
|
221
|
+
count: 0,
|
|
222
|
+
};
|
|
223
|
+
const ENGINE_KEYS = Object.keys(NO_PROVIDER_ENGINE) as (keyof TourEngine)[];
|
|
224
|
+
|
|
196
225
|
/* ── useTourCatalogueUrlState ───────────────────────────────────────────────── */
|
|
197
226
|
|
|
198
227
|
/** The catalogue's state filter. */
|