@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.
Files changed (143) hide show
  1. package/README.md +14 -12
  2. package/dist/admin/user-roster.js +31 -12
  3. package/dist/admin/user-roster.js.map +1 -1
  4. package/dist/auth/verify-email.d.ts +4 -0
  5. package/dist/auth/verify-email.js.map +1 -1
  6. package/dist/components/alert-banner.d.ts +9 -0
  7. package/dist/components/alert-banner.js +9 -5
  8. package/dist/components/alert-banner.js.map +1 -1
  9. package/dist/components/autocomplete.js +13 -0
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/button-look.d.ts +4 -19
  12. package/dist/components/button-look.js +3 -30
  13. package/dist/components/button-look.js.map +1 -1
  14. package/dist/components/combobox.js +3 -1
  15. package/dist/components/combobox.js.map +1 -1
  16. package/dist/components/data-table-fit.js.map +1 -1
  17. package/dist/components/data-table-virtual.js +1 -0
  18. package/dist/components/data-table-virtual.js.map +1 -1
  19. package/dist/components/field-base.d.ts +31 -1
  20. package/dist/components/field-base.js +28 -0
  21. package/dist/components/field-base.js.map +1 -1
  22. package/dist/components/floating-panel.d.ts +6 -13
  23. package/dist/components/floating-panel.js +2 -9
  24. package/dist/components/floating-panel.js.map +1 -1
  25. package/dist/components/icon-button.js +3 -5
  26. package/dist/components/icon-button.js.map +1 -1
  27. package/dist/components/inline-entity-combobox.js +12 -0
  28. package/dist/components/inline-entity-combobox.js.map +1 -1
  29. package/dist/components/select.js +3 -2
  30. package/dist/components/select.js.map +1 -1
  31. package/dist/components/stat-tile.d.ts +8 -1
  32. package/dist/components/stat-tile.js +7 -3
  33. package/dist/components/stat-tile.js.map +1 -1
  34. package/dist/eslint/index.js +1 -1
  35. package/dist/i18n/german.js +2 -0
  36. package/dist/i18n/german.js.map +1 -1
  37. package/dist/i18n/kit-labels.d.ts +2 -2
  38. package/dist/i18n/kit-labels.js.map +1 -1
  39. package/dist/i18n/locales/en.js +2 -0
  40. package/dist/i18n/locales/en.js.map +2 -2
  41. package/dist/i18n/locales/es.js +2 -0
  42. package/dist/i18n/locales/es.js.map +1 -1
  43. package/dist/i18n/locales/fr.js +2 -0
  44. package/dist/i18n/locales/fr.js.map +1 -1
  45. package/dist/i18n/locales/hu.js +2 -0
  46. package/dist/i18n/locales/hu.js.map +1 -1
  47. package/dist/i18n/locales/it.js +2 -0
  48. package/dist/i18n/locales/it.js.map +1 -1
  49. package/dist/i18n/locales/zh.js +2 -0
  50. package/dist/i18n/locales/zh.js.map +1 -1
  51. package/dist/index.d.ts +3 -1
  52. package/dist/index.js +2 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/landing/page-seo.d.ts +19 -11
  55. package/dist/landing/page-seo.js +11 -7
  56. package/dist/landing/page-seo.js.map +1 -1
  57. package/dist/landing/public-page.d.ts +57 -0
  58. package/dist/landing/public-page.js +45 -0
  59. package/dist/landing/public-page.js.map +1 -0
  60. package/dist/lib/quiet-focus.d.ts +4 -0
  61. package/dist/lib/quiet-focus.js +19 -0
  62. package/dist/lib/quiet-focus.js.map +1 -0
  63. package/dist/lib/server-wake.d.ts +3 -2
  64. package/dist/lib/server-wake.js +19 -13
  65. package/dist/lib/server-wake.js.map +1 -1
  66. package/dist/shell/app-shell.d.ts +2 -15
  67. package/dist/shell/app-shell.js +7 -26
  68. package/dist/shell/app-shell.js.map +1 -1
  69. package/dist/shell/dev-menu.d.ts +98 -0
  70. package/dist/shell/dev-menu.js +133 -0
  71. package/dist/shell/dev-menu.js.map +1 -0
  72. package/dist/shell/dev-switchers.d.ts +6 -53
  73. package/dist/shell/dev-switchers.js +5 -100
  74. package/dist/shell/dev-switchers.js.map +1 -1
  75. package/dist/shell/top-bar-actions.d.ts +4 -1
  76. package/dist/shell/top-bar-actions.js.map +1 -1
  77. package/dist/shell/top-bar-parts-labels.d.ts +18 -9
  78. package/dist/shell/top-bar-parts-labels.js +2 -0
  79. package/dist/shell/top-bar-parts-labels.js.map +1 -1
  80. package/dist/shell/topbar-action-menu.d.ts +17 -2
  81. package/dist/shell/topbar-action-menu.js +3 -1
  82. package/dist/shell/topbar-action-menu.js.map +1 -1
  83. package/dist/shell.d.ts +3 -1
  84. package/dist/shell.js +3 -1
  85. package/dist/shell.js.map +1 -1
  86. package/dist/testing/index.d.ts +1 -0
  87. package/dist/testing/index.js +4 -0
  88. package/dist/testing/index.js.map +1 -1
  89. package/dist/testing/tour.d.ts +31 -0
  90. package/dist/testing/tour.js +12 -0
  91. package/dist/testing/tour.js.map +1 -0
  92. package/dist/theme/palette-presets.d.ts +0 -3
  93. package/dist/theme/palette-presets.js +0 -2
  94. package/dist/theme/palette-presets.js.map +1 -1
  95. package/dist/tour/tour-engine.d.ts +12 -0
  96. package/dist/tour/tour-engine.js +31 -0
  97. package/dist/tour/tour-engine.js.map +1 -0
  98. package/dist/tour/tour.d.ts +14 -3
  99. package/dist/tour/tour.js +60 -45
  100. package/dist/tour/tour.js.map +1 -1
  101. package/dist/tour/use-tour-url-sync.d.ts +5 -3
  102. package/dist/tour/use-tour-url-sync.js +21 -1
  103. package/dist/tour/use-tour-url-sync.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/admin/user-roster.tsx +30 -10
  106. package/src/auth/verify-email.tsx +4 -0
  107. package/src/components/alert-banner.tsx +37 -10
  108. package/src/components/autocomplete.tsx +18 -0
  109. package/src/components/button-look.ts +7 -57
  110. package/src/components/combobox.tsx +5 -1
  111. package/src/components/data-table-fit.ts +4 -1
  112. package/src/components/data-table-virtual.ts +14 -1
  113. package/src/components/field-base.tsx +53 -1
  114. package/src/components/floating-panel.tsx +6 -28
  115. package/src/components/icon-button.tsx +6 -8
  116. package/src/components/inline-entity-combobox.tsx +17 -0
  117. package/src/components/select.tsx +3 -6
  118. package/src/components/stat-tile.tsx +27 -9
  119. package/src/i18n/german.ts +2 -0
  120. package/src/i18n/kit-labels.tsx +2 -2
  121. package/src/i18n/locales/es.ts +2 -0
  122. package/src/i18n/locales/fr.ts +2 -0
  123. package/src/i18n/locales/hu.ts +2 -0
  124. package/src/i18n/locales/it.ts +2 -0
  125. package/src/i18n/locales/zh.ts +2 -0
  126. package/src/index.ts +4 -1
  127. package/src/landing/page-seo.ts +28 -16
  128. package/src/landing/public-page.tsx +106 -0
  129. package/src/lib/quiet-focus.ts +32 -0
  130. package/src/lib/server-wake.ts +41 -17
  131. package/src/shell/app-shell.tsx +11 -48
  132. package/src/shell/dev-menu.tsx +259 -0
  133. package/src/shell/dev-switchers.tsx +13 -174
  134. package/src/shell/top-bar-actions.tsx +4 -1
  135. package/src/shell/top-bar-parts-labels.ts +20 -9
  136. package/src/shell/topbar-action-menu.tsx +28 -2
  137. package/src/shell.ts +5 -1
  138. package/src/testing/index.ts +8 -0
  139. package/src/testing/tour.ts +41 -0
  140. package/src/theme/palette-presets.ts +0 -4
  141. package/src/tour/tour-engine.ts +41 -0
  142. package/src/tour/tour.tsx +64 -24
  143. 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
- /** `DevSwitchers` (role, then plan). */
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 `DevSwitchers`. A module
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 dev role
8
- * switcher's name and tooltip are `topBar.switchRole` / `topBar.role`, as `RoleSwitcher`'s.
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
- /** `DevSwitchers`' words (§2.7, §6.2, §6.3). */
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
- /** The heading of the role menu. */
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
- /** The plan switcher's name. */
85
+ /** @deprecated 0.51 — DevMenu's one flask is named by `menu`; removed in 0.52. */
77
86
  switchPlan: string;
78
- /** The plan switcher's tooltip, given the active 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 menu. */
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 menu's note while billing is off locally. */
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
- export { DevSwitchers, DEV_ORIGINAL_ROLE_KEY } from "./shell/dev-switchers";
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 {
@@ -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. `useTourUrlSync` passes it too
197
- * (under a provider). A two-argument launcher that reaches `start` its own way still works.
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`, handed to `launch` as its third argument (keksdose N4).
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!)).catch((error: unknown) =>
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.focus();
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
- control?.focus();
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. Without a provider it is a `start` that does nothing (a development build warns).
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: (id: string, options: { startIndex: number }, start: TourStart) => void | Promise<unknown>;
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. */