@eifi1/ui-kit 0.49.0 → 0.50.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 (144) hide show
  1. package/README.md +11 -7
  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 +1 -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/currency-select.d.ts +28 -2
  18. package/dist/components/currency-select.js +56 -10
  19. package/dist/components/currency-select.js.map +1 -1
  20. package/dist/components/data-table-mobile.d.ts +11 -2
  21. package/dist/components/data-table-mobile.js +62 -8
  22. package/dist/components/data-table-mobile.js.map +1 -1
  23. package/dist/components/data-table-row.d.ts +1 -1
  24. package/dist/components/data-table-row.js.map +1 -1
  25. package/dist/components/data-table.js +3 -1
  26. package/dist/components/data-table.js.map +1 -1
  27. package/dist/components/dropdown.js +1 -0
  28. package/dist/components/dropdown.js.map +1 -1
  29. package/dist/components/full-bleed-dialog.js +1 -0
  30. package/dist/components/full-bleed-dialog.js.map +1 -1
  31. package/dist/components/modal.js +1 -0
  32. package/dist/components/modal.js.map +1 -1
  33. package/dist/components/number-input.d.ts +2 -1
  34. package/dist/components/number-input.js +6 -2
  35. package/dist/components/number-input.js.map +1 -1
  36. package/dist/components/numpad-sheet.js +2 -0
  37. package/dist/components/numpad-sheet.js.map +1 -1
  38. package/dist/components/picker-sheet.js +2 -0
  39. package/dist/components/picker-sheet.js.map +1 -1
  40. package/dist/components/popover.js +1 -0
  41. package/dist/components/popover.js.map +1 -1
  42. package/dist/eslint/index.js +1 -1
  43. package/dist/i18n/defaults.js +2 -0
  44. package/dist/i18n/defaults.js.map +1 -1
  45. package/dist/i18n/german.js +14 -1
  46. package/dist/i18n/german.js.map +1 -1
  47. package/dist/i18n/kit-labels.d.ts +3 -0
  48. package/dist/i18n/kit-labels.js.map +1 -1
  49. package/dist/i18n/locales/en.js +16 -1
  50. package/dist/i18n/locales/en.js.map +3 -3
  51. package/dist/i18n/locales/es.js +14 -1
  52. package/dist/i18n/locales/es.js.map +1 -1
  53. package/dist/i18n/locales/fr.js +14 -1
  54. package/dist/i18n/locales/fr.js.map +1 -1
  55. package/dist/i18n/locales/hu.js +14 -1
  56. package/dist/i18n/locales/hu.js.map +1 -1
  57. package/dist/i18n/locales/it.js +14 -1
  58. package/dist/i18n/locales/it.js.map +1 -1
  59. package/dist/i18n/locales/zh.js +14 -1
  60. package/dist/i18n/locales/zh.js.map +1 -1
  61. package/dist/i18n/review.js +1 -0
  62. package/dist/i18n/review.js.map +1 -1
  63. package/dist/lib/data-attributes.d.ts +11 -0
  64. package/dist/lib/data-attributes.js +10 -0
  65. package/dist/lib/data-attributes.js.map +1 -0
  66. package/dist/lib/format.d.ts +1 -0
  67. package/dist/lib/format.js +2 -0
  68. package/dist/lib/format.js.map +1 -1
  69. package/dist/lib/minus.d.ts +10 -0
  70. package/dist/lib/minus.js.map +1 -1
  71. package/dist/testing/react.d.ts +25 -0
  72. package/dist/testing/react.js +19 -0
  73. package/dist/testing/react.js.map +1 -0
  74. package/dist/tour/tour-dock.d.ts +42 -0
  75. package/dist/tour/tour-dock.js +80 -0
  76. package/dist/tour/tour-dock.js.map +1 -0
  77. package/dist/tour/tour-gates.d.ts +71 -0
  78. package/dist/tour/tour-gates.js +114 -0
  79. package/dist/tour/tour-gates.js.map +1 -0
  80. package/dist/tour/tour-guide-button.d.ts +27 -0
  81. package/dist/tour/tour-guide-button.js +45 -0
  82. package/dist/tour/tour-guide-button.js.map +1 -0
  83. package/dist/tour/tour-guide-labels.d.ts +8 -0
  84. package/dist/tour/tour-guide-labels.js +9 -0
  85. package/dist/tour/tour-guide-labels.js.map +1 -0
  86. package/dist/tour/tour-labels.js +9 -1
  87. package/dist/tour/tour-labels.js.map +1 -1
  88. package/dist/tour/tour.d.ts +133 -9
  89. package/dist/tour/tour.js +594 -70
  90. package/dist/tour/tour.js.map +1 -1
  91. package/dist/tour/use-tour-url-sync.d.ts +5 -2
  92. package/dist/tour/use-tour-url-sync.js +7 -1
  93. package/dist/tour/use-tour-url-sync.js.map +1 -1
  94. package/dist/tour.d.ts +2 -0
  95. package/dist/tour.js +3 -0
  96. package/dist/tour.js.map +1 -1
  97. package/dist/wizard/stepper-nav.js +15 -2
  98. package/dist/wizard/stepper-nav.js.map +1 -1
  99. package/dist/wizard/types.d.ts +11 -0
  100. package/dist/wizard/types.js.map +1 -1
  101. package/dist/wizard/use-wizard.js +11 -5
  102. package/dist/wizard/use-wizard.js.map +1 -1
  103. package/package.json +9 -1
  104. package/src/admin/user-roster.tsx +22 -12
  105. package/src/auth/verify-email.tsx +10 -0
  106. package/src/components/amount-input.tsx +5 -4
  107. package/src/components/autocomplete.tsx +2 -0
  108. package/src/components/combobox-core.tsx +2 -0
  109. package/src/components/combobox-suggestions.tsx +2 -0
  110. package/src/components/currency-select.tsx +95 -6
  111. package/src/components/data-table-mobile.tsx +99 -8
  112. package/src/components/data-table-row.tsx +1 -0
  113. package/src/components/data-table.tsx +9 -1
  114. package/src/components/dropdown.tsx +2 -0
  115. package/src/components/full-bleed-dialog.tsx +3 -0
  116. package/src/components/modal.tsx +3 -0
  117. package/src/components/number-input.tsx +8 -3
  118. package/src/components/numpad-sheet.tsx +4 -0
  119. package/src/components/picker-sheet.tsx +4 -0
  120. package/src/components/popover.tsx +2 -0
  121. package/src/i18n/defaults.ts +2 -0
  122. package/src/i18n/german.ts +13 -0
  123. package/src/i18n/kit-labels.tsx +3 -0
  124. package/src/i18n/locales/es.ts +13 -0
  125. package/src/i18n/locales/fr.ts +13 -0
  126. package/src/i18n/locales/hu.ts +13 -0
  127. package/src/i18n/locales/it.ts +13 -0
  128. package/src/i18n/locales/zh.ts +13 -0
  129. package/src/i18n/review.ts +1 -0
  130. package/src/lib/data-attributes.ts +14 -0
  131. package/src/lib/format.ts +2 -0
  132. package/src/lib/minus.ts +10 -0
  133. package/src/testing/react.ts +59 -0
  134. package/src/tour/tour-dock.ts +132 -0
  135. package/src/tour/tour-gates.ts +195 -0
  136. package/src/tour/tour-guide-button.tsx +64 -0
  137. package/src/tour/tour-guide-labels.ts +15 -0
  138. package/src/tour/tour-labels.ts +8 -0
  139. package/src/tour/tour.tsx +949 -86
  140. package/src/tour/use-tour-url-sync.ts +14 -4
  141. package/src/tour.ts +3 -0
  142. package/src/wizard/stepper-nav.tsx +23 -3
  143. package/src/wizard/types.ts +12 -0
  144. package/src/wizard/use-wizard.ts +16 -9
package/src/tour/tour.tsx CHANGED
@@ -3,29 +3,39 @@ 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";
18
23
  import type { Direction } from "../lib/direction";
19
24
  import { DEFAULT_TOUR_LABELS } from "./tour-labels";
20
25
  import type { TourInfo } from "./tour-info";
21
26
  import { completeStored, rememberAccount, rememberMigrate, type UseTourProgressOptions } from "./use-tour-progress";
27
+ import { createGateRegistry, devWarnings, TourInternalContext, type GateRegistry } from "./tour-gates";
28
+ import { DOCK_MAX_WIDTH, dockEdge, dockStyle, readBand, scrollIntoFreeHalf, type DockEdge } from "./tour-dock";
29
+
30
+ export { useTourGate, useTourGateRelease } from "./tour-gates";
31
+ export type { TourGateRelease } from "./tour-gates";
22
32
 
23
33
  /**
24
34
  * `start`/`end` follow the reading direction (`end` is the right side in LTR and the
25
35
  * left side in RTL) and are what a translated app wants. `left`/`right` stay physical
26
36
  * for the steps that genuinely mean a screen side.
27
37
  */
28
- export type TourPlacement = "top" | "right" | "bottom" | "left" | "start" | "end" | "center";
38
+ export type TourPlacement = "top" | "right" | "bottom" | "left" | "start" | "end" | "center" | "dock";
29
39
 
30
40
  export interface TourStep {
31
41
  /** CSS selector for the element to spotlight. Omit for a centered step
@@ -35,13 +45,22 @@ export interface TourStep {
35
45
  body: ReactNode;
36
46
  /** Extra footer content, e.g. a "load sample data" shortcut. */
37
47
  action?: ReactNode;
38
- /** Spotlight padding around the target, px (default 8). */
48
+ /** Spotlight padding around the target, px (default 8). On a field step the spot also
49
+ * takes in the lines the field's `aria-describedby` names when they sit within the
50
+ * ring's reach of it (a hint or warning line under the field, keksdose S1, §11.6). */
39
51
  padding?: number;
40
52
  /** Where the card sits relative to the target. Omit for auto placement
41
53
  * (below → above → centered). "center" ignores the target and centers on
42
54
  * screen. "start"/"end" are the logical sides and flip in RTL; "left"/"right"
43
55
  * are physical. An explicit side flips to the opposite side if it would
44
- * overflow, then clamps into the viewport. */
56
+ * overflow, then clamps into the viewport.
57
+ *
58
+ * `"dock"` (0.50, §11.7): below `sm` (640 px) the card is pinned full-width to the top
59
+ * or bottom edge of the usable band — the visual viewport less the keyboard and any
60
+ * `[data-tour-obstruction]` (the number pad) — on the edge away from the field, and the
61
+ * field's scroll container brings it into the other half. From `sm` up it is auto
62
+ * placement. On a phone a field step (`interactive`, `awaitInput`, `gate`, `awaitGate`)
63
+ * with a target and no `placement` docks by default (`TourProvider dockOnPhones`). */
45
64
  placement?: TourPlacement;
46
65
  /** Advance only when the user clicks the spotlighted target itself (not the
47
66
  * card's Next button). Requires `target`: the real control's own handler runs
@@ -76,9 +95,59 @@ export interface TourStep {
76
95
  * button) has nothing to compare: call `useTour().next()` from its `onChange`.
77
96
  */
78
97
  awaitInput?: boolean;
98
+ /**
99
+ * 0.50 (docs/tours-harmonization.md §11.1): Next stays locked until the gate `key` is
100
+ * satisfied — the form publishes it with `useTourGate(key, satisfied)` beside the field.
101
+ * Implies `interactive`. The locked Next is `aria-disabled` with {@link gateHint} under
102
+ * it; pressing it focuses the field. Once satisfied the tour does NOT advance by itself:
103
+ * Next, Enter in the field, or focus leaving the field to nowhere or to the card (the
104
+ * number pad's Done) moves on. A key nobody publishes is not enforced (the form closed
105
+ * under the tour): Next opens. A step may have a `gate` and no `target`.
106
+ */
107
+ gate?: string;
108
+ /** Overrides `labels.gateHint` for this step. */
109
+ gateHint?: string;
110
+ /**
111
+ * 0.50 (§11.4): wait for the app to RELEASE this key — `useTourGateRelease().release(key)`
112
+ * from the save's `onSuccess`. Implies `interactive`; usually it targets the Save button,
113
+ * whose hole stays click-through. Next is replaced by {@link awaitGateHint}, and a click
114
+ * does not advance: only `release` does. A release that arrives on an earlier step (Enter
115
+ * in the last field submitted the form) jumps past the first later step awaiting the key.
116
+ * `refuse(key, reason)` keeps the step and shows the reason, with a "Back to …" link to the
117
+ * first earlier field whose gate is now unsatisfied; the kit never jumps there itself.
118
+ * A viewer who cannot save gets `awaitGate: undefined`, so a plain Next.
119
+ */
120
+ awaitGate?: string;
121
+ /** Overrides `labels.awaitGateHint` for this step. */
122
+ awaitGateHint?: string;
123
+ /**
124
+ * 0.50 (§11.2): the field may stay empty. While the `gate` is unsatisfied the card
125
+ * offers "Leave empty" beside the locked Next, which moves on without touching the field
126
+ * (the kit never writes app values); the title carries an "Optional" chip. Not a skip:
127
+ * the run continues and can complete. Without a `gate`, only the chip.
128
+ */
129
+ optional?: boolean;
130
+ /**
131
+ * 0.50 (§11.3): shown only if this returns true on ARRIVAL (evaluated before
132
+ * `beforeStep`, so a skipped step never navigates). Next walks forward to the first step
133
+ * shown, Back to the nearest earlier one shown now. Branch facts are gates:
134
+ * `when: (c) => c.gate("txn.transfer") === true`. Keep it pure and cheap: the counter
135
+ * evaluates every step's `when` on each arrival. A throw counts as skipped.
136
+ */
137
+ when?: (ctx: TourStepContext) => boolean;
79
138
  /** 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>;
139
+ * May be async; the step waits for it, then locates the target. Receives the
140
+ * {@link TourStepContext} (0.50); a step entered by "Guide me" sees `via: "at"` and
141
+ * should leave an open form alone. */
142
+ beforeStep?: (ctx: TourStepContext) => void | Promise<void>;
143
+ }
144
+
145
+ /** What a step's `when` and `beforeStep` may ask on arrival (0.50, §11.3). */
146
+ export interface TourStepContext {
147
+ /** The gate's current value: true/false, or undefined while nobody publishes it. */
148
+ gate(key: string): boolean | undefined;
149
+ /** How this step is being entered. */
150
+ via: "start" | "at" | "next" | "back" | "goto";
82
151
  }
83
152
 
84
153
  export interface TourLabels {
@@ -90,13 +159,49 @@ export interface TourLabels {
90
159
  awaitClickHint: string;
91
160
  /** Shown in place of the Next button on an `awaitInput` step (0.46). */
92
161
  awaitInputHint: string;
93
- /** Step counter, e.g. (2, 7) => "2 / 7". */
162
+ /** Step counter, e.g. (2, 7) => "2 / 7". Counts the steps the run shows (0.50). */
94
163
  step: (current: number, total: number) => string;
164
+ /** 0.50: an `optional` gate step's button while the field is empty. */
165
+ leaveEmpty: string;
166
+ /** 0.50: the chip beside an `optional` step's title. */
167
+ optional: string;
168
+ /** 0.50: under a `gate` step's locked Next (`TourStep.gateHint` overrides it). */
169
+ gateHint: string;
170
+ /** 0.50: announced (screen readers only) when a gate step's field is filled. */
171
+ gateReady: string;
172
+ /** 0.50: in place of Next on an `awaitGate` step (`TourStep.awaitGateHint` overrides it). */
173
+ awaitGateHint: string;
174
+ /** 0.50: the warning when the app `refuse`s an `awaitGate` step without a reason. */
175
+ awaitGateRefused: string;
176
+ /** 0.50: the link to an earlier step whose field was emptied, after a refused save. */
177
+ backToStep: (title: string) => string;
95
178
  }
96
179
 
97
180
  // Defined in ./tour-labels, which holds no component code (0.34, KS U8).
98
181
  export { DEFAULT_TOUR_LABELS };
99
182
 
183
+ /** 0.50 (§11.5): a step index, or the `data-tour` value of the step's anchor. */
184
+ export type TourAt = number | string;
185
+
186
+ /** `useTour().start`, both forms: what `TourProvider` hands its `launch` as the third argument. */
187
+ export type TourStart = TourContextValue["start"];
188
+
189
+ /**
190
+ * 0.50 (§11.5): the app's launcher — the shape `useTourUrlSync` already takes, plus `at`.
191
+ * It builds the steps (`prepare()`, `build()`, `tour: tourInfo(def)`) and passes `at` on to
192
+ * `start(steps, { …, at })`. Give the same function to `TourProvider launch` and to
193
+ * `useTourUrlSync`.
194
+ *
195
+ * `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
+ */
199
+ export type TourLauncher = (
200
+ id: string,
201
+ opts: { startIndex?: number; at?: TourAt },
202
+ start: TourStart,
203
+ ) => void | Promise<unknown>;
204
+
100
205
  interface StartOptions {
101
206
  /** Stable id of the tour, surfaced as `activeId` so the app can mirror it to
102
207
  * the URL (deep-link / evaluation tracking) and record completion. Defaults to
@@ -108,6 +213,15 @@ interface StartOptions {
108
213
  tour?: TourInfo;
109
214
  /** Start at this step index instead of 0 (clamped) — for URL/deep-link resume. */
110
215
  startIndex?: number;
216
+ /**
217
+ * 0.50 (§11.5): start here AND make it the floor — earlier steps never run (nor their
218
+ * `beforeStep`), Back stops here, `position`/`count` count from here, and the finish
219
+ * records the completion as a full run does (Marcel, Q2). A number is a step index
220
+ * (clamped); a string is the first step whose `target` contains `data-tour="<at>"`
221
+ * verbatim (unknown: 0, with a development warning). The `at` step is entered with
222
+ * `via: "at"`. With `startIndex` too, `startIndex` picks the step and `at` the floor.
223
+ */
224
+ at?: TourAt;
111
225
  /** Fires synchronously when the tour starts (e.g. enable a mock interceptor). */
112
226
  onStart?: () => void;
113
227
  /** After the last step: runs after the provider's progress write and `onComplete`. */
@@ -131,8 +245,17 @@ interface TourContextValue {
131
245
  active: boolean;
132
246
  /** Id of the running tour (from {@link StartOptions.id}), else null. */
133
247
  activeId: string | null;
134
- /** Start a tour with the given steps. */
135
- start: (steps: TourStep[], opts?: StartOptions) => void;
248
+ /**
249
+ * Start a tour with the given steps — or, by id (0.50), through the provider's `launch`:
250
+ * `start("create-transaction", { at: "txn-entry-mode" })`. Without a `launch` the id form
251
+ * does nothing (a development build warns).
252
+ */
253
+ start: {
254
+ // The id form first: `Parameters<typeof start>` reads the LAST overload, which stays
255
+ // the 0.49 one.
256
+ (id: string, opts?: { at?: TourAt }): void;
257
+ (steps: TourStep[], opts?: StartOptions): void;
258
+ };
136
259
  /** End the current tour (no callbacks fire). */
137
260
  stop: () => void;
138
261
  /** Advance one step; past the last step it finishes the tour (fires onFinish). */
@@ -145,6 +268,10 @@ interface TourContextValue {
145
268
  index: number;
146
269
  /** Total steps (0 while inactive). */
147
270
  total: number;
271
+ /** 0.50: 1-based position among the steps this run shows, for "3 of 9"; 0 while inactive. */
272
+ position: number;
273
+ /** 0.50: how many steps this run shows, from its first (the `at` floor) to the end; 0 while inactive. */
274
+ count: number;
148
275
  }
149
276
 
150
277
  const TourContext = createContext<TourContextValue | null>(null);
@@ -179,11 +306,6 @@ export function useTourOptional(): TourContextValue | null {
179
306
  return useContext(TourContext);
180
307
  }
181
308
 
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
309
  /**
188
310
  * Provides the guided-tour engine: a dimmed spotlight overlay + step card driven
189
311
  * by a list of {@link TourStep}s. Domain-free — the app supplies the steps (copy,
@@ -210,28 +332,47 @@ export function TourProvider({
210
332
  labels,
211
333
  progress,
212
334
  onComplete,
335
+ launch,
336
+ dockOnPhones = true,
213
337
  }: {
214
338
  children: ReactNode;
215
339
  labels?: LabelOverride<TourLabels>;
340
+ /**
341
+ * 0.50 (§11.5): lets `start(id)` and `<TourGuideButton>` start a tour by id. Usually the
342
+ * same function the app gives `useTourUrlSync`.
343
+ */
344
+ launch?: TourLauncher;
216
345
  /** The app's tour progress: the same options its `useTourProgress` readers use. */
217
346
  progress?: UseTourProgressOptions;
218
347
  /** A tour's last step was finished; see {@link TourCompletion}. */
219
348
  onComplete?: (tour: TourInfo | undefined, completion: TourCompletion) => void;
349
+ /**
350
+ * 0.50 (§11.7, Marcel's Q1): below `sm`, a field step with a target and no `placement`
351
+ * docks. Default `true`; `false` makes docking strictly opt-in (`placement: "dock"`).
352
+ */
353
+ dockOnPhones?: boolean;
220
354
  }) {
221
355
  // `labels` over the provider's `tour` over English. Resolved once here and handed
222
356
  // down: the step card is portalled, but context crosses portals anyway — this is
223
357
  // simply the one place that already owned the merge.
224
358
  const merged = useKitLabels("tour", DEFAULT_TOUR_LABELS, labels);
225
359
  const [steps, setSteps] = useState<TourStep[] | null>(null);
226
- const [index, setIndex] = useState(0);
360
+ // Where the run is: the raw index (what `?tourStep=` mirrors), how it got there, and
361
+ // its place among the steps shown (§11.3), computed once on arrival.
362
+ const [nav, setNav] = useState<Nav>(NO_NAV);
363
+ const index = nav.index;
364
+ // The app's last `refuse` on the step awaiting its key; any arrival clears it.
365
+ const [refusal, setRefusal] = useState<Refusal | null>(null);
227
366
  const [activeId, setActiveId] = useState<string | null>(null);
228
367
  const optsRef = useRef<StartOptions>({});
229
368
  // The latest props, read at the finish: a new `storageKey` or callback needs no remount.
230
369
  const progressRef = useRef(progress);
231
370
  const onCompleteRef = useRef(onComplete);
371
+ const launchRef = useRef(launch);
232
372
  useLayoutEffect(() => {
233
373
  progressRef.current = progress;
234
374
  onCompleteRef.current = onComplete;
375
+ launchRef.current = launch;
235
376
  });
236
377
  // The provider's migrate becomes the key's, for readers that pass none (keksdose B-2).
237
378
  const storageKey = progress?.storageKey;
@@ -253,16 +394,97 @@ export function TourProvider({
253
394
  stepsRef.current = steps;
254
395
  indexRef.current = index;
255
396
  }, [steps, index]);
397
+ /** The run's first step: earlier steps never run, Back stops here (the `at` floor). */
398
+ const floorRef = useRef(0);
256
399
  const active = !!steps && steps.length > 0;
257
400
  const total = steps?.length ?? 0;
401
+ // The gate registry (§11.1): one per provider, read by the overlay while a tour runs.
402
+ const [gates] = useState(createGateRegistry);
403
+ useLayoutEffect(() => {
404
+ gates.setActive(active);
405
+ }, [gates, active]);
406
+ const canLaunch = !!launch;
407
+ const internals = useMemo(() => ({ gates, canLaunch }), [gates, canLaunch]);
258
408
 
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
- }, []);
409
+ /** Is step `i` shown, asked now (§11.3)? */
410
+ const shown = useCallback(
411
+ (s: TourStep[], i: number, via: TourStepContext["via"]) => isShown(s[i], stepContext(gates, via)),
412
+ [gates],
413
+ );
414
+ /** Arrive at `i`: the counter is recomputed from every step's `when`, against the gates now. */
415
+ const arrive = useCallback(
416
+ (s: TourStep[], i: number, via: TourStepContext["via"]) => {
417
+ const ctx = stepContext(gates, via);
418
+ let position = 0;
419
+ let count = 0;
420
+ for (let j = floorRef.current; j < s.length; j++) {
421
+ if (j !== i && !isShown(s[j], ctx)) continue;
422
+ count++;
423
+ if (j <= i) position++;
424
+ }
425
+ indexRef.current = i;
426
+ setRefusal(null);
427
+ setNav({ index: i, floor: floorRef.current, via, position, count });
428
+ },
429
+ [gates],
430
+ );
431
+ /** The first step shown at or after `from`, or -1. */
432
+ const forward = useCallback(
433
+ (s: TourStep[], from: number, via: TourStepContext["via"]) => {
434
+ for (let j = Math.max(from, floorRef.current); j < s.length; j++) if (shown(s, j, via)) return j;
435
+ return -1;
436
+ },
437
+ [shown],
438
+ );
439
+ /** The nearest step shown at or before `from`, not below the floor, or -1. */
440
+ const backward = useCallback(
441
+ (s: TourStep[], from: number, via: TourStepContext["via"]) => {
442
+ for (let j = Math.min(from, s.length - 1); j >= floorRef.current; j--) if (shown(s, j, via)) return j;
443
+ return -1;
444
+ },
445
+ [shown],
446
+ );
447
+
448
+ const startSteps = useCallback(
449
+ (s: TourStep[], opts?: StartOptions) => {
450
+ optsRef.current = opts ?? {};
451
+ optsRef.current.onStart?.();
452
+ const last = Math.max(0, s.length - 1);
453
+ floorRef.current = opts?.at === undefined ? 0 : resolveAt(s, opts.at);
454
+ const via: TourStepContext["via"] = opts?.at === undefined ? "start" : "at";
455
+ const wanted = Math.min(Math.max(floorRef.current, opts?.startIndex ?? floorRef.current), last);
456
+ let to = forward(s, wanted, via);
457
+ if (to < 0) to = backward(s, wanted, via);
458
+ // Not one step of the run is shown: there is nothing to tour.
459
+ if (to < 0) return;
460
+ arrive(s, to, via);
461
+ setActiveId(opts?.id ?? opts?.tour?.id ?? null);
462
+ setSteps(s);
463
+ },
464
+ [arrive, forward, backward],
465
+ );
466
+ // The provider's own `start`, handed to `launch` as its third argument (keksdose N4).
467
+ const ownStartRef = useRef<TourContextValue["start"] | null>(null);
468
+ const start = useCallback(
469
+ (what: TourStep[] | string, opts?: StartOptions | { at?: TourAt }) => {
470
+ if (typeof what !== "string") {
471
+ startSteps(what, opts as StartOptions | undefined);
472
+ return;
473
+ }
474
+ const run = launchRef.current;
475
+ if (!run) {
476
+ if (devWarnings()) console.warn(`[ui-kit] start("${what}") needs TourProvider's \`launch\` prop.`);
477
+ return;
478
+ }
479
+ void Promise.resolve(run(what, { at: opts?.at }, ownStartRef.current!)).catch((error: unknown) =>
480
+ console.error(error),
481
+ );
482
+ },
483
+ [startSteps],
484
+ ) as TourContextValue["start"];
485
+ useLayoutEffect(() => {
486
+ ownStartRef.current = start;
487
+ }, [start]);
266
488
  const stop = useCallback(() => {
267
489
  setSteps(null);
268
490
  setActiveId(null);
@@ -301,38 +523,153 @@ export function TourProvider({
301
523
  setActiveId(null);
302
524
  }, []);
303
525
 
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
- }, []);
526
+ // A step whose `when` is false is passed over: `goToStep` lands on the next step shown
527
+ // after it, failing that the previous one; Next past the last shown step finishes.
528
+ const goToStep = useCallback(
529
+ (i: number) => {
530
+ const s = stepsRef.current;
531
+ if (!s || s.length === 0) return;
532
+ const wanted = Math.min(Math.max(floorRef.current, i), s.length - 1);
533
+ let to = forward(s, wanted, "goto");
534
+ if (to < 0) to = backward(s, wanted, "goto");
535
+ if (to >= 0) arrive(s, to, "goto");
536
+ },
537
+ [arrive, forward, backward],
538
+ );
309
539
  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]);
540
+ const s = stepsRef.current;
541
+ if (!s || s.length === 0) return;
542
+ const to = forward(s, indexRef.current + 1, "next");
543
+ if (to < 0) finish();
544
+ else arrive(s, to, "next");
545
+ }, [arrive, forward, finish]);
315
546
  const prev = useCallback(() => {
316
- if (indexRef.current > 0) setIndex(indexRef.current - 1);
317
- }, []);
547
+ const s = stepsRef.current;
548
+ if (!s) return;
549
+ const to = backward(s, indexRef.current - 1, "back");
550
+ if (to >= 0) arrive(s, to, "back");
551
+ }, [arrive, backward]);
552
+
553
+ // awaitGate (§11.4): `release` and `refuse` are events, heard only while a tour runs.
554
+ useEffect(() => {
555
+ if (!active) return;
556
+ let seq = 0;
557
+ return gates.listen((signal) => {
558
+ const s = stepsRef.current;
559
+ if (!s) return;
560
+ const i = indexRef.current;
561
+ if (signal.kind === "refuse") {
562
+ // On any other step the form's own errors speak.
563
+ if (s[i]?.awaitGate === signal.key) setRefusal({ step: s[i], reason: signal.reason, seq: ++seq });
564
+ return;
565
+ }
566
+ // The step awaiting it, or the first later one: the record is saved, so the fields
567
+ // between are passed over.
568
+ const awaiting = s[i]?.awaitGate === signal.key ? i : s.findIndex((st, k) => k > i && st.awaitGate === signal.key);
569
+ if (awaiting < 0) return;
570
+ const to = forward(s, awaiting + 1, "next");
571
+ if (to < 0) finish();
572
+ else arrive(s, to, "next");
573
+ });
574
+ }, [active, gates, arrive, forward, finish]);
318
575
 
319
576
  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>
577
+ <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
+ >
593
+ {children}
594
+ {active && (
595
+ <TourOverlay
596
+ steps={steps!}
597
+ index={index}
598
+ via={nav.via}
599
+ floor={nav.floor}
600
+ position={nav.position}
601
+ count={nav.count}
602
+ refusal={refusal?.step === steps![index] ? refusal : null}
603
+ onGoTo={goToStep}
604
+ labels={merged}
605
+ gates={gates}
606
+ dockOnPhones={dockOnPhones}
607
+ onNext={next}
608
+ onPrev={prev}
609
+ onSkip={skip}
610
+ />
611
+ )}
612
+ </TourContext.Provider>
613
+ </TourInternalContext.Provider>
333
614
  );
334
615
  }
335
616
 
617
+ interface Nav {
618
+ index: number;
619
+ /** The run's first step (the `at` floor). */
620
+ floor: number;
621
+ via: TourStepContext["via"];
622
+ position: number;
623
+ count: number;
624
+ }
625
+
626
+ const NO_NAV: Nav = { index: 0, floor: 0, via: "start", position: 0, count: 0 };
627
+
628
+ /** The app's `refuse` on the step that awaits its key (§11.4). */
629
+ interface Refusal {
630
+ step: TourStep;
631
+ reason?: string;
632
+ /** Bumped per refusal, so the same words are announced again. */
633
+ seq: number;
634
+ }
635
+
636
+ const NOOP = () => {};
637
+
638
+ function stepContext(gates: GateRegistry, via: TourStepContext["via"]): TourStepContext {
639
+ return { gate: (key) => gates.get(key), via };
640
+ }
641
+
642
+ /** `StartOptions.at` as a step index: a number clamped, an anchor's first step, else 0. */
643
+ function resolveAt(steps: TourStep[], at: TourAt): number {
644
+ if (typeof at === "number") return Math.min(Math.max(0, Math.floor(at)), Math.max(0, steps.length - 1));
645
+ const anchor = `data-tour="${at}"`;
646
+ const found = steps.findIndex((step) => step.target?.includes(anchor));
647
+ if (found >= 0) return found;
648
+ if (devWarnings()) console.warn(`[ui-kit] A tour was started at "${at}", but no step targets [${anchor}]: it starts at 0.`);
649
+ return 0;
650
+ }
651
+
652
+ /** The first step in [floor, index) shown now whose gate is published and unsatisfied, or -1. */
653
+ function firstEmptied(steps: TourStep[], floor: number, index: number, gates: GateRegistry): number {
654
+ const ctx = stepContext(gates, "goto");
655
+ for (let j = floor; j < index; j++) {
656
+ const key = steps[j].gate;
657
+ if (key && gates.get(key) === false && isShown(steps[j], ctx)) return j;
658
+ }
659
+ return -1;
660
+ }
661
+
662
+ /** A step's `when`, asked now; a throw is a skip, and the error goes to the console. */
663
+ function isShown(step: TourStep, ctx: TourStepContext): boolean {
664
+ if (!step.when) return true;
665
+ try {
666
+ return step.when(ctx) === true;
667
+ } catch (error) {
668
+ console.error(error);
669
+ return false;
670
+ }
671
+ }
672
+
336
673
  interface Rect {
337
674
  top: number;
338
675
  left: number;
@@ -370,6 +707,59 @@ function rectOf(el: Element): Rect {
370
707
  return { top: r.top, left: r.left, width: r.width, height: r.height };
371
708
  }
372
709
 
710
+ /** The two-tone frame's width outside the spot (3px white, 2px keyline), plus a pixel. */
711
+ const RING_BAND = 6;
712
+
713
+ /**
714
+ * What a step's ring frames (keksdose S1, §11.6): the target, and on a FIELD step also the
715
+ * lines that describe the field from outside it — the elements its `aria-describedby`
716
+ * (on the target or a control inside) names that lie within the ring's reach of it (a
717
+ * caption, a hint or warning line, an error drawn under or above the field). Without
718
+ * them the ring's frame ran through such a line; with them it frames field and line, and
719
+ * the line is lit, not under the scrim. A kit field's own caption and error are inside
720
+ * its root already. A line far away (a tooltip, another part of the page) is not taken.
721
+ */
722
+ function spotRectOf(el: Element, step: TourStep): Rect {
723
+ const own = rectOf(el);
724
+ if (!isFieldStep(step)) return own;
725
+ const ids = new Set<string>();
726
+ const holders = [el, ...Array.from(el.querySelectorAll("[aria-describedby]"))];
727
+ for (const holder of holders) {
728
+ for (const id of (holder.getAttribute("aria-describedby") ?? "").split(/\s+/)) if (id) ids.add(id);
729
+ }
730
+ if (ids.size === 0) return own;
731
+ const lines: DOMRect[] = [];
732
+ for (const id of ids) {
733
+ const line = document.getElementById(id);
734
+ if (!line || el.contains(line) || line.closest('[role="tooltip"]') || !isVisible(line)) continue;
735
+ const r = line.getBoundingClientRect();
736
+ // An `sr-only` description measures 1×1: nothing to frame.
737
+ if (r.width > 1 && r.height > 1) lines.push(r);
738
+ }
739
+ let top = own.top;
740
+ let left = own.left;
741
+ let right = own.left + own.width;
742
+ let bottom = own.top + own.height;
743
+ const reach = (step.padding ?? 8) + RING_BAND;
744
+ // Grow until nothing more is in reach: a hint under the field, then an error under it.
745
+ for (let grew = true; grew; ) {
746
+ grew = false;
747
+ for (let i = lines.length - 1; i >= 0; i--) {
748
+ const r = lines[i];
749
+ const across = r.left < right && r.right > left;
750
+ const gap = Math.max(r.top - bottom, top - r.bottom);
751
+ if (!across || gap > reach) continue;
752
+ top = Math.min(top, r.top);
753
+ left = Math.min(left, r.left);
754
+ right = Math.max(right, r.right);
755
+ bottom = Math.max(bottom, r.bottom);
756
+ lines.splice(i, 1);
757
+ grew = true;
758
+ }
759
+ }
760
+ return { top, left, width: right - left, height: bottom - top };
761
+ }
762
+
373
763
  /**
374
764
  * What the finder established for ONE step: where its target is (`null` = no target,
375
765
  * or not on the page), and the reading direction the card takes from it.
@@ -400,26 +790,117 @@ interface Located {
400
790
  step: TourStep;
401
791
  rect: Rect | null;
402
792
  dir: Direction;
793
+ /**
794
+ * A field step's own container (§11.6): inserted right after the kit layer holding the
795
+ * target (`host.after`), or at the end of <body> on the page. `null` on a plain step,
796
+ * which is portalled to <body> at z-[60] as ever.
797
+ */
798
+ layer: HTMLElement | null;
799
+ /** The host fills the visual viewport (a phone's FullBleedDialog): no scrim, no shields. */
800
+ fullScreen: boolean;
801
+ }
802
+
803
+ /** The kit layer (`data-kit-layer`) holding `el`, if any. */
804
+ function layerHost(el: Element): HTMLElement | null {
805
+ return el.closest<HTMLElement>("[data-kit-layer]");
806
+ }
807
+
808
+ /**
809
+ * Does the host fill the visual viewport? A backdrop host (`data-kit-layer="backdrop"`:
810
+ * Modal, FullBleedDialog) is judged by its panel, the child holding the target.
811
+ */
812
+ function coversViewport(host: HTMLElement, el: Element): boolean {
813
+ let surface: Element = host;
814
+ if (host.getAttribute("data-kit-layer") === "backdrop") {
815
+ for (const child of Array.from(host.children)) {
816
+ if (child.contains(el)) {
817
+ surface = child;
818
+ break;
819
+ }
820
+ }
821
+ }
822
+ const r = surface.getBoundingClientRect();
823
+ const vv = window.visualViewport;
824
+ const top = vv?.offsetTop ?? 0;
825
+ const bottom = top + (vv?.height ?? window.innerHeight);
826
+ const width = vv?.width ?? window.innerWidth;
827
+ return r.width > 0 && r.left <= 1 && r.top <= top + 1 && r.right >= width - 1 && r.bottom >= bottom - 1;
828
+ }
829
+
830
+ /** Does this step's card dock now (§11.7)? A phone-width viewport, and `"dock"` or a field
831
+ * step with no placement while the provider docks on phones. */
832
+ function docks(step: TourStep, dockOnPhones: boolean): boolean {
833
+ if (typeof window === "undefined" || window.innerWidth >= DOCK_MAX_WIDTH || !step.target) return false;
834
+ return step.placement === "dock" || (step.placement === undefined && dockOnPhones && isFieldStep(step));
835
+ }
836
+
837
+ /** A step whose control is for using: its card joins the layer stack above the field (§11.6). */
838
+ function isFieldStep(step: TourStep): boolean {
839
+ return !!(step.interactive || step.awaitInput || step.gate || step.awaitGate);
840
+ }
841
+
842
+ /** The first control in `el` (or `el` itself) a person would type into or press. */
843
+ function firstFocusable(el: Element): HTMLElement | null {
844
+ if (el instanceof HTMLElement && el.matches(FOCUSABLE_CONTROL)) return el;
845
+ return el.querySelector<HTMLElement>(FOCUSABLE_CONTROL);
846
+ }
847
+
848
+ /** The tab stops inside `el`, in DOM order (the focus trap's selector). */
849
+ function tabStops(el: Element): HTMLElement[] {
850
+ const all = el.matches(FOCUSABLE_CONTROL) ? [el as HTMLElement] : [];
851
+ return all.concat(
852
+ Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE_CONTROL)).filter(
853
+ (x) => x.tabIndex >= 0 && !x.closest("[hidden],[inert]"),
854
+ ),
855
+ );
856
+ }
857
+
858
+ const FOCUSABLE_CONTROL =
859
+ 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
860
+
861
+ /** A touch screen: the card takes focus, so no keyboard or pad opens before the step is read (§11.8). */
862
+ function coarsePointer(): boolean {
863
+ return typeof window !== "undefined" && typeof window.matchMedia === "function"
864
+ ? window.matchMedia("(pointer: coarse)").matches
865
+ : false;
403
866
  }
404
867
 
405
868
  function TourOverlay({
406
869
  steps,
407
870
  index,
871
+ via,
872
+ floor,
873
+ position,
874
+ count,
875
+ refusal,
876
+ onGoTo,
408
877
  labels,
878
+ gates,
879
+ dockOnPhones,
409
880
  onNext,
410
881
  onPrev,
411
882
  onSkip,
412
883
  }: {
413
884
  steps: TourStep[];
414
885
  index: number;
886
+ via: TourStepContext["via"];
887
+ floor: number;
888
+ /** The app refused this step's save (§11.4). */
889
+ refusal: Refusal | null;
890
+ onGoTo: (index: number) => void;
891
+ /** 1-based among the steps shown; Back is hidden on the first, Done on the last. */
892
+ position: number;
893
+ count: number;
415
894
  labels: TourLabels;
895
+ gates: GateRegistry;
896
+ dockOnPhones: boolean;
416
897
  onNext: () => void;
417
898
  onPrev: () => void;
418
899
  onSkip: () => void;
419
900
  }) {
420
901
  const step = steps[index];
421
- const isFirst = index === 0;
422
- const isLast = index === steps.length - 1;
902
+ const isFirst = position <= 1;
903
+ const isLast = position >= count;
423
904
  // A new step starts unlocated: whatever was found for another step (see `Located`)
424
905
  // is ignored, so the overlay renders nothing until the effect below has located THIS
425
906
  // step, and never the previous step's spotlight.
@@ -432,16 +913,46 @@ function TourOverlay({
432
913
  // step). Arrow keys and start/end placement read it.
433
914
  const dir: Direction = current?.dir ?? "ltr";
434
915
  const rootRef = useRef<HTMLDivElement>(null);
916
+ const cardRef = useRef<HTMLDivElement>(null);
917
+ const bodyId = useId();
918
+ const hintId = useId();
919
+
920
+ // The gate (§11.1). Re-rendered on every registry change while the tour runs.
921
+ const gateVersion = useSyncExternalStore(gates.subscribe, gates.version, gates.version);
922
+ const gateKey = step.gate;
923
+ const enforced = !!gateKey && gates.has(gateKey);
924
+ const locked = enforced && gates.get(gateKey) !== true;
925
+
926
+ // The polite live region (§11.8): `gateReady` once a gate opens, the hint on a locked
927
+ // Next. A repeat of the same words gets a trailing zero-width space so it is read again.
928
+ const [polite, setPolite] = useState("");
929
+ const announce = useCallback((text: string) => {
930
+ setPolite((prev) => (prev === text ? `${text}\u200b` : text));
931
+ }, []);
435
932
 
436
933
  // Run beforeStep, then locate the target (retrying across route/render lag).
437
934
  useEffect(() => {
438
935
  let cancelled = false;
439
936
  let raf = 0;
440
937
  // `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) });
938
+ const settle = (el: Element | null) => {
939
+ let layer: HTMLElement | null = null;
940
+ let fullScreen = false;
941
+ if (el && isFieldStep(step)) {
942
+ // §11.6: directly above the layer holding the field — DOM order decides between
943
+ // the kit's z-50 body children, so this paints over the host and everything opened
944
+ // before it, and under whatever the field opens next (a list, the number pad).
945
+ const host = layerHost(el);
946
+ layer = document.createElement("div");
947
+ layer.setAttribute("data-tour-layer", "");
948
+ if (host) host.after(layer);
949
+ else document.body.appendChild(layer);
950
+ fullScreen = !!host && coversViewport(host, el);
951
+ }
952
+ setLocated({ index, step, rect: el ? spotRectOf(el, step) : null, dir: dirOf(el), layer, fullScreen });
953
+ };
443
954
  void (async () => {
444
- await step.beforeStep?.();
955
+ await step.beforeStep?.(stepContext(gates, via));
445
956
  if (cancelled) return;
446
957
  if (!step.target) {
447
958
  settle(null);
@@ -452,7 +963,10 @@ function TourOverlay({
452
963
  if (cancelled) return;
453
964
  const el = queryVisibleTarget(step.target!);
454
965
  if (el) {
455
- el.scrollIntoView({ block: "center", behavior: "smooth" });
966
+ // A docked step scrolls the field into the half the card leaves free, through
967
+ // the field's own scroll container (§11.7); any other centres it, as ever.
968
+ if (docks(step, dockOnPhones)) scrollIntoFreeHalf(el, null, null);
969
+ else el.scrollIntoView({ block: "center", behavior: "smooth" });
456
970
  settle(el);
457
971
  return;
458
972
  }
@@ -465,6 +979,8 @@ function TourOverlay({
465
979
  cancelled = true;
466
980
  cancelAnimationFrame(raf);
467
981
  };
982
+ // `via` and the gates are read at arrival, not followed.
983
+ // eslint-disable-next-line react-hooks/exhaustive-deps
468
984
  }, [index, step]);
469
985
 
470
986
  // Follow the target as the page scrolls / reflows — only once THIS step is located,
@@ -474,12 +990,16 @@ function TourOverlay({
474
990
  const update = () => {
475
991
  const el = queryVisibleTarget(step.target!);
476
992
  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
- );
993
+ const next = spotRectOf(el, step);
994
+ // A sheet that grows to full screen (or shrinks from it) takes or drops the scrim.
995
+ const host = layerHost(el);
996
+ const full = !!host && coversViewport(host, el);
997
+ setLocated((prev) => {
998
+ if (!prev || prev.index !== index || prev.step !== step) return prev;
999
+ const fullScreen = prev.layer ? full : prev.fullScreen;
1000
+ if (sameRect(prev.rect, next) && fullScreen === prev.fullScreen) return prev;
1001
+ return { ...prev, rect: next, fullScreen };
1002
+ });
483
1003
  }
484
1004
  };
485
1005
  window.addEventListener("scroll", update, true);
@@ -492,6 +1012,31 @@ function TourOverlay({
492
1012
  };
493
1013
  }, [index, step]);
494
1014
 
1015
+ // A field step's container goes with the step (and with the tour).
1016
+ const layer = located?.layer ?? null;
1017
+ useEffect(() => {
1018
+ if (!layer) return;
1019
+ return () => layer.remove();
1020
+ }, [layer]);
1021
+
1022
+ // Escape (§11.6.4): the tour is a layer on the kit's Escape stack, pushed on each
1023
+ // arrival, so it sits above the sheet holding the form. Escape from the card skips the
1024
+ // tour and no longer also closes that sheet; a layer the field opens afterwards (a
1025
+ // list, the pad) is above the tour and takes Escape first. An Escape pressed in the
1026
+ // field itself belongs to the field: the tour lets it pass by doing nothing.
1027
+ useDismissLayer(
1028
+ ready,
1029
+ (e) => {
1030
+ if (fieldStepNow && step.target && e.target instanceof Node) {
1031
+ const el = queryVisibleTarget(step.target);
1032
+ if (el?.contains(e.target)) return;
1033
+ }
1034
+ onSkip();
1035
+ },
1036
+ // Not an overlay to `useOverlayOpen`: a floating launcher a tour spotlights stays.
1037
+ { overlay: false },
1038
+ );
1039
+
495
1040
  // awaitClick: advance when the *real* target is clicked. The spotlight hole is
496
1041
  // click-through (see the shields in the render below) so the element's own
497
1042
  // handler runs first; we step forward on the next macrotask.
@@ -509,9 +1054,145 @@ function TourOverlay({
509
1054
  return () => el.removeEventListener("click", onClick);
510
1055
  }, [ready, spotted, index, step, onNext]);
511
1056
 
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);
1057
+ // A field step (see `TourStep.interactive`): only while the target is lit — a step
1058
+ // whose target is missing centres its card over a full scrim, modal as ever.
1059
+ const interactive =
1060
+ spotted && !!step.target && !!(step.interactive || step.awaitInput || step.gate || step.awaitGate);
1061
+ // A GUIDED field step (§11.8): focus goes to the field on a fine pointer, and Tab
1062
+ // cycles field + card. A plain `interactive` step keeps the card's own trap.
1063
+ const guided = interactive && !!(step.gate || step.awaitInput || step.awaitGate);
1064
+ const fieldStepNow = interactive;
1065
+ // An awaitGate step whose target is lit: only `release` moves it (a missing Save
1066
+ // button would otherwise be a dead end, as for awaitClick).
1067
+ const awaitsRelease = spotted && !!step.target && !!step.awaitGate;
1068
+
1069
+ // A gate nobody publishes is not enforced: Next opens, and a development build says why
1070
+ // (on arrival, or when its publisher unmounts under the step), once per step.
1071
+ const warnedGateRef = useRef<TourStep | null>(null);
1072
+ useEffect(() => {
1073
+ if (!ready || !gateKey || gates.has(gateKey) || !devWarnings() || warnedGateRef.current === step) return;
1074
+ warnedGateRef.current = step;
1075
+ console.warn(
1076
+ `[ui-kit] Tour step "${step.title}" waits for the gate "${gateKey}", which nothing publishes: its Next is open. ` +
1077
+ "Publish it with useTourGate beside the field, or the form has closed under the tour.",
1078
+ );
1079
+ }, [ready, gateKey, gates, step, gateVersion]);
1080
+
1081
+ // `optional` means "may stay EMPTY": without a gate there is no value to compare.
1082
+ useEffect(() => {
1083
+ if (!ready || !step.optional || step.gate || !devWarnings()) return;
1084
+ console.warn(
1085
+ `[ui-kit] Tour step "${step.title}" is \`optional\` without a \`gate\`: it shows the Optional chip ` +
1086
+ "and a plain Next. \"Leave empty\" needs a gate to compare.",
1087
+ );
1088
+ }, [ready, step]);
1089
+
1090
+ // "Ready" once, when this step's gate turns satisfied (not when it arrives satisfied).
1091
+ const wasLockedRef = useRef<{ step: TourStep; locked: boolean } | null>(null);
1092
+ useEffect(() => {
1093
+ if (!ready || !gateKey) return;
1094
+ const before = wasLockedRef.current;
1095
+ if (before && before.step === step && before.locked && !locked && enforced) announce(labels.gateReady);
1096
+ wasLockedRef.current = { step, locked };
1097
+ }, [ready, gateKey, step, locked, enforced, announce, labels.gateReady]);
1098
+
1099
+ // Arrival on a guided step: the field takes focus on a fine pointer, the card on a
1100
+ // coarse one; the field is described by the card's body while the step lasts.
1101
+ // Declared BEFORE the advance listeners below, so the focus it moves is not mistaken
1102
+ // for the person leaving the field.
1103
+ useEffect(() => {
1104
+ if (!ready || !guided || !step.target) return;
1105
+ const el = queryVisibleTarget(step.target);
1106
+ const control = el ? firstFocusable(el) : null;
1107
+ if (coarsePointer() || !control) cardRef.current?.focus();
1108
+ else if (!el!.contains(document.activeElement)) control.focus();
1109
+ if (!control) return;
1110
+ const previous = control.getAttribute("aria-describedby");
1111
+ control.setAttribute("aria-describedby", previous ? `${previous} ${bodyId}` : bodyId);
1112
+ return () => {
1113
+ if (previous === null) control.removeAttribute("aria-describedby");
1114
+ else control.setAttribute("aria-describedby", previous);
1115
+ };
1116
+ }, [ready, guided, index, step, bodyId]);
1117
+
1118
+ // Tab on a guided step cycles field + card as one ring (§11.8): from the field's last
1119
+ // stop to the card's first, from the card's last back to the field. Captured on the
1120
+ // document, ahead of a sheet's own trap, which would wrap inside the sheet.
1121
+ useEffect(() => {
1122
+ if (!ready || !guided || !step.target) return;
1123
+ const onTab = (e: KeyboardEvent) => {
1124
+ if (e.key !== "Tab") return;
1125
+ const el = queryVisibleTarget(step.target!);
1126
+ const card = cardRef.current;
1127
+ if (!el || !card) return;
1128
+ const active = document.activeElement;
1129
+ const fieldStops = tabStops(el);
1130
+ const cardStops = tabStops(card);
1131
+ if (fieldStops.length === 0 || cardStops.length === 0) return;
1132
+ let to: HTMLElement | null = null;
1133
+ if (active instanceof Node && el.contains(active)) {
1134
+ if (!e.shiftKey && active === fieldStops[fieldStops.length - 1]) to = cardStops[0];
1135
+ else if (e.shiftKey && active === fieldStops[0]) to = cardStops[cardStops.length - 1];
1136
+ } else if (active instanceof Node && card.contains(active)) {
1137
+ if (!e.shiftKey && active === cardStops[cardStops.length - 1]) to = fieldStops[0];
1138
+ else if (e.shiftKey && (active === cardStops[0] || active === card)) to = fieldStops[fieldStops.length - 1];
1139
+ }
1140
+ if (!to) return;
1141
+ e.preventDefault();
1142
+ e.stopPropagation();
1143
+ to.focus();
1144
+ };
1145
+ document.addEventListener("keydown", onTab, true);
1146
+ return () => document.removeEventListener("keydown", onTab, true);
1147
+ }, [ready, guided, index, step]);
1148
+
1149
+ // gate: once satisfied, advance on Enter in the field (unless the control took it, or
1150
+ // a textarea without Ctrl/Cmd) or on focus leaving the field to nowhere or to the card.
1151
+ // Checked on the next macrotask, after the field's own blur/Enter commit has rendered.
1152
+ useEffect(() => {
1153
+ if (!ready || !spotted || !gateKey || !step.target) return;
1154
+ const el = queryVisibleTarget(step.target) as HTMLElement | null;
1155
+ if (!el) return;
1156
+ const satisfied = () => !gates.has(gateKey) || gates.get(gateKey) === true;
1157
+ let timer = 0;
1158
+ // Focus that was in the field when the step settled, or went there since. A focus
1159
+ // move the tour made itself (the card taking it on a phone) is not a leave.
1160
+ let armed = el.contains(document.activeElement);
1161
+ const check = () => {
1162
+ window.clearTimeout(timer);
1163
+ timer = window.setTimeout(() => {
1164
+ if (satisfied()) onNext();
1165
+ }, 0);
1166
+ };
1167
+ const onFocusIn = () => {
1168
+ armed = true;
1169
+ };
1170
+ const onFocusOut = (e: FocusEvent) => {
1171
+ const to = e.relatedTarget;
1172
+ if (to instanceof Node && el.contains(to)) return;
1173
+ const wasArmed = armed;
1174
+ armed = false;
1175
+ if (!wasArmed) return;
1176
+ if (to === null || (to instanceof Node && rootRef.current?.contains(to))) check();
1177
+ };
1178
+ const onKeyDown = (e: KeyboardEvent) => {
1179
+ if (e.key !== "Enter" || !(e.target instanceof Node) || !el.contains(e.target)) return;
1180
+ // Heard at the document, after React's handlers: a combobox picking an option has
1181
+ // called preventDefault by now.
1182
+ if (e.defaultPrevented || isComposing(e)) return;
1183
+ if (e.target instanceof HTMLTextAreaElement && !(e.ctrlKey || e.metaKey)) return;
1184
+ check();
1185
+ };
1186
+ el.addEventListener("focusin", onFocusIn);
1187
+ el.addEventListener("focusout", onFocusOut);
1188
+ document.addEventListener("keydown", onKeyDown);
1189
+ return () => {
1190
+ window.clearTimeout(timer);
1191
+ el.removeEventListener("focusin", onFocusIn);
1192
+ el.removeEventListener("focusout", onFocusOut);
1193
+ document.removeEventListener("keydown", onKeyDown);
1194
+ };
1195
+ }, [ready, spotted, gateKey, gates, index, step, onNext]);
515
1196
 
516
1197
  // awaitInput: advance once the target's fields hold another value than when the step
517
1198
  // was armed, checked when focus leaves the target or Enter is pressed in it — on the
@@ -551,10 +1232,26 @@ function TourOverlay({
551
1232
  };
552
1233
  }, [ready, spotted, index, step, onNext]);
553
1234
 
1235
+ /** A press on the locked Next (or Enter / → from the card): no move; the field takes
1236
+ * focus and the hint is read again. */
1237
+ const gateHint = step.gateHint ?? labels.gateHint;
1238
+ const lockedNext = useCallback(() => {
1239
+ const el = step.target ? queryVisibleTarget(step.target) : null;
1240
+ const control = el ? firstFocusable(el) : null;
1241
+ control?.focus();
1242
+ announce(gateHint);
1243
+ }, [step.target, announce, gateHint]);
1244
+ const tryNext = awaitsRelease ? NOOP : locked ? lockedNext : onNext;
1245
+
1246
+ // After a refused save: the first earlier step shown whose field is now empty (§11.4).
1247
+ // Computed each render, which the gate subscription above triggers: a field filled
1248
+ // again drops the link.
1249
+ const backTo = refusal ? firstEmptied(steps, floor, index, gates) : -1;
1250
+
554
1251
  // Keyboard: Esc skips, Enter and the reading-direction arrow advance (→ in LTR,
555
1252
  // ← in RTL), the other arrow goes back. On awaitClick steps Enter is ignored (it
556
1253
  // would fire the target's click AND advance) — the forward arrow stays as an escape
557
- // hatch.
1254
+ // hatch. On a locked gate step both are the locked Next.
558
1255
  //
559
1256
  // Enter on a focused control belongs to that control. This used to advance from
560
1257
  // anywhere, so Enter on a focused Skip or Back ran the button's own click AND stepped
@@ -568,14 +1265,16 @@ function TourOverlay({
568
1265
  if (interactive && e.target instanceof Node && e.target !== document.body && !rootRef.current?.contains(e.target)) {
569
1266
  return;
570
1267
  }
1268
+ // Escape is the tour's Escape layer's (above) once the step is shown; while it is
1269
+ // still being located there is no layer yet, and the tour takes it here as in 0.49.
571
1270
  if (e.key === "Escape") {
572
- onSkip();
1271
+ if (!ready) onSkip();
573
1272
  return;
574
1273
  }
575
1274
  if (e.key === "Enter") {
576
1275
  if (step.awaitClick || step.awaitInput || ownsKey(e.target, "Enter")) return;
577
1276
  e.preventDefault();
578
- onNext();
1277
+ tryNext();
579
1278
  return;
580
1279
  }
581
1280
  if (ownsKey(e.target, e.key)) return;
@@ -583,17 +1282,19 @@ function TourOverlay({
583
1282
  const delta = horizontalStep(e.key, rootRef.current);
584
1283
  if (delta === 1) {
585
1284
  e.preventDefault();
586
- onNext();
1285
+ tryNext();
587
1286
  } else if (delta === -1) {
588
1287
  if (!isFirst) onPrev();
589
1288
  }
590
1289
  };
591
1290
  document.addEventListener("keydown", onKey);
592
1291
  return () => document.removeEventListener("keydown", onKey);
593
- }, [isFirst, step, interactive, onNext, onPrev, onSkip]);
1292
+ }, [isFirst, step, interactive, tryNext, onPrev, onSkip, ready]);
594
1293
 
595
1294
  if (!ready) return null;
596
1295
 
1296
+ // No scrim, no shields: a field step inside a layer that fills the screen (§11.6.3).
1297
+ const bare = interactive && !!current?.fullScreen;
597
1298
  const pad = step.padding ?? 8;
598
1299
  const spot: Rect | null = rect
599
1300
  ? { top: rect.top - pad, left: rect.left - pad, width: rect.width + pad * 2, height: rect.height + pad * 2 }
@@ -610,9 +1311,10 @@ function TourOverlay({
610
1311
  return createPortal(
611
1312
  <div
612
1313
  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]")}
1314
+ // z-50 on a field step, in its own container right after the layer holding the field
1315
+ // (or at the end of <body>): over that layer and the page, under the z-50 layers the
1316
+ // field opens next (§11.6). z-[60] on a plain step, over everything.
1317
+ className={cn("pointer-events-none fixed inset-0", interactive ? "z-50" : "z-[60]")}
616
1318
  role="dialog"
617
1319
  aria-modal={interactive ? undefined : "true"}
618
1320
  // A stable marker for the overlay, on every step (0.48, keksdose dev#611–613): an
@@ -621,10 +1323,13 @@ function TourOverlay({
621
1323
  // keksdose matched the `pointer-events-none` class until now, which is the kit's to change.
622
1324
  data-tour-overlay=""
623
1325
  data-tour-interactive={interactive ? "" : undefined}
1326
+ data-tour-bare={bare ? "" : undefined}
624
1327
  aria-label={step.title}
625
1328
  dir={dir}
626
1329
  >
627
- {spot && <SpotlightShields spot={spot} />}
1330
+ {/* In a full-screen layer (a phone's form sheet) no scrim and no shields: the rest of
1331
+ the form stays usable and scrollable, and the ring alone marks the field. */}
1332
+ {spot && !bare && <SpotlightShields spot={spot} />}
628
1333
  {spot ? (
629
1334
  <div
630
1335
  className="pointer-events-none fixed rounded-lg transition-all duration-200 ease-out"
@@ -651,7 +1356,7 @@ function TourOverlay({
651
1356
  "0 0 0 3px rgba(255,255,255,0.98)",
652
1357
  "0 0 0 5px rgba(15,23,42,0.92)",
653
1358
  "0 0 18px 6px rgba(15,23,42,0.45)",
654
- "0 0 0 9999px rgba(15,23,42,0.70)",
1359
+ ...(bare ? [] : ["0 0 0 9999px rgba(15,23,42,0.70)"]),
655
1360
  ].join(", "),
656
1361
  }}
657
1362
  />
@@ -659,20 +1364,35 @@ function TourOverlay({
659
1364
  <div className="pointer-events-auto fixed inset-0 bg-black/70" />
660
1365
  )}
661
1366
  <TourCard
1367
+ cardRef={cardRef}
662
1368
  step={step}
663
1369
  index={index}
664
- total={steps.length}
1370
+ position={position}
1371
+ total={count}
665
1372
  labels={labels}
666
1373
  isFirst={isFirst}
667
1374
  isLast={isLast}
668
1375
  rect={spot}
669
1376
  dir={dir}
1377
+ trap={!guided}
1378
+ bodyId={bodyId}
1379
+ hintId={hintId}
1380
+ locked={locked}
1381
+ gateHint={gateHint}
670
1382
  onBack={onPrev}
671
- onNext={onNext}
1383
+ onNext={tryNext}
1384
+ onLeaveEmpty={onNext}
672
1385
  onSkip={onSkip}
1386
+ awaitsRelease={awaitsRelease}
1387
+ docked={spot !== null && docks(step, dockOnPhones)}
1388
+ refusal={refusal}
1389
+ backTo={backTo >= 0 ? { title: steps[backTo].title, go: () => onGoTo(backTo) } : null}
673
1390
  />
1391
+ <div className="sr-only" aria-live="polite" aria-atomic="true">
1392
+ {polite}
1393
+ </div>
674
1394
  </div>,
675
- document.body,
1395
+ (interactive && current?.layer) || document.body,
676
1396
  );
677
1397
  }
678
1398
 
@@ -788,31 +1508,62 @@ function placeCard(
788
1508
  }
789
1509
 
790
1510
  function TourCard({
1511
+ cardRef: ref,
791
1512
  step,
792
1513
  index,
1514
+ position,
793
1515
  total,
794
1516
  labels,
795
1517
  isFirst,
796
1518
  isLast,
797
1519
  rect,
798
1520
  dir,
1521
+ trap,
1522
+ bodyId,
1523
+ hintId,
1524
+ locked,
1525
+ gateHint,
799
1526
  onBack,
800
1527
  onNext,
1528
+ onLeaveEmpty,
801
1529
  onSkip,
1530
+ awaitsRelease,
1531
+ docked,
1532
+ refusal,
1533
+ backTo,
802
1534
  }: {
1535
+ cardRef: RefObject<HTMLDivElement | null>;
803
1536
  step: TourStep;
804
1537
  index: number;
1538
+ /** 1-based among the steps shown (§11.3). */
1539
+ position: number;
1540
+ /** How many steps the run shows. */
805
1541
  total: number;
806
1542
  labels: TourLabels;
807
1543
  isFirst: boolean;
808
1544
  isLast: boolean;
809
1545
  rect: Rect | null;
810
1546
  dir: Direction;
1547
+ /** The card's own focus trap; off on a guided step, where field + card are one ring. */
1548
+ trap: boolean;
1549
+ bodyId: string;
1550
+ hintId: string;
1551
+ /** A gate step whose field is not filled yet (§11.1). */
1552
+ locked: boolean;
1553
+ gateHint: string;
811
1554
  onBack: () => void;
812
1555
  onNext: () => void;
1556
+ /** Moves on past an `optional` field left empty. */
1557
+ onLeaveEmpty: () => void;
813
1558
  onSkip: () => void;
1559
+ /** An awaitGate step with its target lit: the hint in place of Next. */
1560
+ awaitsRelease: boolean;
1561
+ /** Pinned to the band's edge away from the field (§11.7). */
1562
+ docked: boolean;
1563
+ refusal: Refusal | null;
1564
+ /** The "Back to …" link after a refused save. */
1565
+ backTo: { title: string; go: () => void } | null;
814
1566
  }) {
815
- const ref = useRef<HTMLDivElement>(null);
816
1567
  const [style, setStyle] = useState<CSSProperties>({ opacity: 0 });
817
1568
 
818
1569
  /**
@@ -839,16 +1590,69 @@ function TourCard({
839
1590
  * is), which is the escape hatch that step already documents for the case where the
840
1591
  * target cannot be found. Containment is worth that: an `awaitClick` step is a
841
1592
  * minority of steps, while focus stranded behind `aria-modal` is every step.
1593
+ *
1594
+ * A guided field step (`gate`, `awaitInput`, 0.50) is the exception: the field is
1595
+ * what the step is about, so the overlay focuses it (or the card, on a touch screen)
1596
+ * and runs one Tab ring over field + card itself.
842
1597
  */
843
- useFocusTrap(ref, { active: true });
1598
+ useFocusTrap(ref, { active: trap });
1599
+
1600
+ // Docked (§11.7): the edge it last took, for the hysteresis, and a tick bumped when the
1601
+ // band moves under an unmoved field (a pad or keyboard opening).
1602
+ const edgeRef = useRef<DockEdge | null>(null);
1603
+ const [bandTick, setBandTick] = useState(0);
1604
+ const [edge, setEdge] = useState<DockEdge | null>(null);
1605
+ useEffect(() => {
1606
+ if (!docked) return;
1607
+ let last = "";
1608
+ const look = () => {
1609
+ const target = step.target ? queryVisibleTarget(step.target) : null;
1610
+ const band = readBand(target, ref.current);
1611
+ const key = `${band.top}:${band.bottom}`;
1612
+ if (key === last) return;
1613
+ last = key;
1614
+ // A field the pad or keyboard now covers is brought back into the free half.
1615
+ if (target) {
1616
+ const r = target.getBoundingClientRect();
1617
+ if (r.top < band.top || r.bottom > band.bottom) {
1618
+ edgeRef.current = scrollIntoFreeHalf(target, ref.current, edgeRef.current);
1619
+ }
1620
+ }
1621
+ setBandTick((t) => t + 1);
1622
+ };
1623
+ const vv = window.visualViewport;
1624
+ vv?.addEventListener("resize", look);
1625
+ vv?.addEventListener("scroll", look);
1626
+ window.addEventListener("resize", look);
1627
+ // The follow tick's cadence: it catches a pad opening, which moves nothing.
1628
+ const id = window.setInterval(look, 250);
1629
+ return () => {
1630
+ vv?.removeEventListener("resize", look);
1631
+ vv?.removeEventListener("scroll", look);
1632
+ window.removeEventListener("resize", look);
1633
+ clearInterval(id);
1634
+ };
1635
+ }, [docked, step.target, ref]);
844
1636
 
845
1637
  useLayoutEffect(() => {
846
1638
  const card = ref.current;
847
1639
  if (!card) return;
1640
+ if (docked) {
1641
+ const target = step.target ? queryVisibleTarget(step.target) : null;
1642
+ const band = readBand(target, card);
1643
+ const next = rect ? dockEdge(rect, band, edgeRef.current, card.offsetHeight) : "bottom";
1644
+ edgeRef.current = next;
1645
+ setEdge(next);
1646
+ setStyle(dockStyle(next, rect, band));
1647
+ return;
1648
+ }
1649
+ setEdge(null);
848
1650
  setStyle(
849
1651
  placeCard(rect, card.offsetWidth, card.offsetHeight, window.innerWidth, window.innerHeight, 12, step.placement, dir),
850
1652
  );
851
- }, [rect, index, step.placement, dir]);
1653
+ }, [ref, rect, index, step.placement, step.target, dir, docked, bandTick]);
1654
+
1655
+ const awaitsTarget = ((step.awaitClick || step.awaitInput) && step.target && rect) || awaitsRelease;
852
1656
 
853
1657
  return (
854
1658
  <div
@@ -859,10 +1663,28 @@ function TourCard({
859
1663
  // trap silently does nothing.
860
1664
  tabIndex={-1}
861
1665
  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"
1666
+ data-tour-card=""
1667
+ data-tour-docked={edge ?? undefined}
1668
+ className={cn(
1669
+ "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",
1670
+ // Docked, the height is capped: the body scrolls, the title and buttons stay.
1671
+ docked && "flex flex-col",
1672
+ )}
863
1673
  >
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>
1674
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-semibold text-primary">
1675
+ <span>{step.title}</span>
1676
+ {step.optional && (
1677
+ <span className="rounded-full border border-subtle px-1.5 py-px text-xs font-normal text-muted">
1678
+ {labels.optional}
1679
+ </span>
1680
+ )}
1681
+ </div>
1682
+ <div
1683
+ id={bodyId}
1684
+ className={cn("mt-1 text-sm leading-relaxed text-secondary", docked && "min-h-0 shrink overflow-y-auto")}
1685
+ >
1686
+ {step.body}
1687
+ </div>
866
1688
  {step.action && <div className="mt-3">{step.action}</div>}
867
1689
  <div className="mt-4 space-y-2">
868
1690
  <div className="flex items-center justify-between gap-2">
@@ -875,7 +1697,7 @@ function TourCard({
875
1697
  </button>
876
1698
  <div className="flex items-center gap-2">
877
1699
  <span className="whitespace-nowrap text-xs tabular-nums text-placeholder">
878
- {labels.step(index + 1, total)}
1700
+ {labels.step(position, total)}
879
1701
  </span>
880
1702
  {!isFirst && (
881
1703
  <Button variant="secondary" onClick={onBack} className="px-2.5 py-1 text-xs">
@@ -890,16 +1712,57 @@ function TourCard({
890
1712
  way forward on any step whose anchor happens not to be rendered
891
1713
  for this user (a guest sees no "add account" button), leaving the
892
1714
  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")}>
1715
+ {/* "Leave empty" (§11.2): an optional field may stay empty, so the step can
1716
+ move on without it. Gone once the field is filled. */}
1717
+ {step.optional && locked && !awaitsTarget && (
1718
+ <Button variant="secondary" onClick={onLeaveEmpty} className="px-2.5 py-1 text-xs">
1719
+ {labels.leaveEmpty}
1720
+ </Button>
1721
+ )}
1722
+ {!awaitsTarget && (
1723
+ <Button
1724
+ variant="accent"
1725
+ onClick={onNext}
1726
+ // Locked, not disabled (§11.1): it stays focusable, and a press focuses the
1727
+ // field and reads the hint again.
1728
+ aria-disabled={locked || undefined}
1729
+ aria-describedby={locked ? hintId : undefined}
1730
+ className={cn("px-2.5 py-1 text-xs", locked && "opacity-60")}
1731
+ >
895
1732
  {isLast ? labels.done : labels.next}
896
1733
  </Button>
897
1734
  )}
898
1735
  </div>
899
1736
  </div>
900
- {(step.awaitClick || step.awaitInput) && step.target && rect && (
1737
+ {awaitsTarget && (
901
1738
  <div className="text-center text-xs italic text-placeholder">
902
- {step.awaitInput && !step.awaitClick ? labels.awaitInputHint : labels.awaitClickHint}
1739
+ {awaitsRelease
1740
+ ? (step.awaitGateHint ?? labels.awaitGateHint)
1741
+ : step.awaitInput && !step.awaitClick
1742
+ ? labels.awaitInputHint
1743
+ : labels.awaitClickHint}
1744
+ </div>
1745
+ )}
1746
+ {/* Always mounted on an awaitGate step, so a refusal is read out (assertively)
1747
+ the moment it lands. */}
1748
+ {awaitsRelease && (
1749
+ <div aria-live="assertive" aria-atomic="true" className="space-y-1">
1750
+ {refusal && (
1751
+ <p data-tour-refused="" className="text-xs text-danger">
1752
+ {refusal.reason ?? labels.awaitGateRefused}
1753
+ {refusal.seq % 2 === 0 ? "\u200b" : ""}
1754
+ </p>
1755
+ )}
1756
+ {refusal && backTo && (
1757
+ <Button variant="link" onClick={backTo.go} className="text-xs">
1758
+ {labels.backToStep(backTo.title)}
1759
+ </Button>
1760
+ )}
1761
+ </div>
1762
+ )}
1763
+ {locked && !awaitsTarget && (
1764
+ <div id={hintId} className="text-center text-xs italic text-placeholder">
1765
+ {gateHint}
903
1766
  </div>
904
1767
  )}
905
1768
  </div>