@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.
- package/README.md +12 -8
- package/dist/admin/user-roster.d.ts +16 -10
- package/dist/admin/user-roster.js +7 -3
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/verify-email.d.ts +9 -1
- package/dist/auth/verify-email.js +3 -1
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/components/amount-input.d.ts +2 -1
- package/dist/components/amount-input.js +3 -2
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.js +14 -0
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/combobox-core.js +1 -0
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox-suggestions.js +1 -0
- package/dist/components/combobox-suggestions.js.map +1 -1
- package/dist/components/combobox.js +3 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.d.ts +28 -2
- package/dist/components/currency-select.js +56 -10
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/data-table-mobile.d.ts +11 -2
- package/dist/components/data-table-mobile.js +62 -8
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table-row.d.ts +1 -1
- package/dist/components/data-table-row.js.map +1 -1
- package/dist/components/data-table.js +3 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/dropdown.js +1 -0
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/full-bleed-dialog.js +1 -0
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +12 -0
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/modal.js +1 -0
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-input.d.ts +2 -1
- package/dist/components/number-input.js +6 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.js +2 -0
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/picker-sheet.js +2 -0
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.js +1 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/defaults.js +2 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +14 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +3 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +16 -1
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +14 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +14 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +14 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +14 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +14 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +1 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/lib/data-attributes.d.ts +11 -0
- package/dist/lib/data-attributes.js +10 -0
- package/dist/lib/data-attributes.js.map +1 -0
- package/dist/lib/format.d.ts +1 -0
- package/dist/lib/format.js +2 -0
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/minus.d.ts +10 -0
- package/dist/lib/minus.js.map +1 -1
- package/dist/lib/quiet-focus.d.ts +4 -0
- package/dist/lib/quiet-focus.js +19 -0
- package/dist/lib/quiet-focus.js.map +1 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/react.d.ts +25 -0
- package/dist/testing/react.js +19 -0
- package/dist/testing/react.js.map +1 -0
- package/dist/testing/tour.d.ts +31 -0
- package/dist/testing/tour.js +12 -0
- package/dist/testing/tour.js.map +1 -0
- package/dist/tour/tour-dock.d.ts +42 -0
- package/dist/tour/tour-dock.js +80 -0
- package/dist/tour/tour-dock.js.map +1 -0
- package/dist/tour/tour-engine.d.ts +12 -0
- package/dist/tour/tour-engine.js +31 -0
- package/dist/tour/tour-engine.js.map +1 -0
- package/dist/tour/tour-gates.d.ts +71 -0
- package/dist/tour/tour-gates.js +114 -0
- package/dist/tour/tour-gates.js.map +1 -0
- package/dist/tour/tour-guide-button.d.ts +27 -0
- package/dist/tour/tour-guide-button.js +45 -0
- package/dist/tour/tour-guide-button.js.map +1 -0
- package/dist/tour/tour-guide-labels.d.ts +8 -0
- package/dist/tour/tour-guide-labels.js +9 -0
- package/dist/tour/tour-guide-labels.js.map +1 -0
- package/dist/tour/tour-labels.js +9 -1
- package/dist/tour/tour-labels.js.map +1 -1
- package/dist/tour/tour.d.ts +144 -9
- package/dist/tour/tour.js +598 -59
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-url-sync.d.ts +7 -2
- package/dist/tour/use-tour-url-sync.js +27 -1
- package/dist/tour/use-tour-url-sync.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +3 -0
- package/dist/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.js +15 -2
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +11 -0
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +11 -5
- package/dist/wizard/use-wizard.js.map +1 -1
- package/package.json +9 -1
- package/src/admin/user-roster.tsx +22 -12
- package/src/auth/verify-email.tsx +10 -0
- package/src/components/amount-input.tsx +5 -4
- package/src/components/autocomplete.tsx +20 -0
- package/src/components/combobox-core.tsx +2 -0
- package/src/components/combobox-suggestions.tsx +2 -0
- package/src/components/combobox.tsx +5 -1
- package/src/components/currency-select.tsx +95 -6
- package/src/components/data-table-mobile.tsx +99 -8
- package/src/components/data-table-row.tsx +1 -0
- package/src/components/data-table.tsx +9 -1
- package/src/components/dropdown.tsx +2 -0
- package/src/components/full-bleed-dialog.tsx +3 -0
- package/src/components/inline-entity-combobox.tsx +17 -0
- package/src/components/modal.tsx +3 -0
- package/src/components/number-input.tsx +8 -3
- package/src/components/numpad-sheet.tsx +4 -0
- package/src/components/picker-sheet.tsx +4 -0
- package/src/components/popover.tsx +2 -0
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/german.ts +13 -0
- package/src/i18n/kit-labels.tsx +3 -0
- package/src/i18n/locales/es.ts +13 -0
- package/src/i18n/locales/fr.ts +13 -0
- package/src/i18n/locales/hu.ts +13 -0
- package/src/i18n/locales/it.ts +13 -0
- package/src/i18n/locales/zh.ts +13 -0
- package/src/i18n/review.ts +1 -0
- package/src/lib/data-attributes.ts +14 -0
- package/src/lib/format.ts +2 -0
- package/src/lib/minus.ts +10 -0
- package/src/lib/quiet-focus.ts +32 -0
- package/src/testing/index.ts +8 -0
- package/src/testing/react.ts +59 -0
- package/src/testing/tour.ts +41 -0
- package/src/tour/tour-dock.ts +132 -0
- package/src/tour/tour-engine.ts +41 -0
- package/src/tour/tour-gates.ts +195 -0
- package/src/tour/tour-guide-button.tsx +64 -0
- package/src/tour/tour-guide-labels.ts +15 -0
- package/src/tour/tour-labels.ts +8 -0
- package/src/tour/tour.tsx +989 -86
- package/src/tour/use-tour-url-sync.ts +43 -4
- package/src/tour.ts +3 -0
- package/src/wizard/stepper-nav.tsx +23 -3
- package/src/wizard/types.ts +12 -0
- 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
|
-
|
|
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
|
-
/**
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
311
|
-
if (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
321
|
-
{
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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 =
|
|
422
|
-
const isLast =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
//
|
|
513
|
-
//
|
|
514
|
-
const interactive =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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-
|
|
614
|
-
//
|
|
615
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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={
|
|
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:
|
|
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
|
-
|
|
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">
|
|
865
|
-
|
|
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(
|
|
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
|
-
{
|
|
894
|
-
|
|
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
|
-
{
|
|
1777
|
+
{awaitsTarget && (
|
|
901
1778
|
<div className="text-center text-xs italic text-placeholder">
|
|
902
|
-
{
|
|
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>
|