@eifi1/ui-kit 0.49.0 → 0.50.1

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 (166) hide show
  1. package/README.md +12 -8
  2. package/dist/admin/user-roster.d.ts +16 -10
  3. package/dist/admin/user-roster.js +7 -3
  4. package/dist/admin/user-roster.js.map +1 -1
  5. package/dist/auth/verify-email.d.ts +9 -1
  6. package/dist/auth/verify-email.js +3 -1
  7. package/dist/auth/verify-email.js.map +1 -1
  8. package/dist/components/amount-input.d.ts +2 -1
  9. package/dist/components/amount-input.js +3 -2
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/autocomplete.js +14 -0
  12. package/dist/components/autocomplete.js.map +1 -1
  13. package/dist/components/combobox-core.js +1 -0
  14. package/dist/components/combobox-core.js.map +1 -1
  15. package/dist/components/combobox-suggestions.js +1 -0
  16. package/dist/components/combobox-suggestions.js.map +1 -1
  17. package/dist/components/combobox.js +3 -1
  18. package/dist/components/combobox.js.map +1 -1
  19. package/dist/components/currency-select.d.ts +28 -2
  20. package/dist/components/currency-select.js +56 -10
  21. package/dist/components/currency-select.js.map +1 -1
  22. package/dist/components/data-table-mobile.d.ts +11 -2
  23. package/dist/components/data-table-mobile.js +62 -8
  24. package/dist/components/data-table-mobile.js.map +1 -1
  25. package/dist/components/data-table-row.d.ts +1 -1
  26. package/dist/components/data-table-row.js.map +1 -1
  27. package/dist/components/data-table.js +3 -1
  28. package/dist/components/data-table.js.map +1 -1
  29. package/dist/components/dropdown.js +1 -0
  30. package/dist/components/dropdown.js.map +1 -1
  31. package/dist/components/full-bleed-dialog.js +1 -0
  32. package/dist/components/full-bleed-dialog.js.map +1 -1
  33. package/dist/components/inline-entity-combobox.js +12 -0
  34. package/dist/components/inline-entity-combobox.js.map +1 -1
  35. package/dist/components/modal.js +1 -0
  36. package/dist/components/modal.js.map +1 -1
  37. package/dist/components/number-input.d.ts +2 -1
  38. package/dist/components/number-input.js +6 -2
  39. package/dist/components/number-input.js.map +1 -1
  40. package/dist/components/numpad-sheet.js +2 -0
  41. package/dist/components/numpad-sheet.js.map +1 -1
  42. package/dist/components/picker-sheet.js +2 -0
  43. package/dist/components/picker-sheet.js.map +1 -1
  44. package/dist/components/popover.js +1 -0
  45. package/dist/components/popover.js.map +1 -1
  46. package/dist/eslint/index.js +1 -1
  47. package/dist/i18n/defaults.js +2 -0
  48. package/dist/i18n/defaults.js.map +1 -1
  49. package/dist/i18n/german.js +14 -1
  50. package/dist/i18n/german.js.map +1 -1
  51. package/dist/i18n/kit-labels.d.ts +3 -0
  52. package/dist/i18n/kit-labels.js.map +1 -1
  53. package/dist/i18n/locales/en.js +16 -1
  54. package/dist/i18n/locales/en.js.map +3 -3
  55. package/dist/i18n/locales/es.js +14 -1
  56. package/dist/i18n/locales/es.js.map +1 -1
  57. package/dist/i18n/locales/fr.js +14 -1
  58. package/dist/i18n/locales/fr.js.map +1 -1
  59. package/dist/i18n/locales/hu.js +14 -1
  60. package/dist/i18n/locales/hu.js.map +1 -1
  61. package/dist/i18n/locales/it.js +14 -1
  62. package/dist/i18n/locales/it.js.map +1 -1
  63. package/dist/i18n/locales/zh.js +14 -1
  64. package/dist/i18n/locales/zh.js.map +1 -1
  65. package/dist/i18n/review.js +1 -0
  66. package/dist/i18n/review.js.map +1 -1
  67. package/dist/lib/data-attributes.d.ts +11 -0
  68. package/dist/lib/data-attributes.js +10 -0
  69. package/dist/lib/data-attributes.js.map +1 -0
  70. package/dist/lib/format.d.ts +1 -0
  71. package/dist/lib/format.js +2 -0
  72. package/dist/lib/format.js.map +1 -1
  73. package/dist/lib/minus.d.ts +10 -0
  74. package/dist/lib/minus.js.map +1 -1
  75. package/dist/lib/quiet-focus.d.ts +4 -0
  76. package/dist/lib/quiet-focus.js +19 -0
  77. package/dist/lib/quiet-focus.js.map +1 -0
  78. package/dist/testing/index.d.ts +1 -0
  79. package/dist/testing/index.js +4 -0
  80. package/dist/testing/index.js.map +1 -1
  81. package/dist/testing/react.d.ts +25 -0
  82. package/dist/testing/react.js +19 -0
  83. package/dist/testing/react.js.map +1 -0
  84. package/dist/testing/tour.d.ts +31 -0
  85. package/dist/testing/tour.js +12 -0
  86. package/dist/testing/tour.js.map +1 -0
  87. package/dist/tour/tour-dock.d.ts +42 -0
  88. package/dist/tour/tour-dock.js +80 -0
  89. package/dist/tour/tour-dock.js.map +1 -0
  90. package/dist/tour/tour-engine.d.ts +12 -0
  91. package/dist/tour/tour-engine.js +31 -0
  92. package/dist/tour/tour-engine.js.map +1 -0
  93. package/dist/tour/tour-gates.d.ts +71 -0
  94. package/dist/tour/tour-gates.js +114 -0
  95. package/dist/tour/tour-gates.js.map +1 -0
  96. package/dist/tour/tour-guide-button.d.ts +27 -0
  97. package/dist/tour/tour-guide-button.js +45 -0
  98. package/dist/tour/tour-guide-button.js.map +1 -0
  99. package/dist/tour/tour-guide-labels.d.ts +8 -0
  100. package/dist/tour/tour-guide-labels.js +9 -0
  101. package/dist/tour/tour-guide-labels.js.map +1 -0
  102. package/dist/tour/tour-labels.js +9 -1
  103. package/dist/tour/tour-labels.js.map +1 -1
  104. package/dist/tour/tour.d.ts +144 -9
  105. package/dist/tour/tour.js +598 -59
  106. package/dist/tour/tour.js.map +1 -1
  107. package/dist/tour/use-tour-url-sync.d.ts +7 -2
  108. package/dist/tour/use-tour-url-sync.js +27 -1
  109. package/dist/tour/use-tour-url-sync.js.map +1 -1
  110. package/dist/tour.d.ts +2 -0
  111. package/dist/tour.js +3 -0
  112. package/dist/tour.js.map +1 -1
  113. package/dist/wizard/stepper-nav.js +15 -2
  114. package/dist/wizard/stepper-nav.js.map +1 -1
  115. package/dist/wizard/types.d.ts +11 -0
  116. package/dist/wizard/types.js.map +1 -1
  117. package/dist/wizard/use-wizard.js +11 -5
  118. package/dist/wizard/use-wizard.js.map +1 -1
  119. package/package.json +9 -1
  120. package/src/admin/user-roster.tsx +22 -12
  121. package/src/auth/verify-email.tsx +10 -0
  122. package/src/components/amount-input.tsx +5 -4
  123. package/src/components/autocomplete.tsx +20 -0
  124. package/src/components/combobox-core.tsx +2 -0
  125. package/src/components/combobox-suggestions.tsx +2 -0
  126. package/src/components/combobox.tsx +5 -1
  127. package/src/components/currency-select.tsx +95 -6
  128. package/src/components/data-table-mobile.tsx +99 -8
  129. package/src/components/data-table-row.tsx +1 -0
  130. package/src/components/data-table.tsx +9 -1
  131. package/src/components/dropdown.tsx +2 -0
  132. package/src/components/full-bleed-dialog.tsx +3 -0
  133. package/src/components/inline-entity-combobox.tsx +17 -0
  134. package/src/components/modal.tsx +3 -0
  135. package/src/components/number-input.tsx +8 -3
  136. package/src/components/numpad-sheet.tsx +4 -0
  137. package/src/components/picker-sheet.tsx +4 -0
  138. package/src/components/popover.tsx +2 -0
  139. package/src/i18n/defaults.ts +2 -0
  140. package/src/i18n/german.ts +13 -0
  141. package/src/i18n/kit-labels.tsx +3 -0
  142. package/src/i18n/locales/es.ts +13 -0
  143. package/src/i18n/locales/fr.ts +13 -0
  144. package/src/i18n/locales/hu.ts +13 -0
  145. package/src/i18n/locales/it.ts +13 -0
  146. package/src/i18n/locales/zh.ts +13 -0
  147. package/src/i18n/review.ts +1 -0
  148. package/src/lib/data-attributes.ts +14 -0
  149. package/src/lib/format.ts +2 -0
  150. package/src/lib/minus.ts +10 -0
  151. package/src/lib/quiet-focus.ts +32 -0
  152. package/src/testing/index.ts +8 -0
  153. package/src/testing/react.ts +59 -0
  154. package/src/testing/tour.ts +41 -0
  155. package/src/tour/tour-dock.ts +132 -0
  156. package/src/tour/tour-engine.ts +41 -0
  157. package/src/tour/tour-gates.ts +195 -0
  158. package/src/tour/tour-guide-button.tsx +64 -0
  159. package/src/tour/tour-guide-labels.ts +15 -0
  160. package/src/tour/tour-labels.ts +8 -0
  161. package/src/tour/tour.tsx +989 -86
  162. package/src/tour/use-tour-url-sync.ts +43 -4
  163. package/src/tour.ts +3 -0
  164. package/src/wizard/stepper-nav.tsx +23 -3
  165. package/src/wizard/types.ts +12 -0
  166. package/src/wizard/use-wizard.ts +16 -9
package/src/tour/tour.tsx CHANGED
@@ -3,29 +3,41 @@ import {
3
3
  useCallback,
4
4
  useContext,
5
5
  useEffect,
6
+ useId,
6
7
  useLayoutEffect,
8
+ useMemo,
7
9
  useRef,
8
10
  useState,
11
+ useSyncExternalStore,
9
12
  } from "react";
10
- import type { CSSProperties, ReactNode } from "react";
13
+ import type { CSSProperties, ReactNode, RefObject } from "react";
11
14
  import { createPortal } from "react-dom";
12
15
  import { cn } from "../lib/cn";
13
16
  import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
14
17
  import { Button } from "../components/button";
15
18
  import { useFocusTrap } from "../hooks/use-focus-trap";
19
+ import { useDismissLayer } from "../hooks/use-dismiss";
16
20
  import { sameRect } from "../hooks/use-anchored-rect";
17
21
  import { dirOf, horizontalStep } from "../lib/direction";
22
+ import { isComposing } from "../lib/ime";
23
+ import { focusQuietly } from "../lib/quiet-focus";
18
24
  import type { Direction } from "../lib/direction";
19
25
  import { DEFAULT_TOUR_LABELS } from "./tour-labels";
20
26
  import type { TourInfo } from "./tour-info";
21
27
  import { completeStored, rememberAccount, rememberMigrate, type UseTourProgressOptions } from "./use-tour-progress";
28
+ import { createGateRegistry, devWarnings, TourInternalContext, type GateRegistry } from "./tour-gates";
29
+ import { liveEngine } from "./tour-engine";
30
+ import { DOCK_MAX_WIDTH, dockEdge, dockStyle, readBand, scrollIntoFreeHalf, type DockEdge } from "./tour-dock";
31
+
32
+ export { useTourGate, useTourGateRelease } from "./tour-gates";
33
+ export type { TourGateRelease } from "./tour-gates";
22
34
 
23
35
  /**
24
36
  * `start`/`end` follow the reading direction (`end` is the right side in LTR and the
25
37
  * left side in RTL) and are what a translated app wants. `left`/`right` stay physical
26
38
  * for the steps that genuinely mean a screen side.
27
39
  */
28
- export type TourPlacement = "top" | "right" | "bottom" | "left" | "start" | "end" | "center";
40
+ export type TourPlacement = "top" | "right" | "bottom" | "left" | "start" | "end" | "center" | "dock";
29
41
 
30
42
  export interface TourStep {
31
43
  /** CSS selector for the element to spotlight. Omit for a centered step
@@ -35,13 +47,22 @@ export interface TourStep {
35
47
  body: ReactNode;
36
48
  /** Extra footer content, e.g. a "load sample data" shortcut. */
37
49
  action?: ReactNode;
38
- /** Spotlight padding around the target, px (default 8). */
50
+ /** Spotlight padding around the target, px (default 8). On a field step the spot also
51
+ * takes in the lines the field's `aria-describedby` names when they sit within the
52
+ * ring's reach of it (a hint or warning line under the field, keksdose S1, §11.6). */
39
53
  padding?: number;
40
54
  /** Where the card sits relative to the target. Omit for auto placement
41
55
  * (below → above → centered). "center" ignores the target and centers on
42
56
  * screen. "start"/"end" are the logical sides and flip in RTL; "left"/"right"
43
57
  * are physical. An explicit side flips to the opposite side if it would
44
- * overflow, then clamps into the viewport. */
58
+ * overflow, then clamps into the viewport.
59
+ *
60
+ * `"dock"` (0.50, §11.7): below `sm` (640 px) the card is pinned full-width to the top
61
+ * or bottom edge of the usable band — the visual viewport less the keyboard and any
62
+ * `[data-tour-obstruction]` (the number pad) — on the edge away from the field, and the
63
+ * field's scroll container brings it into the other half. From `sm` up it is auto
64
+ * placement. On a phone a field step (`interactive`, `awaitInput`, `gate`, `awaitGate`)
65
+ * with a target and no `placement` docks by default (`TourProvider dockOnPhones`). */
45
66
  placement?: TourPlacement;
46
67
  /** Advance only when the user clicks the spotlighted target itself (not the
47
68
  * card's Next button). Requires `target`: the real control's own handler runs
@@ -76,9 +97,59 @@ export interface TourStep {
76
97
  * button) has nothing to compare: call `useTour().next()` from its `onChange`.
77
98
  */
78
99
  awaitInput?: boolean;
100
+ /**
101
+ * 0.50 (docs/tours-harmonization.md §11.1): Next stays locked until the gate `key` is
102
+ * satisfied — the form publishes it with `useTourGate(key, satisfied)` beside the field.
103
+ * Implies `interactive`. The locked Next is `aria-disabled` with {@link gateHint} under
104
+ * it; pressing it focuses the field. Once satisfied the tour does NOT advance by itself:
105
+ * Next, Enter in the field, or focus leaving the field to nowhere or to the card (the
106
+ * number pad's Done) moves on. A key nobody publishes is not enforced (the form closed
107
+ * under the tour): Next opens. A step may have a `gate` and no `target`.
108
+ */
109
+ gate?: string;
110
+ /** Overrides `labels.gateHint` for this step. */
111
+ gateHint?: string;
112
+ /**
113
+ * 0.50 (§11.4): wait for the app to RELEASE this key — `useTourGateRelease().release(key)`
114
+ * from the save's `onSuccess`. Implies `interactive`; usually it targets the Save button,
115
+ * whose hole stays click-through. Next is replaced by {@link awaitGateHint}, and a click
116
+ * does not advance: only `release` does. A release that arrives on an earlier step (Enter
117
+ * in the last field submitted the form) jumps past the first later step awaiting the key.
118
+ * `refuse(key, reason)` keeps the step and shows the reason, with a "Back to …" link to the
119
+ * first earlier field whose gate is now unsatisfied; the kit never jumps there itself.
120
+ * A viewer who cannot save gets `awaitGate: undefined`, so a plain Next.
121
+ */
122
+ awaitGate?: string;
123
+ /** Overrides `labels.awaitGateHint` for this step. */
124
+ awaitGateHint?: string;
125
+ /**
126
+ * 0.50 (§11.2): the field may stay empty. While the `gate` is unsatisfied the card
127
+ * offers "Leave empty" beside the locked Next, which moves on without touching the field
128
+ * (the kit never writes app values); the title carries an "Optional" chip. Not a skip:
129
+ * the run continues and can complete. Without a `gate`, only the chip.
130
+ */
131
+ optional?: boolean;
132
+ /**
133
+ * 0.50 (§11.3): shown only if this returns true on ARRIVAL (evaluated before
134
+ * `beforeStep`, so a skipped step never navigates). Next walks forward to the first step
135
+ * shown, Back to the nearest earlier one shown now. Branch facts are gates:
136
+ * `when: (c) => c.gate("txn.transfer") === true`. Keep it pure and cheap: the counter
137
+ * evaluates every step's `when` on each arrival. A throw counts as skipped.
138
+ */
139
+ when?: (ctx: TourStepContext) => boolean;
79
140
  /** Runs before the step is shown — e.g. navigate to a route, open a panel.
80
- * May be async; the step waits for it, then locates the target. */
81
- beforeStep?: () => void | Promise<void>;
141
+ * May be async; the step waits for it, then locates the target. Receives the
142
+ * {@link TourStepContext} (0.50); a step entered by "Guide me" sees `via: "at"` and
143
+ * should leave an open form alone. */
144
+ beforeStep?: (ctx: TourStepContext) => void | Promise<void>;
145
+ }
146
+
147
+ /** What a step's `when` and `beforeStep` may ask on arrival (0.50, §11.3). */
148
+ export interface TourStepContext {
149
+ /** The gate's current value: true/false, or undefined while nobody publishes it. */
150
+ gate(key: string): boolean | undefined;
151
+ /** How this step is being entered. */
152
+ via: "start" | "at" | "next" | "back" | "goto";
82
153
  }
83
154
 
84
155
  export interface TourLabels {
@@ -90,13 +161,76 @@ export interface TourLabels {
90
161
  awaitClickHint: string;
91
162
  /** Shown in place of the Next button on an `awaitInput` step (0.46). */
92
163
  awaitInputHint: string;
93
- /** Step counter, e.g. (2, 7) => "2 / 7". */
164
+ /** Step counter, e.g. (2, 7) => "2 / 7". Counts the steps the run shows (0.50). */
94
165
  step: (current: number, total: number) => string;
166
+ /** 0.50: an `optional` gate step's button while the field is empty. */
167
+ leaveEmpty: string;
168
+ /** 0.50: the chip beside an `optional` step's title. */
169
+ optional: string;
170
+ /** 0.50: under a `gate` step's locked Next (`TourStep.gateHint` overrides it). */
171
+ gateHint: string;
172
+ /** 0.50: announced (screen readers only) when a gate step's field is filled. */
173
+ gateReady: string;
174
+ /** 0.50: in place of Next on an `awaitGate` step (`TourStep.awaitGateHint` overrides it). */
175
+ awaitGateHint: string;
176
+ /** 0.50: the warning when the app `refuse`s an `awaitGate` step without a reason. */
177
+ awaitGateRefused: string;
178
+ /** 0.50: the link to an earlier step whose field was emptied, after a refused save. */
179
+ backToStep: (title: string) => string;
95
180
  }
96
181
 
97
182
  // Defined in ./tour-labels, which holds no component code (0.34, KS U8).
98
183
  export { DEFAULT_TOUR_LABELS };
99
184
 
185
+ /** 0.50 (§11.5): a step index, or the `data-tour` value of the step's anchor. */
186
+ export type TourAt = number | string;
187
+
188
+ /** `useTour().start`, both forms: what `TourProvider` hands its `launch` as the third argument. */
189
+ export type TourStart = TourContextValue["start"];
190
+
191
+ /**
192
+ * 0.50 (§11.5): the app's launcher — the shape `useTourUrlSync` already takes, plus `at`.
193
+ * It builds the steps (`prepare()`, `build()`, `tour: tourInfo(def)`) and passes `at` on to
194
+ * `start(steps, { …, at })`. Give the same function to `TourProvider launch` and to
195
+ * `useTourUrlSync`.
196
+ *
197
+ * `start` is the provider's own `start` (keksdose N4), so the launcher needs no ref to a
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.
202
+ */
203
+ export type TourLauncher = (
204
+ id: string,
205
+ opts: { startIndex?: number; at?: TourAt },
206
+ start: TourStart,
207
+ engine: TourEngine,
208
+ ) => void | Promise<unknown>;
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
+
100
234
  interface StartOptions {
101
235
  /** Stable id of the tour, surfaced as `activeId` so the app can mirror it to
102
236
  * the URL (deep-link / evaluation tracking) and record completion. Defaults to
@@ -108,6 +242,15 @@ interface StartOptions {
108
242
  tour?: TourInfo;
109
243
  /** Start at this step index instead of 0 (clamped) — for URL/deep-link resume. */
110
244
  startIndex?: number;
245
+ /**
246
+ * 0.50 (§11.5): start here AND make it the floor — earlier steps never run (nor their
247
+ * `beforeStep`), Back stops here, `position`/`count` count from here, and the finish
248
+ * records the completion as a full run does (Marcel, Q2). A number is a step index
249
+ * (clamped); a string is the first step whose `target` contains `data-tour="<at>"`
250
+ * verbatim (unknown: 0, with a development warning). The `at` step is entered with
251
+ * `via: "at"`. With `startIndex` too, `startIndex` picks the step and `at` the floor.
252
+ */
253
+ at?: TourAt;
111
254
  /** Fires synchronously when the tour starts (e.g. enable a mock interceptor). */
112
255
  onStart?: () => void;
113
256
  /** After the last step: runs after the provider's progress write and `onComplete`. */
@@ -131,8 +274,17 @@ interface TourContextValue {
131
274
  active: boolean;
132
275
  /** Id of the running tour (from {@link StartOptions.id}), else null. */
133
276
  activeId: string | null;
134
- /** Start a tour with the given steps. */
135
- start: (steps: TourStep[], opts?: StartOptions) => void;
277
+ /**
278
+ * Start a tour with the given steps — or, by id (0.50), through the provider's `launch`:
279
+ * `start("create-transaction", { at: "txn-entry-mode" })`. Without a `launch` the id form
280
+ * does nothing (a development build warns).
281
+ */
282
+ start: {
283
+ // The id form first: `Parameters<typeof start>` reads the LAST overload, which stays
284
+ // the 0.49 one.
285
+ (id: string, opts?: { at?: TourAt }): void;
286
+ (steps: TourStep[], opts?: StartOptions): void;
287
+ };
136
288
  /** End the current tour (no callbacks fire). */
137
289
  stop: () => void;
138
290
  /** Advance one step; past the last step it finishes the tour (fires onFinish). */
@@ -145,6 +297,10 @@ interface TourContextValue {
145
297
  index: number;
146
298
  /** Total steps (0 while inactive). */
147
299
  total: number;
300
+ /** 0.50: 1-based position among the steps this run shows, for "3 of 9"; 0 while inactive. */
301
+ position: number;
302
+ /** 0.50: how many steps this run shows, from its first (the `at` floor) to the end; 0 while inactive. */
303
+ count: number;
148
304
  }
149
305
 
150
306
  const TourContext = createContext<TourContextValue | null>(null);
@@ -179,11 +335,6 @@ export function useTourOptional(): TourContextValue | null {
179
335
  return useContext(TourContext);
180
336
  }
181
337
 
182
- /** The kit's dev switch (as number-input's): a development build, never under vitest. */
183
- function devWarnings(): boolean {
184
- return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
185
- }
186
-
187
338
  /**
188
339
  * Provides the guided-tour engine: a dimmed spotlight overlay + step card driven
189
340
  * by a list of {@link TourStep}s. Domain-free — the app supplies the steps (copy,
@@ -210,28 +361,47 @@ export function TourProvider({
210
361
  labels,
211
362
  progress,
212
363
  onComplete,
364
+ launch,
365
+ dockOnPhones = true,
213
366
  }: {
214
367
  children: ReactNode;
215
368
  labels?: LabelOverride<TourLabels>;
369
+ /**
370
+ * 0.50 (§11.5): lets `start(id)` and `<TourGuideButton>` start a tour by id. Usually the
371
+ * same function the app gives `useTourUrlSync`.
372
+ */
373
+ launch?: TourLauncher;
216
374
  /** The app's tour progress: the same options its `useTourProgress` readers use. */
217
375
  progress?: UseTourProgressOptions;
218
376
  /** A tour's last step was finished; see {@link TourCompletion}. */
219
377
  onComplete?: (tour: TourInfo | undefined, completion: TourCompletion) => void;
378
+ /**
379
+ * 0.50 (§11.7, Marcel's Q1): below `sm`, a field step with a target and no `placement`
380
+ * docks. Default `true`; `false` makes docking strictly opt-in (`placement: "dock"`).
381
+ */
382
+ dockOnPhones?: boolean;
220
383
  }) {
221
384
  // `labels` over the provider's `tour` over English. Resolved once here and handed
222
385
  // down: the step card is portalled, but context crosses portals anyway — this is
223
386
  // simply the one place that already owned the merge.
224
387
  const merged = useKitLabels("tour", DEFAULT_TOUR_LABELS, labels);
225
388
  const [steps, setSteps] = useState<TourStep[] | null>(null);
226
- const [index, setIndex] = useState(0);
389
+ // Where the run is: the raw index (what `?tourStep=` mirrors), how it got there, and
390
+ // its place among the steps shown (§11.3), computed once on arrival.
391
+ const [nav, setNav] = useState<Nav>(NO_NAV);
392
+ const index = nav.index;
393
+ // The app's last `refuse` on the step awaiting its key; any arrival clears it.
394
+ const [refusal, setRefusal] = useState<Refusal | null>(null);
227
395
  const [activeId, setActiveId] = useState<string | null>(null);
228
396
  const optsRef = useRef<StartOptions>({});
229
397
  // The latest props, read at the finish: a new `storageKey` or callback needs no remount.
230
398
  const progressRef = useRef(progress);
231
399
  const onCompleteRef = useRef(onComplete);
400
+ const launchRef = useRef(launch);
232
401
  useLayoutEffect(() => {
233
402
  progressRef.current = progress;
234
403
  onCompleteRef.current = onComplete;
404
+ launchRef.current = launch;
235
405
  });
236
406
  // The provider's migrate becomes the key's, for readers that pass none (keksdose B-2).
237
407
  const storageKey = progress?.storageKey;
@@ -253,16 +423,100 @@ export function TourProvider({
253
423
  stepsRef.current = steps;
254
424
  indexRef.current = index;
255
425
  }, [steps, index]);
426
+ /** The run's first step: earlier steps never run, Back stops here (the `at` floor). */
427
+ const floorRef = useRef(0);
256
428
  const active = !!steps && steps.length > 0;
257
429
  const total = steps?.length ?? 0;
430
+ // The gate registry (§11.1): one per provider, read by the overlay while a tour runs.
431
+ const [gates] = useState(createGateRegistry);
432
+ useLayoutEffect(() => {
433
+ gates.setActive(active);
434
+ }, [gates, active]);
435
+ const canLaunch = !!launch;
436
+ const internals = useMemo(() => ({ gates, canLaunch }), [gates, canLaunch]);
258
437
 
259
- const start = useCallback((s: TourStep[], opts?: StartOptions) => {
260
- optsRef.current = opts ?? {};
261
- optsRef.current.onStart?.();
262
- setIndex(Math.min(Math.max(0, opts?.startIndex ?? 0), Math.max(0, s.length - 1)));
263
- setActiveId(opts?.id ?? opts?.tour?.id ?? null);
264
- setSteps(s);
265
- }, []);
438
+ /** Is step `i` shown, asked now (§11.3)? */
439
+ const shown = useCallback(
440
+ (s: TourStep[], i: number, via: TourStepContext["via"]) => isShown(s[i], stepContext(gates, via)),
441
+ [gates],
442
+ );
443
+ /** Arrive at `i`: the counter is recomputed from every step's `when`, against the gates now. */
444
+ const arrive = useCallback(
445
+ (s: TourStep[], i: number, via: TourStepContext["via"]) => {
446
+ const ctx = stepContext(gates, via);
447
+ let position = 0;
448
+ let count = 0;
449
+ for (let j = floorRef.current; j < s.length; j++) {
450
+ if (j !== i && !isShown(s[j], ctx)) continue;
451
+ count++;
452
+ if (j <= i) position++;
453
+ }
454
+ indexRef.current = i;
455
+ setRefusal(null);
456
+ setNav({ index: i, floor: floorRef.current, via, position, count });
457
+ },
458
+ [gates],
459
+ );
460
+ /** The first step shown at or after `from`, or -1. */
461
+ const forward = useCallback(
462
+ (s: TourStep[], from: number, via: TourStepContext["via"]) => {
463
+ for (let j = Math.max(from, floorRef.current); j < s.length; j++) if (shown(s, j, via)) return j;
464
+ return -1;
465
+ },
466
+ [shown],
467
+ );
468
+ /** The nearest step shown at or before `from`, not below the floor, or -1. */
469
+ const backward = useCallback(
470
+ (s: TourStep[], from: number, via: TourStepContext["via"]) => {
471
+ for (let j = Math.min(from, s.length - 1); j >= floorRef.current; j--) if (shown(s, j, via)) return j;
472
+ return -1;
473
+ },
474
+ [shown],
475
+ );
476
+
477
+ const startSteps = useCallback(
478
+ (s: TourStep[], opts?: StartOptions) => {
479
+ optsRef.current = opts ?? {};
480
+ optsRef.current.onStart?.();
481
+ const last = Math.max(0, s.length - 1);
482
+ floorRef.current = opts?.at === undefined ? 0 : resolveAt(s, opts.at);
483
+ const via: TourStepContext["via"] = opts?.at === undefined ? "start" : "at";
484
+ const wanted = Math.min(Math.max(floorRef.current, opts?.startIndex ?? floorRef.current), last);
485
+ let to = forward(s, wanted, via);
486
+ if (to < 0) to = backward(s, wanted, via);
487
+ // Not one step of the run is shown: there is nothing to tour.
488
+ if (to < 0) return;
489
+ arrive(s, to, via);
490
+ setActiveId(opts?.id ?? opts?.tour?.id ?? null);
491
+ setSteps(s);
492
+ },
493
+ [arrive, forward, backward],
494
+ );
495
+ // The provider's own `start` and the live engine, handed to `launch` as its third and
496
+ // fourth arguments (keksdose N4, N1).
497
+ const ownStartRef = useRef<TourContextValue["start"] | null>(null);
498
+ const [live] = useState(() => liveEngine<TourEngine>(ENGINE_KEYS));
499
+ const engine = live.engine;
500
+ const start = useCallback(
501
+ (what: TourStep[] | string, opts?: StartOptions | { at?: TourAt }) => {
502
+ if (typeof what !== "string") {
503
+ startSteps(what, opts as StartOptions | undefined);
504
+ return;
505
+ }
506
+ const run = launchRef.current;
507
+ if (!run) {
508
+ if (devWarnings()) console.warn(`[ui-kit] start("${what}") needs TourProvider's \`launch\` prop.`);
509
+ return;
510
+ }
511
+ void Promise.resolve(run(what, { at: opts?.at }, ownStartRef.current!, engine)).catch((error: unknown) =>
512
+ console.error(error),
513
+ );
514
+ },
515
+ [startSteps, engine],
516
+ ) as TourContextValue["start"];
517
+ useLayoutEffect(() => {
518
+ ownStartRef.current = start;
519
+ }, [start]);
266
520
  const stop = useCallback(() => {
267
521
  setSteps(null);
268
522
  setActiveId(null);
@@ -301,38 +555,157 @@ export function TourProvider({
301
555
  setActiveId(null);
302
556
  }, []);
303
557
 
304
- const goToStep = useCallback((i: number) => {
305
- const len = stepsRef.current?.length ?? 0;
306
- if (len === 0) return;
307
- setIndex(Math.min(Math.max(0, i), len - 1));
308
- }, []);
558
+ // A step whose `when` is false is passed over: `goToStep` lands on the next step shown
559
+ // after it, failing that the previous one; Next past the last shown step finishes.
560
+ const goToStep = useCallback(
561
+ (i: number) => {
562
+ const s = stepsRef.current;
563
+ if (!s || s.length === 0) return;
564
+ const wanted = Math.min(Math.max(floorRef.current, i), s.length - 1);
565
+ let to = forward(s, wanted, "goto");
566
+ if (to < 0) to = backward(s, wanted, "goto");
567
+ if (to >= 0) arrive(s, to, "goto");
568
+ },
569
+ [arrive, forward, backward],
570
+ );
309
571
  const next = useCallback(() => {
310
- const len = stepsRef.current?.length ?? 0;
311
- if (len === 0) return;
312
- if (indexRef.current >= len - 1) finish();
313
- else setIndex(indexRef.current + 1);
314
- }, [finish]);
572
+ const s = stepsRef.current;
573
+ if (!s || s.length === 0) return;
574
+ const to = forward(s, indexRef.current + 1, "next");
575
+ if (to < 0) finish();
576
+ else arrive(s, to, "next");
577
+ }, [arrive, forward, finish]);
315
578
  const prev = useCallback(() => {
316
- if (indexRef.current > 0) setIndex(indexRef.current - 1);
317
- }, []);
579
+ const s = stepsRef.current;
580
+ if (!s) return;
581
+ const to = backward(s, indexRef.current - 1, "back");
582
+ if (to >= 0) arrive(s, to, "back");
583
+ }, [arrive, backward]);
584
+
585
+ // awaitGate (§11.4): `release` and `refuse` are events, heard only while a tour runs.
586
+ useEffect(() => {
587
+ if (!active) return;
588
+ let seq = 0;
589
+ return gates.listen((signal) => {
590
+ const s = stepsRef.current;
591
+ if (!s) return;
592
+ const i = indexRef.current;
593
+ if (signal.kind === "refuse") {
594
+ // On any other step the form's own errors speak.
595
+ if (s[i]?.awaitGate === signal.key) setRefusal({ step: s[i], reason: signal.reason, seq: ++seq });
596
+ return;
597
+ }
598
+ // The step awaiting it, or the first later one: the record is saved, so the fields
599
+ // between are passed over.
600
+ const awaiting = s[i]?.awaitGate === signal.key ? i : s.findIndex((st, k) => k > i && st.awaitGate === signal.key);
601
+ if (awaiting < 0) return;
602
+ const to = forward(s, awaiting + 1, "next");
603
+ if (to < 0) finish();
604
+ else arrive(s, to, "next");
605
+ });
606
+ }, [active, gates, arrive, forward, finish]);
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
+ });
318
625
 
319
626
  return (
320
- <TourContext.Provider value={{ active, activeId, start, stop, next, prev, goToStep, index, total }}>
321
- {children}
322
- {active && (
323
- <TourOverlay
324
- steps={steps!}
325
- index={index}
326
- labels={merged}
327
- onNext={next}
328
- onPrev={prev}
329
- onSkip={skip}
330
- />
331
- )}
332
- </TourContext.Provider>
627
+ <TourInternalContext.Provider value={internals}>
628
+ <TourContext.Provider value={value}>
629
+ {children}
630
+ {active && (
631
+ <TourOverlay
632
+ steps={steps!}
633
+ index={index}
634
+ via={nav.via}
635
+ floor={nav.floor}
636
+ position={nav.position}
637
+ count={nav.count}
638
+ refusal={refusal?.step === steps![index] ? refusal : null}
639
+ onGoTo={goToStep}
640
+ labels={merged}
641
+ gates={gates}
642
+ dockOnPhones={dockOnPhones}
643
+ onNext={next}
644
+ onPrev={prev}
645
+ onSkip={skip}
646
+ />
647
+ )}
648
+ </TourContext.Provider>
649
+ </TourInternalContext.Provider>
333
650
  );
334
651
  }
335
652
 
653
+ interface Nav {
654
+ index: number;
655
+ /** The run's first step (the `at` floor). */
656
+ floor: number;
657
+ via: TourStepContext["via"];
658
+ position: number;
659
+ count: number;
660
+ }
661
+
662
+ const NO_NAV: Nav = { index: 0, floor: 0, via: "start", position: 0, count: 0 };
663
+
664
+ /** The app's `refuse` on the step that awaits its key (§11.4). */
665
+ interface Refusal {
666
+ step: TourStep;
667
+ reason?: string;
668
+ /** Bumped per refusal, so the same words are announced again. */
669
+ seq: number;
670
+ }
671
+
672
+ const NOOP = () => {};
673
+
674
+ function stepContext(gates: GateRegistry, via: TourStepContext["via"]): TourStepContext {
675
+ return { gate: (key) => gates.get(key), via };
676
+ }
677
+
678
+ /** `StartOptions.at` as a step index: a number clamped, an anchor's first step, else 0. */
679
+ function resolveAt(steps: TourStep[], at: TourAt): number {
680
+ if (typeof at === "number") return Math.min(Math.max(0, Math.floor(at)), Math.max(0, steps.length - 1));
681
+ const anchor = `data-tour="${at}"`;
682
+ const found = steps.findIndex((step) => step.target?.includes(anchor));
683
+ if (found >= 0) return found;
684
+ if (devWarnings()) console.warn(`[ui-kit] A tour was started at "${at}", but no step targets [${anchor}]: it starts at 0.`);
685
+ return 0;
686
+ }
687
+
688
+ /** The first step in [floor, index) shown now whose gate is published and unsatisfied, or -1. */
689
+ function firstEmptied(steps: TourStep[], floor: number, index: number, gates: GateRegistry): number {
690
+ const ctx = stepContext(gates, "goto");
691
+ for (let j = floor; j < index; j++) {
692
+ const key = steps[j].gate;
693
+ if (key && gates.get(key) === false && isShown(steps[j], ctx)) return j;
694
+ }
695
+ return -1;
696
+ }
697
+
698
+ /** A step's `when`, asked now; a throw is a skip, and the error goes to the console. */
699
+ function isShown(step: TourStep, ctx: TourStepContext): boolean {
700
+ if (!step.when) return true;
701
+ try {
702
+ return step.when(ctx) === true;
703
+ } catch (error) {
704
+ console.error(error);
705
+ return false;
706
+ }
707
+ }
708
+
336
709
  interface Rect {
337
710
  top: number;
338
711
  left: number;
@@ -370,6 +743,59 @@ function rectOf(el: Element): Rect {
370
743
  return { top: r.top, left: r.left, width: r.width, height: r.height };
371
744
  }
372
745
 
746
+ /** The two-tone frame's width outside the spot (3px white, 2px keyline), plus a pixel. */
747
+ const RING_BAND = 6;
748
+
749
+ /**
750
+ * What a step's ring frames (keksdose S1, §11.6): the target, and on a FIELD step also the
751
+ * lines that describe the field from outside it — the elements its `aria-describedby`
752
+ * (on the target or a control inside) names that lie within the ring's reach of it (a
753
+ * caption, a hint or warning line, an error drawn under or above the field). Without
754
+ * them the ring's frame ran through such a line; with them it frames field and line, and
755
+ * the line is lit, not under the scrim. A kit field's own caption and error are inside
756
+ * its root already. A line far away (a tooltip, another part of the page) is not taken.
757
+ */
758
+ function spotRectOf(el: Element, step: TourStep): Rect {
759
+ const own = rectOf(el);
760
+ if (!isFieldStep(step)) return own;
761
+ const ids = new Set<string>();
762
+ const holders = [el, ...Array.from(el.querySelectorAll("[aria-describedby]"))];
763
+ for (const holder of holders) {
764
+ for (const id of (holder.getAttribute("aria-describedby") ?? "").split(/\s+/)) if (id) ids.add(id);
765
+ }
766
+ if (ids.size === 0) return own;
767
+ const lines: DOMRect[] = [];
768
+ for (const id of ids) {
769
+ const line = document.getElementById(id);
770
+ if (!line || el.contains(line) || line.closest('[role="tooltip"]') || !isVisible(line)) continue;
771
+ const r = line.getBoundingClientRect();
772
+ // An `sr-only` description measures 1×1: nothing to frame.
773
+ if (r.width > 1 && r.height > 1) lines.push(r);
774
+ }
775
+ let top = own.top;
776
+ let left = own.left;
777
+ let right = own.left + own.width;
778
+ let bottom = own.top + own.height;
779
+ const reach = (step.padding ?? 8) + RING_BAND;
780
+ // Grow until nothing more is in reach: a hint under the field, then an error under it.
781
+ for (let grew = true; grew; ) {
782
+ grew = false;
783
+ for (let i = lines.length - 1; i >= 0; i--) {
784
+ const r = lines[i];
785
+ const across = r.left < right && r.right > left;
786
+ const gap = Math.max(r.top - bottom, top - r.bottom);
787
+ if (!across || gap > reach) continue;
788
+ top = Math.min(top, r.top);
789
+ left = Math.min(left, r.left);
790
+ right = Math.max(right, r.right);
791
+ bottom = Math.max(bottom, r.bottom);
792
+ lines.splice(i, 1);
793
+ grew = true;
794
+ }
795
+ }
796
+ return { top, left, width: right - left, height: bottom - top };
797
+ }
798
+
373
799
  /**
374
800
  * What the finder established for ONE step: where its target is (`null` = no target,
375
801
  * or not on the page), and the reading direction the card takes from it.
@@ -400,26 +826,117 @@ interface Located {
400
826
  step: TourStep;
401
827
  rect: Rect | null;
402
828
  dir: Direction;
829
+ /**
830
+ * A field step's own container (§11.6): inserted right after the kit layer holding the
831
+ * target (`host.after`), or at the end of <body> on the page. `null` on a plain step,
832
+ * which is portalled to <body> at z-[60] as ever.
833
+ */
834
+ layer: HTMLElement | null;
835
+ /** The host fills the visual viewport (a phone's FullBleedDialog): no scrim, no shields. */
836
+ fullScreen: boolean;
837
+ }
838
+
839
+ /** The kit layer (`data-kit-layer`) holding `el`, if any. */
840
+ function layerHost(el: Element): HTMLElement | null {
841
+ return el.closest<HTMLElement>("[data-kit-layer]");
842
+ }
843
+
844
+ /**
845
+ * Does the host fill the visual viewport? A backdrop host (`data-kit-layer="backdrop"`:
846
+ * Modal, FullBleedDialog) is judged by its panel, the child holding the target.
847
+ */
848
+ function coversViewport(host: HTMLElement, el: Element): boolean {
849
+ let surface: Element = host;
850
+ if (host.getAttribute("data-kit-layer") === "backdrop") {
851
+ for (const child of Array.from(host.children)) {
852
+ if (child.contains(el)) {
853
+ surface = child;
854
+ break;
855
+ }
856
+ }
857
+ }
858
+ const r = surface.getBoundingClientRect();
859
+ const vv = window.visualViewport;
860
+ const top = vv?.offsetTop ?? 0;
861
+ const bottom = top + (vv?.height ?? window.innerHeight);
862
+ const width = vv?.width ?? window.innerWidth;
863
+ return r.width > 0 && r.left <= 1 && r.top <= top + 1 && r.right >= width - 1 && r.bottom >= bottom - 1;
864
+ }
865
+
866
+ /** Does this step's card dock now (§11.7)? A phone-width viewport, and `"dock"` or a field
867
+ * step with no placement while the provider docks on phones. */
868
+ function docks(step: TourStep, dockOnPhones: boolean): boolean {
869
+ if (typeof window === "undefined" || window.innerWidth >= DOCK_MAX_WIDTH || !step.target) return false;
870
+ return step.placement === "dock" || (step.placement === undefined && dockOnPhones && isFieldStep(step));
871
+ }
872
+
873
+ /** A step whose control is for using: its card joins the layer stack above the field (§11.6). */
874
+ function isFieldStep(step: TourStep): boolean {
875
+ return !!(step.interactive || step.awaitInput || step.gate || step.awaitGate);
876
+ }
877
+
878
+ /** The first control in `el` (or `el` itself) a person would type into or press. */
879
+ function firstFocusable(el: Element): HTMLElement | null {
880
+ if (el instanceof HTMLElement && el.matches(FOCUSABLE_CONTROL)) return el;
881
+ return el.querySelector<HTMLElement>(FOCUSABLE_CONTROL);
882
+ }
883
+
884
+ /** The tab stops inside `el`, in DOM order (the focus trap's selector). */
885
+ function tabStops(el: Element): HTMLElement[] {
886
+ const all = el.matches(FOCUSABLE_CONTROL) ? [el as HTMLElement] : [];
887
+ return all.concat(
888
+ Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE_CONTROL)).filter(
889
+ (x) => x.tabIndex >= 0 && !x.closest("[hidden],[inert]"),
890
+ ),
891
+ );
892
+ }
893
+
894
+ const FOCUSABLE_CONTROL =
895
+ 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
896
+
897
+ /** A touch screen: the card takes focus, so no keyboard or pad opens before the step is read (§11.8). */
898
+ function coarsePointer(): boolean {
899
+ return typeof window !== "undefined" && typeof window.matchMedia === "function"
900
+ ? window.matchMedia("(pointer: coarse)").matches
901
+ : false;
403
902
  }
404
903
 
405
904
  function TourOverlay({
406
905
  steps,
407
906
  index,
907
+ via,
908
+ floor,
909
+ position,
910
+ count,
911
+ refusal,
912
+ onGoTo,
408
913
  labels,
914
+ gates,
915
+ dockOnPhones,
409
916
  onNext,
410
917
  onPrev,
411
918
  onSkip,
412
919
  }: {
413
920
  steps: TourStep[];
414
921
  index: number;
922
+ via: TourStepContext["via"];
923
+ floor: number;
924
+ /** The app refused this step's save (§11.4). */
925
+ refusal: Refusal | null;
926
+ onGoTo: (index: number) => void;
927
+ /** 1-based among the steps shown; Back is hidden on the first, Done on the last. */
928
+ position: number;
929
+ count: number;
415
930
  labels: TourLabels;
931
+ gates: GateRegistry;
932
+ dockOnPhones: boolean;
416
933
  onNext: () => void;
417
934
  onPrev: () => void;
418
935
  onSkip: () => void;
419
936
  }) {
420
937
  const step = steps[index];
421
- const isFirst = index === 0;
422
- const isLast = index === steps.length - 1;
938
+ const isFirst = position <= 1;
939
+ const isLast = position >= count;
423
940
  // A new step starts unlocated: whatever was found for another step (see `Located`)
424
941
  // is ignored, so the overlay renders nothing until the effect below has located THIS
425
942
  // step, and never the previous step's spotlight.
@@ -432,16 +949,46 @@ function TourOverlay({
432
949
  // step). Arrow keys and start/end placement read it.
433
950
  const dir: Direction = current?.dir ?? "ltr";
434
951
  const rootRef = useRef<HTMLDivElement>(null);
952
+ const cardRef = useRef<HTMLDivElement>(null);
953
+ const bodyId = useId();
954
+ const hintId = useId();
955
+
956
+ // The gate (§11.1). Re-rendered on every registry change while the tour runs.
957
+ const gateVersion = useSyncExternalStore(gates.subscribe, gates.version, gates.version);
958
+ const gateKey = step.gate;
959
+ const enforced = !!gateKey && gates.has(gateKey);
960
+ const locked = enforced && gates.get(gateKey) !== true;
961
+
962
+ // The polite live region (§11.8): `gateReady` once a gate opens, the hint on a locked
963
+ // Next. A repeat of the same words gets a trailing zero-width space so it is read again.
964
+ const [polite, setPolite] = useState("");
965
+ const announce = useCallback((text: string) => {
966
+ setPolite((prev) => (prev === text ? `${text}\u200b` : text));
967
+ }, []);
435
968
 
436
969
  // Run beforeStep, then locate the target (retrying across route/render lag).
437
970
  useEffect(() => {
438
971
  let cancelled = false;
439
972
  let raf = 0;
440
973
  // `null` = untargeted, or the finder gave up → the card centres, with no hole.
441
- const settle = (el: Element | null) =>
442
- setLocated({ index, step, rect: el ? rectOf(el) : null, dir: dirOf(el) });
974
+ const settle = (el: Element | null) => {
975
+ let layer: HTMLElement | null = null;
976
+ let fullScreen = false;
977
+ if (el && isFieldStep(step)) {
978
+ // §11.6: directly above the layer holding the field — DOM order decides between
979
+ // the kit's z-50 body children, so this paints over the host and everything opened
980
+ // before it, and under whatever the field opens next (a list, the number pad).
981
+ const host = layerHost(el);
982
+ layer = document.createElement("div");
983
+ layer.setAttribute("data-tour-layer", "");
984
+ if (host) host.after(layer);
985
+ else document.body.appendChild(layer);
986
+ fullScreen = !!host && coversViewport(host, el);
987
+ }
988
+ setLocated({ index, step, rect: el ? spotRectOf(el, step) : null, dir: dirOf(el), layer, fullScreen });
989
+ };
443
990
  void (async () => {
444
- await step.beforeStep?.();
991
+ await step.beforeStep?.(stepContext(gates, via));
445
992
  if (cancelled) return;
446
993
  if (!step.target) {
447
994
  settle(null);
@@ -452,7 +999,10 @@ function TourOverlay({
452
999
  if (cancelled) return;
453
1000
  const el = queryVisibleTarget(step.target!);
454
1001
  if (el) {
455
- el.scrollIntoView({ block: "center", behavior: "smooth" });
1002
+ // A docked step scrolls the field into the half the card leaves free, through
1003
+ // the field's own scroll container (§11.7); any other centres it, as ever.
1004
+ if (docks(step, dockOnPhones)) scrollIntoFreeHalf(el, null, null);
1005
+ else el.scrollIntoView({ block: "center", behavior: "smooth" });
456
1006
  settle(el);
457
1007
  return;
458
1008
  }
@@ -465,6 +1015,8 @@ function TourOverlay({
465
1015
  cancelled = true;
466
1016
  cancelAnimationFrame(raf);
467
1017
  };
1018
+ // `via` and the gates are read at arrival, not followed.
1019
+ // eslint-disable-next-line react-hooks/exhaustive-deps
468
1020
  }, [index, step]);
469
1021
 
470
1022
  // Follow the target as the page scrolls / reflows — only once THIS step is located,
@@ -474,12 +1026,16 @@ function TourOverlay({
474
1026
  const update = () => {
475
1027
  const el = queryVisibleTarget(step.target!);
476
1028
  if (el) {
477
- const next = rectOf(el);
478
- setLocated((prev) =>
479
- prev && prev.index === index && prev.step === step && !sameRect(prev.rect, next)
480
- ? { ...prev, rect: next }
481
- : prev,
482
- );
1029
+ const next = spotRectOf(el, step);
1030
+ // A sheet that grows to full screen (or shrinks from it) takes or drops the scrim.
1031
+ const host = layerHost(el);
1032
+ const full = !!host && coversViewport(host, el);
1033
+ setLocated((prev) => {
1034
+ if (!prev || prev.index !== index || prev.step !== step) return prev;
1035
+ const fullScreen = prev.layer ? full : prev.fullScreen;
1036
+ if (sameRect(prev.rect, next) && fullScreen === prev.fullScreen) return prev;
1037
+ return { ...prev, rect: next, fullScreen };
1038
+ });
483
1039
  }
484
1040
  };
485
1041
  window.addEventListener("scroll", update, true);
@@ -492,6 +1048,31 @@ function TourOverlay({
492
1048
  };
493
1049
  }, [index, step]);
494
1050
 
1051
+ // A field step's container goes with the step (and with the tour).
1052
+ const layer = located?.layer ?? null;
1053
+ useEffect(() => {
1054
+ if (!layer) return;
1055
+ return () => layer.remove();
1056
+ }, [layer]);
1057
+
1058
+ // Escape (§11.6.4): the tour is a layer on the kit's Escape stack, pushed on each
1059
+ // arrival, so it sits above the sheet holding the form. Escape from the card skips the
1060
+ // tour and no longer also closes that sheet; a layer the field opens afterwards (a
1061
+ // list, the pad) is above the tour and takes Escape first. An Escape pressed in the
1062
+ // field itself belongs to the field: the tour lets it pass by doing nothing.
1063
+ useDismissLayer(
1064
+ ready,
1065
+ (e) => {
1066
+ if (fieldStepNow && step.target && e.target instanceof Node) {
1067
+ const el = queryVisibleTarget(step.target);
1068
+ if (el?.contains(e.target)) return;
1069
+ }
1070
+ onSkip();
1071
+ },
1072
+ // Not an overlay to `useOverlayOpen`: a floating launcher a tour spotlights stays.
1073
+ { overlay: false },
1074
+ );
1075
+
495
1076
  // awaitClick: advance when the *real* target is clicked. The spotlight hole is
496
1077
  // click-through (see the shields in the render below) so the element's own
497
1078
  // handler runs first; we step forward on the next macrotask.
@@ -509,9 +1090,148 @@ function TourOverlay({
509
1090
  return () => el.removeEventListener("click", onClick);
510
1091
  }, [ready, spotted, index, step, onNext]);
511
1092
 
512
- // An interactive step (see `TourStep.interactive`): only while the target is lit — a
513
- // step whose target is missing centres its card over a full scrim, modal as ever.
514
- const interactive = spotted && !!step.target && !!(step.interactive || step.awaitInput);
1093
+ // A field step (see `TourStep.interactive`): only while the target is lit — a step
1094
+ // whose target is missing centres its card over a full scrim, modal as ever.
1095
+ const interactive =
1096
+ spotted && !!step.target && !!(step.interactive || step.awaitInput || step.gate || step.awaitGate);
1097
+ // A GUIDED field step (§11.8): focus goes to the field on a fine pointer, and Tab
1098
+ // cycles field + card. A plain `interactive` step keeps the card's own trap.
1099
+ const guided = interactive && !!(step.gate || step.awaitInput || step.awaitGate);
1100
+ const fieldStepNow = interactive;
1101
+ // An awaitGate step whose target is lit: only `release` moves it (a missing Save
1102
+ // button would otherwise be a dead end, as for awaitClick).
1103
+ const awaitsRelease = spotted && !!step.target && !!step.awaitGate;
1104
+
1105
+ // A gate nobody publishes is not enforced: Next opens, and a development build says why
1106
+ // (on arrival, or when its publisher unmounts under the step), once per step.
1107
+ const warnedGateRef = useRef<TourStep | null>(null);
1108
+ useEffect(() => {
1109
+ if (!ready || !gateKey || gates.has(gateKey) || !devWarnings() || warnedGateRef.current === step) return;
1110
+ warnedGateRef.current = step;
1111
+ console.warn(
1112
+ `[ui-kit] Tour step "${step.title}" waits for the gate "${gateKey}", which nothing publishes: its Next is open. ` +
1113
+ "Publish it with useTourGate beside the field, or the form has closed under the tour.",
1114
+ );
1115
+ }, [ready, gateKey, gates, step, gateVersion]);
1116
+
1117
+ // `optional` means "may stay EMPTY": without a gate there is no value to compare.
1118
+ useEffect(() => {
1119
+ if (!ready || !step.optional || step.gate || !devWarnings()) return;
1120
+ console.warn(
1121
+ `[ui-kit] Tour step "${step.title}" is \`optional\` without a \`gate\`: it shows the Optional chip ` +
1122
+ "and a plain Next. \"Leave empty\" needs a gate to compare.",
1123
+ );
1124
+ }, [ready, step]);
1125
+
1126
+ // "Ready" once, when this step's gate turns satisfied (not when it arrives satisfied).
1127
+ const wasLockedRef = useRef<{ step: TourStep; locked: boolean } | null>(null);
1128
+ useEffect(() => {
1129
+ if (!ready || !gateKey) return;
1130
+ const before = wasLockedRef.current;
1131
+ if (before && before.step === step && before.locked && !locked && enforced) announce(labels.gateReady);
1132
+ wasLockedRef.current = { step, locked };
1133
+ }, [ready, gateKey, step, locked, enforced, announce, labels.gateReady]);
1134
+
1135
+ // Arrival on a guided step: the field takes focus on a fine pointer, the card on a
1136
+ // coarse one; the field is described by the card's body while the step lasts.
1137
+ // Declared BEFORE the advance listeners below, so the focus it moves is not mistaken
1138
+ // for the person leaving the field.
1139
+ useEffect(() => {
1140
+ if (!ready || !guided || !step.target) return;
1141
+ const el = queryVisibleTarget(step.target);
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);
1148
+ if (coarsePointer() || !control) cardRef.current?.focus();
1149
+ else if (!el!.contains(document.activeElement)) focusQuietly(control);
1150
+ if (!control) return;
1151
+ return () => {
1152
+ if (previous === null) control.removeAttribute("aria-describedby");
1153
+ else control.setAttribute("aria-describedby", previous);
1154
+ };
1155
+ }, [ready, guided, index, step, bodyId]);
1156
+
1157
+ // Tab on a guided step cycles field + card as one ring (§11.8): from the field's last
1158
+ // stop to the card's first, from the card's last back to the field. Captured on the
1159
+ // document, ahead of a sheet's own trap, which would wrap inside the sheet.
1160
+ useEffect(() => {
1161
+ if (!ready || !guided || !step.target) return;
1162
+ const onTab = (e: KeyboardEvent) => {
1163
+ if (e.key !== "Tab") return;
1164
+ const el = queryVisibleTarget(step.target!);
1165
+ const card = cardRef.current;
1166
+ if (!el || !card) return;
1167
+ const active = document.activeElement;
1168
+ const fieldStops = tabStops(el);
1169
+ const cardStops = tabStops(card);
1170
+ if (fieldStops.length === 0 || cardStops.length === 0) return;
1171
+ let to: HTMLElement | null = null;
1172
+ if (active instanceof Node && el.contains(active)) {
1173
+ if (!e.shiftKey && active === fieldStops[fieldStops.length - 1]) to = cardStops[0];
1174
+ else if (e.shiftKey && active === fieldStops[0]) to = cardStops[cardStops.length - 1];
1175
+ } else if (active instanceof Node && card.contains(active)) {
1176
+ if (!e.shiftKey && active === cardStops[cardStops.length - 1]) to = fieldStops[0];
1177
+ else if (e.shiftKey && (active === cardStops[0] || active === card)) to = fieldStops[fieldStops.length - 1];
1178
+ }
1179
+ if (!to) return;
1180
+ e.preventDefault();
1181
+ e.stopPropagation();
1182
+ to.focus();
1183
+ };
1184
+ document.addEventListener("keydown", onTab, true);
1185
+ return () => document.removeEventListener("keydown", onTab, true);
1186
+ }, [ready, guided, index, step]);
1187
+
1188
+ // gate: once satisfied, advance on Enter in the field (unless the control took it, or
1189
+ // a textarea without Ctrl/Cmd) or on focus leaving the field to nowhere or to the card.
1190
+ // Checked on the next macrotask, after the field's own blur/Enter commit has rendered.
1191
+ useEffect(() => {
1192
+ if (!ready || !spotted || !gateKey || !step.target) return;
1193
+ const el = queryVisibleTarget(step.target) as HTMLElement | null;
1194
+ if (!el) return;
1195
+ const satisfied = () => !gates.has(gateKey) || gates.get(gateKey) === true;
1196
+ let timer = 0;
1197
+ // Focus that was in the field when the step settled, or went there since. A focus
1198
+ // move the tour made itself (the card taking it on a phone) is not a leave.
1199
+ let armed = el.contains(document.activeElement);
1200
+ const check = () => {
1201
+ window.clearTimeout(timer);
1202
+ timer = window.setTimeout(() => {
1203
+ if (satisfied()) onNext();
1204
+ }, 0);
1205
+ };
1206
+ const onFocusIn = () => {
1207
+ armed = true;
1208
+ };
1209
+ const onFocusOut = (e: FocusEvent) => {
1210
+ const to = e.relatedTarget;
1211
+ if (to instanceof Node && el.contains(to)) return;
1212
+ const wasArmed = armed;
1213
+ armed = false;
1214
+ if (!wasArmed) return;
1215
+ if (to === null || (to instanceof Node && rootRef.current?.contains(to))) check();
1216
+ };
1217
+ const onKeyDown = (e: KeyboardEvent) => {
1218
+ if (e.key !== "Enter" || !(e.target instanceof Node) || !el.contains(e.target)) return;
1219
+ // Heard at the document, after React's handlers: a combobox picking an option has
1220
+ // called preventDefault by now.
1221
+ if (e.defaultPrevented || isComposing(e)) return;
1222
+ if (e.target instanceof HTMLTextAreaElement && !(e.ctrlKey || e.metaKey)) return;
1223
+ check();
1224
+ };
1225
+ el.addEventListener("focusin", onFocusIn);
1226
+ el.addEventListener("focusout", onFocusOut);
1227
+ document.addEventListener("keydown", onKeyDown);
1228
+ return () => {
1229
+ window.clearTimeout(timer);
1230
+ el.removeEventListener("focusin", onFocusIn);
1231
+ el.removeEventListener("focusout", onFocusOut);
1232
+ document.removeEventListener("keydown", onKeyDown);
1233
+ };
1234
+ }, [ready, spotted, gateKey, gates, index, step, onNext]);
515
1235
 
516
1236
  // awaitInput: advance once the target's fields hold another value than when the step
517
1237
  // was armed, checked when focus leaves the target or Enter is pressed in it — on the
@@ -551,10 +1271,27 @@ function TourOverlay({
551
1271
  };
552
1272
  }, [ready, spotted, index, step, onNext]);
553
1273
 
1274
+ /** A press on the locked Next (or Enter / → from the card): no move; the field takes
1275
+ * focus and the hint is read again. */
1276
+ const gateHint = step.gateHint ?? labels.gateHint;
1277
+ const lockedNext = useCallback(() => {
1278
+ const el = step.target ? queryVisibleTarget(step.target) : null;
1279
+ const control = el ? firstFocusable(el) : null;
1280
+ // Quietly, as on arrival: the hint is read with the card in view, not under a list.
1281
+ if (control) focusQuietly(control);
1282
+ announce(gateHint);
1283
+ }, [step.target, announce, gateHint]);
1284
+ const tryNext = awaitsRelease ? NOOP : locked ? lockedNext : onNext;
1285
+
1286
+ // After a refused save: the first earlier step shown whose field is now empty (§11.4).
1287
+ // Computed each render, which the gate subscription above triggers: a field filled
1288
+ // again drops the link.
1289
+ const backTo = refusal ? firstEmptied(steps, floor, index, gates) : -1;
1290
+
554
1291
  // Keyboard: Esc skips, Enter and the reading-direction arrow advance (→ in LTR,
555
1292
  // ← in RTL), the other arrow goes back. On awaitClick steps Enter is ignored (it
556
1293
  // would fire the target's click AND advance) — the forward arrow stays as an escape
557
- // hatch.
1294
+ // hatch. On a locked gate step both are the locked Next.
558
1295
  //
559
1296
  // Enter on a focused control belongs to that control. This used to advance from
560
1297
  // anywhere, so Enter on a focused Skip or Back ran the button's own click AND stepped
@@ -568,14 +1305,16 @@ function TourOverlay({
568
1305
  if (interactive && e.target instanceof Node && e.target !== document.body && !rootRef.current?.contains(e.target)) {
569
1306
  return;
570
1307
  }
1308
+ // Escape is the tour's Escape layer's (above) once the step is shown; while it is
1309
+ // still being located there is no layer yet, and the tour takes it here as in 0.49.
571
1310
  if (e.key === "Escape") {
572
- onSkip();
1311
+ if (!ready) onSkip();
573
1312
  return;
574
1313
  }
575
1314
  if (e.key === "Enter") {
576
1315
  if (step.awaitClick || step.awaitInput || ownsKey(e.target, "Enter")) return;
577
1316
  e.preventDefault();
578
- onNext();
1317
+ tryNext();
579
1318
  return;
580
1319
  }
581
1320
  if (ownsKey(e.target, e.key)) return;
@@ -583,17 +1322,19 @@ function TourOverlay({
583
1322
  const delta = horizontalStep(e.key, rootRef.current);
584
1323
  if (delta === 1) {
585
1324
  e.preventDefault();
586
- onNext();
1325
+ tryNext();
587
1326
  } else if (delta === -1) {
588
1327
  if (!isFirst) onPrev();
589
1328
  }
590
1329
  };
591
1330
  document.addEventListener("keydown", onKey);
592
1331
  return () => document.removeEventListener("keydown", onKey);
593
- }, [isFirst, step, interactive, onNext, onPrev, onSkip]);
1332
+ }, [isFirst, step, interactive, tryNext, onPrev, onSkip, ready]);
594
1333
 
595
1334
  if (!ready) return null;
596
1335
 
1336
+ // No scrim, no shields: a field step inside a layer that fills the screen (§11.6.3).
1337
+ const bare = interactive && !!current?.fullScreen;
597
1338
  const pad = step.padding ?? 8;
598
1339
  const spot: Rect | null = rect
599
1340
  ? { top: rect.top - pad, left: rect.left - pad, width: rect.width + pad * 2, height: rect.height + pad * 2 }
@@ -610,9 +1351,10 @@ function TourOverlay({
610
1351
  return createPortal(
611
1352
  <div
612
1353
  ref={rootRef}
613
- // z-[45] on an interactive step: over the top bar (z-40), under the z-50 layers the
614
- // target opens. See `TourStep.interactive`.
615
- className={cn("pointer-events-none fixed inset-0", interactive ? "z-[45]" : "z-[60]")}
1354
+ // z-50 on a field step, in its own container right after the layer holding the field
1355
+ // (or at the end of <body>): over that layer and the page, under the z-50 layers the
1356
+ // field opens next (§11.6). z-[60] on a plain step, over everything.
1357
+ className={cn("pointer-events-none fixed inset-0", interactive ? "z-50" : "z-[60]")}
616
1358
  role="dialog"
617
1359
  aria-modal={interactive ? undefined : "true"}
618
1360
  // A stable marker for the overlay, on every step (0.48, keksdose dev#611–613): an
@@ -621,10 +1363,13 @@ function TourOverlay({
621
1363
  // keksdose matched the `pointer-events-none` class until now, which is the kit's to change.
622
1364
  data-tour-overlay=""
623
1365
  data-tour-interactive={interactive ? "" : undefined}
1366
+ data-tour-bare={bare ? "" : undefined}
624
1367
  aria-label={step.title}
625
1368
  dir={dir}
626
1369
  >
627
- {spot && <SpotlightShields spot={spot} />}
1370
+ {/* In a full-screen layer (a phone's form sheet) no scrim and no shields: the rest of
1371
+ the form stays usable and scrollable, and the ring alone marks the field. */}
1372
+ {spot && !bare && <SpotlightShields spot={spot} />}
628
1373
  {spot ? (
629
1374
  <div
630
1375
  className="pointer-events-none fixed rounded-lg transition-all duration-200 ease-out"
@@ -651,7 +1396,7 @@ function TourOverlay({
651
1396
  "0 0 0 3px rgba(255,255,255,0.98)",
652
1397
  "0 0 0 5px rgba(15,23,42,0.92)",
653
1398
  "0 0 18px 6px rgba(15,23,42,0.45)",
654
- "0 0 0 9999px rgba(15,23,42,0.70)",
1399
+ ...(bare ? [] : ["0 0 0 9999px rgba(15,23,42,0.70)"]),
655
1400
  ].join(", "),
656
1401
  }}
657
1402
  />
@@ -659,20 +1404,35 @@ function TourOverlay({
659
1404
  <div className="pointer-events-auto fixed inset-0 bg-black/70" />
660
1405
  )}
661
1406
  <TourCard
1407
+ cardRef={cardRef}
662
1408
  step={step}
663
1409
  index={index}
664
- total={steps.length}
1410
+ position={position}
1411
+ total={count}
665
1412
  labels={labels}
666
1413
  isFirst={isFirst}
667
1414
  isLast={isLast}
668
1415
  rect={spot}
669
1416
  dir={dir}
1417
+ trap={!guided}
1418
+ bodyId={bodyId}
1419
+ hintId={hintId}
1420
+ locked={locked}
1421
+ gateHint={gateHint}
670
1422
  onBack={onPrev}
671
- onNext={onNext}
1423
+ onNext={tryNext}
1424
+ onLeaveEmpty={onNext}
672
1425
  onSkip={onSkip}
1426
+ awaitsRelease={awaitsRelease}
1427
+ docked={spot !== null && docks(step, dockOnPhones)}
1428
+ refusal={refusal}
1429
+ backTo={backTo >= 0 ? { title: steps[backTo].title, go: () => onGoTo(backTo) } : null}
673
1430
  />
1431
+ <div className="sr-only" aria-live="polite" aria-atomic="true">
1432
+ {polite}
1433
+ </div>
674
1434
  </div>,
675
- document.body,
1435
+ (interactive && current?.layer) || document.body,
676
1436
  );
677
1437
  }
678
1438
 
@@ -788,31 +1548,62 @@ function placeCard(
788
1548
  }
789
1549
 
790
1550
  function TourCard({
1551
+ cardRef: ref,
791
1552
  step,
792
1553
  index,
1554
+ position,
793
1555
  total,
794
1556
  labels,
795
1557
  isFirst,
796
1558
  isLast,
797
1559
  rect,
798
1560
  dir,
1561
+ trap,
1562
+ bodyId,
1563
+ hintId,
1564
+ locked,
1565
+ gateHint,
799
1566
  onBack,
800
1567
  onNext,
1568
+ onLeaveEmpty,
801
1569
  onSkip,
1570
+ awaitsRelease,
1571
+ docked,
1572
+ refusal,
1573
+ backTo,
802
1574
  }: {
1575
+ cardRef: RefObject<HTMLDivElement | null>;
803
1576
  step: TourStep;
804
1577
  index: number;
1578
+ /** 1-based among the steps shown (§11.3). */
1579
+ position: number;
1580
+ /** How many steps the run shows. */
805
1581
  total: number;
806
1582
  labels: TourLabels;
807
1583
  isFirst: boolean;
808
1584
  isLast: boolean;
809
1585
  rect: Rect | null;
810
1586
  dir: Direction;
1587
+ /** The card's own focus trap; off on a guided step, where field + card are one ring. */
1588
+ trap: boolean;
1589
+ bodyId: string;
1590
+ hintId: string;
1591
+ /** A gate step whose field is not filled yet (§11.1). */
1592
+ locked: boolean;
1593
+ gateHint: string;
811
1594
  onBack: () => void;
812
1595
  onNext: () => void;
1596
+ /** Moves on past an `optional` field left empty. */
1597
+ onLeaveEmpty: () => void;
813
1598
  onSkip: () => void;
1599
+ /** An awaitGate step with its target lit: the hint in place of Next. */
1600
+ awaitsRelease: boolean;
1601
+ /** Pinned to the band's edge away from the field (§11.7). */
1602
+ docked: boolean;
1603
+ refusal: Refusal | null;
1604
+ /** The "Back to …" link after a refused save. */
1605
+ backTo: { title: string; go: () => void } | null;
814
1606
  }) {
815
- const ref = useRef<HTMLDivElement>(null);
816
1607
  const [style, setStyle] = useState<CSSProperties>({ opacity: 0 });
817
1608
 
818
1609
  /**
@@ -839,16 +1630,69 @@ function TourCard({
839
1630
  * is), which is the escape hatch that step already documents for the case where the
840
1631
  * target cannot be found. Containment is worth that: an `awaitClick` step is a
841
1632
  * minority of steps, while focus stranded behind `aria-modal` is every step.
1633
+ *
1634
+ * A guided field step (`gate`, `awaitInput`, 0.50) is the exception: the field is
1635
+ * what the step is about, so the overlay focuses it (or the card, on a touch screen)
1636
+ * and runs one Tab ring over field + card itself.
842
1637
  */
843
- useFocusTrap(ref, { active: true });
1638
+ useFocusTrap(ref, { active: trap });
1639
+
1640
+ // Docked (§11.7): the edge it last took, for the hysteresis, and a tick bumped when the
1641
+ // band moves under an unmoved field (a pad or keyboard opening).
1642
+ const edgeRef = useRef<DockEdge | null>(null);
1643
+ const [bandTick, setBandTick] = useState(0);
1644
+ const [edge, setEdge] = useState<DockEdge | null>(null);
1645
+ useEffect(() => {
1646
+ if (!docked) return;
1647
+ let last = "";
1648
+ const look = () => {
1649
+ const target = step.target ? queryVisibleTarget(step.target) : null;
1650
+ const band = readBand(target, ref.current);
1651
+ const key = `${band.top}:${band.bottom}`;
1652
+ if (key === last) return;
1653
+ last = key;
1654
+ // A field the pad or keyboard now covers is brought back into the free half.
1655
+ if (target) {
1656
+ const r = target.getBoundingClientRect();
1657
+ if (r.top < band.top || r.bottom > band.bottom) {
1658
+ edgeRef.current = scrollIntoFreeHalf(target, ref.current, edgeRef.current);
1659
+ }
1660
+ }
1661
+ setBandTick((t) => t + 1);
1662
+ };
1663
+ const vv = window.visualViewport;
1664
+ vv?.addEventListener("resize", look);
1665
+ vv?.addEventListener("scroll", look);
1666
+ window.addEventListener("resize", look);
1667
+ // The follow tick's cadence: it catches a pad opening, which moves nothing.
1668
+ const id = window.setInterval(look, 250);
1669
+ return () => {
1670
+ vv?.removeEventListener("resize", look);
1671
+ vv?.removeEventListener("scroll", look);
1672
+ window.removeEventListener("resize", look);
1673
+ clearInterval(id);
1674
+ };
1675
+ }, [docked, step.target, ref]);
844
1676
 
845
1677
  useLayoutEffect(() => {
846
1678
  const card = ref.current;
847
1679
  if (!card) return;
1680
+ if (docked) {
1681
+ const target = step.target ? queryVisibleTarget(step.target) : null;
1682
+ const band = readBand(target, card);
1683
+ const next = rect ? dockEdge(rect, band, edgeRef.current, card.offsetHeight) : "bottom";
1684
+ edgeRef.current = next;
1685
+ setEdge(next);
1686
+ setStyle(dockStyle(next, rect, band));
1687
+ return;
1688
+ }
1689
+ setEdge(null);
848
1690
  setStyle(
849
1691
  placeCard(rect, card.offsetWidth, card.offsetHeight, window.innerWidth, window.innerHeight, 12, step.placement, dir),
850
1692
  );
851
- }, [rect, index, step.placement, dir]);
1693
+ }, [ref, rect, index, step.placement, step.target, dir, docked, bandTick]);
1694
+
1695
+ const awaitsTarget = ((step.awaitClick || step.awaitInput) && step.target && rect) || awaitsRelease;
852
1696
 
853
1697
  return (
854
1698
  <div
@@ -859,10 +1703,28 @@ function TourCard({
859
1703
  // trap silently does nothing.
860
1704
  tabIndex={-1}
861
1705
  style={style}
862
- className="pointer-events-auto fixed z-[61] w-80 max-w-[calc(100vw-1.5rem)] rounded-lg border border-subtle bg-surface p-4 shadow-xl outline-none transition-all duration-200 ease-out"
1706
+ data-tour-card=""
1707
+ data-tour-docked={edge ?? undefined}
1708
+ className={cn(
1709
+ "pointer-events-auto fixed z-[61] w-80 max-w-[calc(100vw-1.5rem)] rounded-lg border border-subtle bg-surface p-4 shadow-xl outline-none transition-all duration-200 ease-out",
1710
+ // Docked, the height is capped: the body scrolls, the title and buttons stay.
1711
+ docked && "flex flex-col",
1712
+ )}
863
1713
  >
864
- <div className="text-sm font-semibold text-primary">{step.title}</div>
865
- <div className="mt-1 text-sm leading-relaxed text-secondary">{step.body}</div>
1714
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-semibold text-primary">
1715
+ <span>{step.title}</span>
1716
+ {step.optional && (
1717
+ <span className="rounded-full border border-subtle px-1.5 py-px text-xs font-normal text-muted">
1718
+ {labels.optional}
1719
+ </span>
1720
+ )}
1721
+ </div>
1722
+ <div
1723
+ id={bodyId}
1724
+ className={cn("mt-1 text-sm leading-relaxed text-secondary", docked && "min-h-0 shrink overflow-y-auto")}
1725
+ >
1726
+ {step.body}
1727
+ </div>
866
1728
  {step.action && <div className="mt-3">{step.action}</div>}
867
1729
  <div className="mt-4 space-y-2">
868
1730
  <div className="flex items-center justify-between gap-2">
@@ -875,7 +1737,7 @@ function TourCard({
875
1737
  </button>
876
1738
  <div className="flex items-center gap-2">
877
1739
  <span className="whitespace-nowrap text-xs tabular-nums text-placeholder">
878
- {labels.step(index + 1, total)}
1740
+ {labels.step(position, total)}
879
1741
  </span>
880
1742
  {!isFirst && (
881
1743
  <Button variant="secondary" onClick={onBack} className="px-2.5 py-1 text-xs">
@@ -890,16 +1752,57 @@ function TourCard({
890
1752
  way forward on any step whose anchor happens not to be rendered
891
1753
  for this user (a guest sees no "add account" button), leaving the
892
1754
  undocumented ArrowRight key as the sole escape. */}
893
- {!((step.awaitClick || step.awaitInput) && step.target && rect) && (
894
- <Button variant="accent" onClick={onNext} className={cn("px-2.5 py-1 text-xs")}>
1755
+ {/* "Leave empty" (§11.2): an optional field may stay empty, so the step can
1756
+ move on without it. Gone once the field is filled. */}
1757
+ {step.optional && locked && !awaitsTarget && (
1758
+ <Button variant="secondary" onClick={onLeaveEmpty} className="px-2.5 py-1 text-xs">
1759
+ {labels.leaveEmpty}
1760
+ </Button>
1761
+ )}
1762
+ {!awaitsTarget && (
1763
+ <Button
1764
+ variant="accent"
1765
+ onClick={onNext}
1766
+ // Locked, not disabled (§11.1): it stays focusable, and a press focuses the
1767
+ // field and reads the hint again.
1768
+ aria-disabled={locked || undefined}
1769
+ aria-describedby={locked ? hintId : undefined}
1770
+ className={cn("px-2.5 py-1 text-xs", locked && "opacity-60")}
1771
+ >
895
1772
  {isLast ? labels.done : labels.next}
896
1773
  </Button>
897
1774
  )}
898
1775
  </div>
899
1776
  </div>
900
- {(step.awaitClick || step.awaitInput) && step.target && rect && (
1777
+ {awaitsTarget && (
901
1778
  <div className="text-center text-xs italic text-placeholder">
902
- {step.awaitInput && !step.awaitClick ? labels.awaitInputHint : labels.awaitClickHint}
1779
+ {awaitsRelease
1780
+ ? (step.awaitGateHint ?? labels.awaitGateHint)
1781
+ : step.awaitInput && !step.awaitClick
1782
+ ? labels.awaitInputHint
1783
+ : labels.awaitClickHint}
1784
+ </div>
1785
+ )}
1786
+ {/* Always mounted on an awaitGate step, so a refusal is read out (assertively)
1787
+ the moment it lands. */}
1788
+ {awaitsRelease && (
1789
+ <div aria-live="assertive" aria-atomic="true" className="space-y-1">
1790
+ {refusal && (
1791
+ <p data-tour-refused="" className="text-xs text-danger">
1792
+ {refusal.reason ?? labels.awaitGateRefused}
1793
+ {refusal.seq % 2 === 0 ? "\u200b" : ""}
1794
+ </p>
1795
+ )}
1796
+ {refusal && backTo && (
1797
+ <Button variant="link" onClick={backTo.go} className="text-xs">
1798
+ {labels.backToStep(backTo.title)}
1799
+ </Button>
1800
+ )}
1801
+ </div>
1802
+ )}
1803
+ {locked && !awaitsTarget && (
1804
+ <div id={hintId} className="text-center text-xs italic text-placeholder">
1805
+ {gateHint}
903
1806
  </div>
904
1807
  )}
905
1808
  </div>