@eifi1/ui-kit 0.48.2 → 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.
- package/README.md +34 -16
- package/dist/admin/admin-action-confirm.js +1 -1
- package/dist/admin/admin-action-confirm.js.map +1 -1
- package/dist/admin/invitations-panel.js +5 -1
- package/dist/admin/invitations-panel.js.map +1 -1
- package/dist/admin/user-roster.d.ts +24 -12
- package/dist/admin/user-roster.js +21 -6
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/accept-invitation.js +2 -2
- package/dist/auth/accept-invitation.js.map +1 -1
- package/dist/auth/not-found-page.js +1 -1
- package/dist/auth/not-found-page.js.map +1 -1
- package/dist/auth/reset-password-form.js +2 -2
- package/dist/auth/reset-password-form.js.map +1 -1
- package/dist/auth/verify-email.d.ts +9 -1
- package/dist/auth/verify-email.js +5 -3
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/billing/plan-card.js +2 -2
- package/dist/billing/plan-card.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 +1 -0
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/breadcrumbs.d.ts +19 -1
- package/dist/components/breadcrumbs.js +11 -4
- package/dist/components/breadcrumbs.js.map +1 -1
- package/dist/components/button-look.d.ts +37 -7
- package/dist/components/button-look.js +33 -6
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/button.d.ts +4 -1
- package/dist/components/button.js +2 -2
- package/dist/components/button.js.map +1 -1
- package/dist/components/calendar-heatmap.js +2 -1
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/chart-edge.d.ts +10 -0
- package/dist/components/chart-edge.js +57 -0
- package/dist/components/chart-edge.js.map +1 -0
- package/dist/components/chart.js +2 -1
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.d.ts +8 -1
- package/dist/components/chip.js +4 -1
- package/dist/components/chip.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/confirm-context.d.ts +5 -0
- package/dist/components/confirm-context.js +7 -0
- package/dist/components/confirm-context.js.map +1 -0
- package/dist/components/confirm-dialog.js +3 -3
- package/dist/components/confirm-dialog.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/danger-confirm.d.ts +2 -2
- package/dist/components/danger-confirm.js +1 -1
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-fit.js +21 -3
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-mobile.d.ts +58 -3
- package/dist/components/data-table-mobile.js +147 -16
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table-row-actions.d.ts +2 -1
- package/dist/components/data-table-row-actions.js +7 -0
- package/dist/components/data-table-row-actions.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.d.ts +34 -1
- package/dist/components/data-table.js +37 -10
- 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/error-boundary.js +2 -2
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/floating-panel.d.ts +36 -13
- package/dist/components/floating-panel.js +27 -7
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/form-actions.js +1 -1
- package/dist/components/form-actions.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/icon-button.d.ts +11 -0
- package/dist/components/icon-button.js +7 -5
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/modal.js +1 -0
- package/dist/components/modal.js.map +1 -1
- package/dist/components/nav-pills.d.ts +8 -1
- package/dist/components/nav-pills.js +61 -38
- package/dist/components/nav-pills.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/page-header-context.d.ts +8 -0
- package/dist/components/page-header-context.js +7 -0
- package/dist/components/page-header-context.js.map +1 -0
- package/dist/components/page-header.d.ts +14 -2
- package/dist/components/page-header.js +10 -1
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/picker-sheet.js +2 -0
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/pie-chart.js +2 -1
- package/dist/components/pie-chart.js.map +1 -1
- package/dist/components/popover.js +1 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/components/progress-bar.js +5 -4
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/reauth-dialog.js +1 -1
- package/dist/components/reauth-dialog.js.map +1 -1
- package/dist/components/row-actions-labels.js +3 -1
- package/dist/components/row-actions-labels.js.map +1 -1
- package/dist/components/row-actions.d.ts +43 -9
- package/dist/components/row-actions.js +78 -15
- package/dist/components/row-actions.js.map +1 -1
- package/dist/components/section-nav.d.ts +37 -0
- package/dist/components/section-nav.js +33 -0
- package/dist/components/section-nav.js.map +1 -0
- package/dist/components/series-chart.js +8 -3
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/share-card.js +1 -1
- package/dist/components/share-card.js.map +1 -1
- package/dist/components/signature-pad.js +1 -1
- package/dist/components/signature-pad.js.map +1 -1
- package/dist/components/sparkline.js +2 -1
- package/dist/components/sparkline.js.map +1 -1
- package/dist/components/stat-tile.js +14 -7
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/status-dot.d.ts +5 -1
- package/dist/components/status-dot.js +4 -1
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/tooltip.js +1 -1
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/translation-review-editor.js +2 -2
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review.js +2 -2
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/treemap.d.ts +5 -2
- package/dist/components/treemap.js +26 -6
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/use-table-state.js +37 -6
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/data-table.js.map +1 -1
- package/dist/demo/demo-banner.js +1 -1
- package/dist/demo/demo-banner.js.map +1 -1
- package/dist/demo/demo-ended.js +1 -1
- package/dist/demo/demo-ended.js.map +1 -1
- package/dist/demo/demo-start.js +1 -1
- package/dist/demo/demo-start.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-inbox.js +1 -1
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/hooks/use-dismiss.d.ts +9 -1
- package/dist/hooks/use-dismiss.js +25 -5
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-document-title.d.ts +14 -0
- package/dist/hooks/use-document-title.js +22 -0
- package/dist/hooks/use-document-title.js.map +1 -0
- package/dist/i18n/defaults.js +2 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +20 -3
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +34 -1
- package/dist/i18n/kit-labels.js +43 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +22 -3
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +20 -3
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +20 -3
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +20 -3
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +20 -3
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +20 -3
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +3 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/i18n/shared-labels.js +1 -0
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/landing/landing-actions.js +2 -2
- package/dist/landing/landing-actions.js.map +1 -1
- package/dist/landing/public-header.js +2 -2
- package/dist/landing/public-header.js.map +1 -1
- package/dist/lib/clipboard-minus.d.ts +27 -0
- package/dist/lib/clipboard-minus.js +63 -0
- package/dist/lib/clipboard-minus.js.map +1 -0
- 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/document-title.d.ts +17 -0
- package/dist/lib/document-title.js +29 -0
- package/dist/lib/document-title.js.map +1 -0
- package/dist/lib/float-clearance.js +8 -2
- package/dist/lib/float-clearance.js.map +1 -1
- package/dist/lib/format.d.ts +36 -10
- package/dist/lib/format.js +57 -11
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/minus.d.ts +32 -0
- package/dist/lib/minus.js +14 -0
- package/dist/lib/minus.js.map +1 -0
- package/dist/lib/status-tone.d.ts +30 -0
- package/dist/lib/status-tone.js +17 -0
- package/dist/lib/status-tone.js.map +1 -0
- package/dist/settings/settings-layout.js +3 -2
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/shell/account-menu-language.d.ts +1 -1
- package/dist/shell/account-menu-language.js +9 -1
- package/dist/shell/account-menu-language.js.map +1 -1
- package/dist/shell/app-shell-labels.js +2 -1
- package/dist/shell/app-shell-labels.js.map +1 -1
- package/dist/shell/app-shell.d.ts +58 -16
- package/dist/shell/app-shell.js +151 -43
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/language-code.d.ts +3 -0
- package/dist/shell/language-code.js +8 -0
- package/dist/shell/language-code.js.map +1 -0
- package/dist/shell/topbar-controls.d.ts +14 -6
- package/dist/shell/topbar-controls.js +14 -17
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/testing/css-variables.generated.d.ts +1 -1
- package/dist/testing/css-variables.generated.js +1 -1
- package/dist/testing/css-variables.generated.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/theme/chart-palette.d.ts +82 -15
- package/dist/theme/chart-palette.js +51 -24
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/theme/palette-presets.d.ts +9 -0
- package/dist/theme/palette-presets.js +77 -62
- package/dist/theme/palette-presets.js.map +1 -1
- 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-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 +133 -9
- package/dist/tour/tour.js +594 -70
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-url-sync.d.ts +5 -2
- package/dist/tour/use-tour-url-sync.js +7 -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.d.ts +1 -1
- package/dist/wizard/stepper-nav.js +18 -5
- 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/admin-action-confirm.tsx +1 -1
- package/src/admin/invitations-panel.tsx +7 -3
- package/src/admin/user-roster.tsx +41 -14
- package/src/auth/accept-invitation.tsx +3 -3
- package/src/auth/not-found-page.tsx +1 -1
- package/src/auth/reset-password-form.tsx +3 -3
- package/src/auth/verify-email.tsx +13 -3
- package/src/billing/plan-card.tsx +2 -2
- package/src/components/amount-input.tsx +5 -4
- package/src/components/autocomplete.tsx +2 -0
- package/src/components/breadcrumbs.tsx +32 -4
- package/src/components/button-look.ts +86 -15
- package/src/components/button.tsx +6 -3
- package/src/components/calendar-heatmap.tsx +2 -1
- package/src/components/chart-edge.ts +73 -0
- package/src/components/chart.tsx +3 -1
- package/src/components/chip.tsx +10 -1
- package/src/components/combobox-core.tsx +2 -0
- package/src/components/combobox-suggestions.tsx +2 -0
- package/src/components/confirm-context.ts +7 -0
- package/src/components/confirm-dialog.tsx +3 -3
- package/src/components/currency-select.tsx +95 -6
- package/src/components/danger-confirm.tsx +3 -3
- package/src/components/data-table-fit.ts +41 -6
- package/src/components/data-table-mobile.tsx +259 -17
- package/src/components/data-table-row-actions.tsx +9 -1
- package/src/components/data-table-row.tsx +3 -0
- package/src/components/data-table.tsx +91 -19
- package/src/components/dropdown.tsx +2 -0
- package/src/components/error-boundary.tsx +2 -2
- package/src/components/floating-panel.tsx +67 -16
- package/src/components/form-actions.tsx +2 -2
- package/src/components/full-bleed-dialog.tsx +3 -0
- package/src/components/icon-button.tsx +21 -8
- package/src/components/modal.tsx +3 -0
- package/src/components/nav-pills.tsx +29 -2
- package/src/components/number-input.tsx +8 -3
- package/src/components/numpad-sheet.tsx +4 -0
- package/src/components/page-header-context.ts +8 -0
- package/src/components/page-header.tsx +34 -2
- package/src/components/picker-sheet.tsx +4 -0
- package/src/components/pie-chart.tsx +2 -1
- package/src/components/popover.tsx +2 -0
- package/src/components/progress-bar.tsx +6 -3
- package/src/components/reauth-dialog.tsx +1 -1
- package/src/components/row-actions-labels.ts +2 -0
- package/src/components/row-actions.tsx +154 -33
- package/src/components/section-nav.tsx +65 -0
- package/src/components/series-chart.tsx +12 -3
- package/src/components/share-card.tsx +1 -1
- package/src/components/signature-pad.tsx +1 -1
- package/src/components/sparkline.tsx +2 -1
- package/src/components/stat-tile.tsx +19 -7
- package/src/components/status-dot.tsx +7 -1
- package/src/components/tooltip.tsx +3 -1
- package/src/components/translation-review-editor.tsx +2 -2
- package/src/components/translation-review.tsx +2 -2
- package/src/components/treemap.tsx +34 -8
- package/src/components/use-table-state.ts +63 -8
- package/src/data-table.ts +3 -3
- package/src/demo/demo-banner.tsx +1 -1
- package/src/demo/demo-ended.tsx +1 -1
- package/src/demo/demo-start.tsx +1 -1
- package/src/feedback/feedback-inbox.tsx +1 -1
- package/src/hooks/use-dismiss.ts +43 -4
- package/src/hooks/use-document-title.ts +31 -0
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/german.ts +17 -0
- package/src/i18n/kit-labels.tsx +79 -1
- package/src/i18n/locales/es.ts +17 -0
- package/src/i18n/locales/fr.ts +17 -0
- package/src/i18n/locales/hu.ts +17 -0
- package/src/i18n/locales/it.ts +17 -0
- package/src/i18n/locales/zh.ts +17 -0
- package/src/i18n/review.ts +3 -0
- package/src/i18n/shared-labels.ts +1 -0
- package/src/index.ts +13 -0
- package/src/landing/landing-actions.tsx +2 -2
- package/src/landing/public-header.tsx +2 -2
- package/src/lib/clipboard-minus.ts +94 -0
- package/src/lib/data-attributes.ts +14 -0
- package/src/lib/document-title.ts +41 -0
- package/src/lib/float-clearance.ts +19 -3
- package/src/lib/format.ts +117 -30
- package/src/lib/minus.ts +50 -0
- package/src/lib/status-tone.ts +43 -0
- package/src/settings/settings-layout.tsx +3 -2
- package/src/shell/account-menu-language.tsx +14 -2
- package/src/shell/app-shell-labels.ts +1 -0
- package/src/shell/app-shell.tsx +291 -68
- package/src/shell/language-code.ts +5 -0
- package/src/shell/topbar-controls.tsx +35 -15
- package/src/testing/css-variables.generated.ts +6 -6
- package/src/testing/react.ts +59 -0
- package/src/theme/chart-palette.ts +102 -20
- package/src/theme/palette-presets.ts +121 -84
- package/src/tour/tour-dock.ts +132 -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 +949 -86
- package/src/tour/use-tour-url-sync.ts +14 -4
- package/src/tour.ts +3 -0
- package/src/wizard/stepper-nav.tsx +27 -7
- package/src/wizard/types.ts +12 -0
- package/src/wizard/use-wizard.ts +16 -9
- package/tokens.css +28 -18
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
|
-
|
|
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
|
-
/**
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
311
|
-
if (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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 =
|
|
422
|
-
const isLast =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
//
|
|
513
|
-
//
|
|
514
|
-
const interactive =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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-
|
|
614
|
-
//
|
|
615
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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={
|
|
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:
|
|
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
|
-
|
|
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">
|
|
865
|
-
|
|
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(
|
|
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
|
-
{
|
|
894
|
-
|
|
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
|
-
{
|
|
1737
|
+
{awaitsTarget && (
|
|
901
1738
|
<div className="text-center text-xs italic text-placeholder">
|
|
902
|
-
{
|
|
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>
|