@eifi1/ui-kit 0.50.0 → 0.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +5 -5
  2. package/dist/components/autocomplete.js +13 -0
  3. package/dist/components/autocomplete.js.map +1 -1
  4. package/dist/components/combobox.js +3 -1
  5. package/dist/components/combobox.js.map +1 -1
  6. package/dist/components/inline-entity-combobox.js +12 -0
  7. package/dist/components/inline-entity-combobox.js.map +1 -1
  8. package/dist/eslint/index.js +1 -1
  9. package/dist/lib/quiet-focus.d.ts +4 -0
  10. package/dist/lib/quiet-focus.js +19 -0
  11. package/dist/lib/quiet-focus.js.map +1 -0
  12. package/dist/testing/index.d.ts +1 -0
  13. package/dist/testing/index.js +4 -0
  14. package/dist/testing/index.js.map +1 -1
  15. package/dist/testing/tour.d.ts +31 -0
  16. package/dist/testing/tour.js +12 -0
  17. package/dist/testing/tour.js.map +1 -0
  18. package/dist/tour/tour-engine.d.ts +12 -0
  19. package/dist/tour/tour-engine.js +31 -0
  20. package/dist/tour/tour-engine.js.map +1 -0
  21. package/dist/tour/tour.d.ts +14 -3
  22. package/dist/tour/tour.js +60 -45
  23. package/dist/tour/tour.js.map +1 -1
  24. package/dist/tour/use-tour-url-sync.d.ts +5 -3
  25. package/dist/tour/use-tour-url-sync.js +21 -1
  26. package/dist/tour/use-tour-url-sync.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/components/autocomplete.tsx +18 -0
  29. package/src/components/combobox.tsx +5 -1
  30. package/src/components/inline-entity-combobox.tsx +17 -0
  31. package/src/lib/quiet-focus.ts +32 -0
  32. package/src/testing/index.ts +8 -0
  33. package/src/testing/tour.ts +41 -0
  34. package/src/tour/tour-engine.ts +41 -0
  35. package/src/tour/tour.tsx +64 -24
  36. package/src/tour/use-tour-url-sync.ts +33 -4
@@ -4,6 +4,7 @@ import * as ReactRouter from "react-router";
4
4
  import { useLocation, useNavigate } from "react-router";
5
5
  import { useSearchParamsState } from "../hooks/use-search-param-state.js";
6
6
  import { useTourOptional } from "./tour.js";
7
+ import { liveEngine } from "./tour-engine.js";
7
8
  import { devWarnings } from "./tour-gates.js";
8
9
  function navigationInFlight(history, dataRouter, rendered) {
9
10
  const state = dataRouter?.router?.state;
@@ -51,10 +52,12 @@ function useTourUrlSync({ launch, ready = true, stepAliases = [] }) {
51
52
  const aliases = stepAliases.join("\n");
52
53
  const launchRef = useRef(launch);
53
54
  const startRef = useRef(engine?.start ?? noProviderStart);
55
+ const [live] = useState(() => liveEngine(ENGINE_KEYS, NO_PROVIDER_ENGINE));
54
56
  const activeIdRef = useRef(activeId);
55
57
  useLayoutEffect(() => {
56
58
  launchRef.current = launch;
57
59
  startRef.current = engine?.start ?? noProviderStart;
60
+ live.set(engine ?? NO_PROVIDER_ENGINE);
58
61
  activeIdRef.current = active ? activeId : null;
59
62
  });
60
63
  const seenRef = useRef(null);
@@ -70,7 +73,7 @@ function useTourUrlSync({ launch, ready = true, stepAliases = [] }) {
70
73
  if (!pending || !ready) return;
71
74
  pendingRef.current = null;
72
75
  launchingRef.current = true;
73
- void Promise.resolve().then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current)).catch(() => void 0).finally(() => {
76
+ void Promise.resolve().then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current, live.engine)).catch(() => void 0).finally(() => {
74
77
  launchingRef.current = false;
75
78
  setSettled((n) => n + 1);
76
79
  });
@@ -95,6 +98,23 @@ function useTourUrlSync({ launch, ready = true, stepAliases = [] }) {
95
98
  const noProviderStart = () => {
96
99
  if (devWarnings()) console.warn("[ui-kit] useTourUrlSync: the launcher's start needs a TourProvider above it.");
97
100
  };
101
+ const noProviderCall = () => {
102
+ if (devWarnings()) console.warn("[ui-kit] useTourUrlSync: the launcher's engine needs a TourProvider above it.");
103
+ };
104
+ const NO_PROVIDER_ENGINE = {
105
+ active: false,
106
+ activeId: null,
107
+ start: noProviderStart,
108
+ stop: noProviderCall,
109
+ next: noProviderCall,
110
+ prev: noProviderCall,
111
+ goToStep: noProviderCall,
112
+ index: 0,
113
+ total: 0,
114
+ position: 0,
115
+ count: 0
116
+ };
117
+ const ENGINE_KEYS = Object.keys(NO_PROVIDER_ENGINE);
98
118
  const STATES = ["all", "todo", "done"];
99
119
  function useTourCatalogueUrlState(params = {}) {
100
120
  const [filter, setFilter] = useSearchParamsState({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tour/use-tour-url-sync.ts"],"sourcesContent":["import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type Context } from \"react\";\nimport * as ReactRouter from \"react-router\";\nimport { useLocation, useNavigate, type Navigator } from \"react-router\";\nimport { useSearchParamsState } from \"../hooks/use-search-param-state\";\nimport { useTourOptional, type TourStart } from \"./tour\";\nimport { devWarnings } from \"./tour-gates\";\n\n/* ── useTourUrlSync ─────────────────────────────────────────────────────────── */\n\nexport interface UseTourUrlSyncOptions {\n /**\n * Start the tour `id` at `startIndex` (0-based) — the app's launcher, which builds the\n * steps, awaits its own `prepare()` and passes `onFinish` (the completion, the points\n * toast) to `useTour().start`. An unknown id should do nothing. May return a promise:\n * the parameters are kept until it settles. Under a `TourProvider` the third argument is\n * that provider's `start` (0.50, as `TourProvider launch` gets it), so one launcher serves\n * both. Without a provider it is a `start` that does nothing (a development build warns).\n */\n launch: (id: string, options: { startIndex: number }, start: TourStart) => void | Promise<unknown>;\n /**\n * Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the\n * caches a branching tour reads)? A deep link waits for it, keeping its parameters, so\n * an F5 mid-tour resumes the right variant at the right step. Default `true`.\n */\n ready?: boolean;\n /**\n * Older names of `tourStep`, read when it is absent and removed with it — kastlan's\n * `ts` (§2.7, for one minor). Their value is a 0-based step index, as kastlan wrote it.\n */\n stepAliases?: readonly string[];\n}\n\ntype Place = { pathname: string; search: string; hash: string };\n\n/**\n * Has the router already moved on from the location React is still rendering?\n * keksdose's check (tour-url-sync.tsx, tours audit 2026-10-02): a mirror write built\n * from the page being LEFT is a replace back onto it, which cancels a step's `go()`.\n *\n * - A data router (`createBrowserRouter`, kastlan): its navigator has no location (build\n * review 0.43, kastlan A2), so the router's own state answers — a navigation still\n * loading, or a location it has committed that React has not rendered yet.\n * - A declarative router (`BrowserRouter`, keksdose): its navigator is its history and\n * carries where it is.\n * - Anything else answers \"not in flight\".\n */\nfunction navigationInFlight(history: Navigator | undefined, dataRouter: DataRouterProbe, rendered: Place) {\n const state = dataRouter?.router?.state;\n if (state && (state.navigation?.state ?? \"idle\") !== \"idle\") return true;\n const live = state?.location ?? (history as (Navigator & { location?: Partial<Place> }) | undefined)?.location;\n if (!live) return false;\n return (\n live.pathname !== rendered.pathname || (live.search ?? \"\") !== rendered.search || (live.hash ?? \"\") !== rendered.hash\n );\n}\n\ntype NavigationProbe = { navigator?: Navigator } | null;\nconst NO_NAVIGATION = createContext<NavigationProbe>(null);\n/** The data router's state, as `useSearchParamState` reads it. */\ntype DataRouterProbe = {\n router?: { state?: { navigation?: { state?: string }; location?: Partial<Place> } };\n} | null;\nconst NO_DATA_ROUTER = createContext<DataRouterProbe>(null);\nconst DataRouterContext: Context<DataRouterProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??\n NO_DATA_ROUTER\n );\n } catch {\n return NO_DATA_ROUTER;\n }\n})();\n/** Read off the namespace inside a `try`, as `useSearchParamState` reads the data router:\n * a test's partial `vi.mock(\"react-router\")` throws on an export it does not define. */\nconst NavigationContext: Context<NavigationProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_NavigationContext?: Context<NavigationProbe> }).UNSAFE_NavigationContext ??\n NO_NAVIGATION\n );\n } catch {\n return NO_NAVIGATION;\n }\n})();\n\n/** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based. */\nfunction stepOf(params: URLSearchParams, aliases: readonly string[]): number {\n const step = Number(params.get(\"tourStep\"));\n if (params.has(\"tourStep\")) return Number.isFinite(step) && step > 0 ? step - 1 : 0;\n for (const alias of aliases) {\n const value = Number(params.get(alias));\n if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;\n }\n return 0;\n}\n\n/**\n * Tours and the URL (docs/tours-harmonization.md §2.7, §6.4) — keksdose's `TourUrlSync`,\n * on the kit's engine (`useTourOptional`):\n *\n * - **`?tour=<id>` starts that tour whenever it APPEARS** — on load, and on in-app\n * navigation too (kastlan's assistant links to `/help/tours?tour=<id>`, ka F4) —\n * resuming at `&tourStep=<n>` (1-based). It waits for `ready`.\n * - **While a tour runs, `?tour=&tourStep=` mirror where it is**, so a reload or a copied\n * link pins the step (kk K-3). A step that navigates drops them; this puts them back.\n * - **At the end it strips only its own parameters**; `?q=`, `?state=` and the hash stay.\n *\n * Mount it once inside the router and the `TourProvider`. Without a provider it still\n * launches through `launch` and never mirrors.\n */\nexport function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTourUrlSyncOptions): void {\n const navigate = useNavigate();\n const location = useLocation();\n const { pathname, search, hash } = location;\n const history = useContext(NavigationContext)?.navigator;\n const dataRouter = useContext(DataRouterContext);\n const engine = useTourOptional();\n const active = engine?.active ?? false;\n const activeId = engine?.activeId ?? null;\n const index = engine?.index ?? 0;\n const urlTour = new URLSearchParams(search).get(\"tour\");\n const aliases = stepAliases.join(\"\\n\");\n\n const launchRef = useRef(launch);\n const startRef = useRef<TourStart>(engine?.start ?? noProviderStart);\n const activeIdRef = useRef(activeId);\n useLayoutEffect(() => {\n launchRef.current = launch;\n startRef.current = engine?.start ?? noProviderStart;\n activeIdRef.current = active ? activeId : null;\n });\n /** The `tour` value last seen in the URL: a tour starts when it CHANGES to an id. */\n const seenRef = useRef<string | null>(null);\n /** A deep link waiting for `ready`. */\n const pendingRef = useRef<{ id: string; startIndex: number } | null>(null);\n /** A launch under way (an app's `prepare()`). */\n const launchingRef = useRef(false);\n /** Bumped when a launch settles, so the mirror looks again. */\n const [settled, setSettled] = useState(0);\n\n useEffect(() => {\n if (urlTour !== seenRef.current) {\n seenRef.current = urlTour;\n // Its own mirror writing the running tour back is no new link.\n pendingRef.current =\n urlTour && urlTour !== activeIdRef.current\n ? { id: urlTour, startIndex: stepOf(new URLSearchParams(search), aliases ? aliases.split(\"\\n\") : []) }\n : null;\n }\n const pending = pendingRef.current;\n if (!pending || !ready) return;\n pendingRef.current = null;\n launchingRef.current = true;\n void Promise.resolve()\n .then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current))\n .catch(() => undefined)\n .finally(() => {\n launchingRef.current = false;\n setSettled((n) => n + 1);\n });\n // Only a change of the parameter, or readiness, starts a tour.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlTour, ready]);\n\n useEffect(() => {\n if (navigationInFlight(history, dataRouter, { pathname, search, hash })) return;\n // A link not yet used keeps its parameters: a reload in that window resumes.\n if (pendingRef.current || launchingRef.current) return;\n const tourId = active && activeId ? activeId : null;\n const own = [\"tour\", \"tourStep\", ...(aliases ? aliases.split(\"\\n\") : [])];\n const current = new URLSearchParams(search);\n // Nothing to mirror and nothing to strip: never touch the URL (a no-op replace\n // clobbers a page's own just-issued navigation, keksdose feedback #4).\n if (!tourId && !own.some((key) => current.has(key))) return;\n const next = new URLSearchParams(search);\n // `set` keeps a parameter where it stands; only the aliases (and, at the end, the\n // tour's own) go.\n for (const key of tourId ? own.slice(2) : own) next.delete(key);\n if (tourId) {\n next.set(\"tour\", tourId);\n next.set(\"tourStep\", String(index + 1));\n }\n if (next.toString() === current.toString()) return;\n // Spelled out, not `setSearchParams` (\"?…\" drops the hash, keksdose dev#495).\n void navigate({ pathname, search: next.toString(), hash }, { replace: true, state: location.state });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [active, activeId, index, pathname, search, hash, settled, aliases]);\n}\n\n/** `start` for a launcher run with no `TourProvider` above `useTourUrlSync`. */\nconst noProviderStart: TourStart = () => {\n if (devWarnings()) console.warn(\"[ui-kit] useTourUrlSync: the launcher's start needs a TourProvider above it.\");\n};\n\n/* ── useTourCatalogueUrlState ───────────────────────────────────────────────── */\n\n/** The catalogue's state filter. */\nexport type TourStateFilter = \"all\" | \"todo\" | \"done\";\n\n/** The catalogue's filters: the search, the group (`null`: every group), the state. */\nexport interface TourCatalogueFilter {\n query: string;\n group: string | null;\n state: TourStateFilter;\n}\n\nconst STATES: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The catalogue's filters in the URL (§2.8) — keksdose's `?q`, `?topic`, `?state`, each\n * written with `replace` (typing does not bury the Back button), the defaults as the\n * clean URL. Spread it onto the catalogue: `<TourCatalogue {...useTourCatalogueUrlState()} … />`.\n * `params` renames them (kastlan's `?group`). Requires a react-router Router.\n */\nexport function useTourCatalogueUrlState(\n params: { query?: string; group?: string; state?: string } = {},\n): TourCatalogueFilter & { onChange: (patch: Partial<TourCatalogueFilter>) => void } {\n const [filter, setFilter] = useSearchParamsState<TourCatalogueFilter>({\n query: { param: params.query ?? \"q\", default: \"\", history: \"replace\" },\n group: { param: params.group ?? \"topic\", default: null, history: \"replace\", parse: (raw) => raw || undefined },\n state: {\n param: params.state ?? \"state\",\n default: \"all\",\n history: \"replace\",\n parse: (raw) => STATES.find((x) => x === raw),\n },\n });\n // `group: null` is a value to the setter (it clears `?topic`); `undefined` leaves it.\n return { ...filter, onChange: setFilter };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/tour/use-tour-url-sync.ts"],"sourcesContent":["import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type Context } from \"react\";\nimport * as ReactRouter from \"react-router\";\nimport { useLocation, useNavigate, type Navigator } from \"react-router\";\nimport { useSearchParamsState } from \"../hooks/use-search-param-state\";\nimport { useTourOptional, type TourEngine, type TourStart } from \"./tour\";\nimport { liveEngine } from \"./tour-engine\";\nimport { devWarnings } from \"./tour-gates\";\n\n/* ── useTourUrlSync ─────────────────────────────────────────────────────────── */\n\nexport interface UseTourUrlSyncOptions {\n /**\n * Start the tour `id` at `startIndex` (0-based) — the app's launcher, which builds the\n * steps, awaits its own `prepare()` and passes `onFinish` (the completion, the points\n * toast) to `useTour().start`. An unknown id should do nothing. May return a promise:\n * the parameters are kept until it settles. Under a `TourProvider` the third argument is\n * that provider's `start` (0.50, as `TourProvider launch` gets it), so one launcher serves\n * both; the fourth is its whole engine, read live (0.50.1, `TourEngine`: `stop`, `next`,\n * `active`, …). Without a provider `start` and the engine's methods do nothing (a\n * development build warns) and the engine reads as idle.\n */\n launch: (\n id: string,\n options: { startIndex: number },\n start: TourStart,\n engine: TourEngine,\n ) => void | Promise<unknown>;\n /**\n * Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the\n * caches a branching tour reads)? A deep link waits for it, keeping its parameters, so\n * an F5 mid-tour resumes the right variant at the right step. Default `true`.\n */\n ready?: boolean;\n /**\n * Older names of `tourStep`, read when it is absent and removed with it — kastlan's\n * `ts` (§2.7, for one minor). Their value is a 0-based step index, as kastlan wrote it.\n */\n stepAliases?: readonly string[];\n}\n\ntype Place = { pathname: string; search: string; hash: string };\n\n/**\n * Has the router already moved on from the location React is still rendering?\n * keksdose's check (tour-url-sync.tsx, tours audit 2026-10-02): a mirror write built\n * from the page being LEFT is a replace back onto it, which cancels a step's `go()`.\n *\n * - A data router (`createBrowserRouter`, kastlan): its navigator has no location (build\n * review 0.43, kastlan A2), so the router's own state answers — a navigation still\n * loading, or a location it has committed that React has not rendered yet.\n * - A declarative router (`BrowserRouter`, keksdose): its navigator is its history and\n * carries where it is.\n * - Anything else answers \"not in flight\".\n */\nfunction navigationInFlight(history: Navigator | undefined, dataRouter: DataRouterProbe, rendered: Place) {\n const state = dataRouter?.router?.state;\n if (state && (state.navigation?.state ?? \"idle\") !== \"idle\") return true;\n const live = state?.location ?? (history as (Navigator & { location?: Partial<Place> }) | undefined)?.location;\n if (!live) return false;\n return (\n live.pathname !== rendered.pathname || (live.search ?? \"\") !== rendered.search || (live.hash ?? \"\") !== rendered.hash\n );\n}\n\ntype NavigationProbe = { navigator?: Navigator } | null;\nconst NO_NAVIGATION = createContext<NavigationProbe>(null);\n/** The data router's state, as `useSearchParamState` reads it. */\ntype DataRouterProbe = {\n router?: { state?: { navigation?: { state?: string }; location?: Partial<Place> } };\n} | null;\nconst NO_DATA_ROUTER = createContext<DataRouterProbe>(null);\nconst DataRouterContext: Context<DataRouterProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??\n NO_DATA_ROUTER\n );\n } catch {\n return NO_DATA_ROUTER;\n }\n})();\n/** Read off the namespace inside a `try`, as `useSearchParamState` reads the data router:\n * a test's partial `vi.mock(\"react-router\")` throws on an export it does not define. */\nconst NavigationContext: Context<NavigationProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_NavigationContext?: Context<NavigationProbe> }).UNSAFE_NavigationContext ??\n NO_NAVIGATION\n );\n } catch {\n return NO_NAVIGATION;\n }\n})();\n\n/** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based. */\nfunction stepOf(params: URLSearchParams, aliases: readonly string[]): number {\n const step = Number(params.get(\"tourStep\"));\n if (params.has(\"tourStep\")) return Number.isFinite(step) && step > 0 ? step - 1 : 0;\n for (const alias of aliases) {\n const value = Number(params.get(alias));\n if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;\n }\n return 0;\n}\n\n/**\n * Tours and the URL (docs/tours-harmonization.md §2.7, §6.4) — keksdose's `TourUrlSync`,\n * on the kit's engine (`useTourOptional`):\n *\n * - **`?tour=<id>` starts that tour whenever it APPEARS** — on load, and on in-app\n * navigation too (kastlan's assistant links to `/help/tours?tour=<id>`, ka F4) —\n * resuming at `&tourStep=<n>` (1-based). It waits for `ready`.\n * - **While a tour runs, `?tour=&tourStep=` mirror where it is**, so a reload or a copied\n * link pins the step (kk K-3). A step that navigates drops them; this puts them back.\n * - **At the end it strips only its own parameters**; `?q=`, `?state=` and the hash stay.\n *\n * Mount it once inside the router and the `TourProvider`. Without a provider it still\n * launches through `launch` and never mirrors.\n */\nexport function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTourUrlSyncOptions): void {\n const navigate = useNavigate();\n const location = useLocation();\n const { pathname, search, hash } = location;\n const history = useContext(NavigationContext)?.navigator;\n const dataRouter = useContext(DataRouterContext);\n const engine = useTourOptional();\n const active = engine?.active ?? false;\n const activeId = engine?.activeId ?? null;\n const index = engine?.index ?? 0;\n const urlTour = new URLSearchParams(search).get(\"tour\");\n const aliases = stepAliases.join(\"\\n\");\n\n const launchRef = useRef(launch);\n const startRef = useRef<TourStart>(engine?.start ?? noProviderStart);\n const [live] = useState(() => liveEngine<TourEngine>(ENGINE_KEYS, NO_PROVIDER_ENGINE));\n const activeIdRef = useRef(activeId);\n useLayoutEffect(() => {\n launchRef.current = launch;\n startRef.current = engine?.start ?? noProviderStart;\n live.set(engine ?? NO_PROVIDER_ENGINE);\n activeIdRef.current = active ? activeId : null;\n });\n /** The `tour` value last seen in the URL: a tour starts when it CHANGES to an id. */\n const seenRef = useRef<string | null>(null);\n /** A deep link waiting for `ready`. */\n const pendingRef = useRef<{ id: string; startIndex: number } | null>(null);\n /** A launch under way (an app's `prepare()`). */\n const launchingRef = useRef(false);\n /** Bumped when a launch settles, so the mirror looks again. */\n const [settled, setSettled] = useState(0);\n\n useEffect(() => {\n if (urlTour !== seenRef.current) {\n seenRef.current = urlTour;\n // Its own mirror writing the running tour back is no new link.\n pendingRef.current =\n urlTour && urlTour !== activeIdRef.current\n ? { id: urlTour, startIndex: stepOf(new URLSearchParams(search), aliases ? aliases.split(\"\\n\") : []) }\n : null;\n }\n const pending = pendingRef.current;\n if (!pending || !ready) return;\n pendingRef.current = null;\n launchingRef.current = true;\n void Promise.resolve()\n .then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current, live.engine))\n .catch(() => undefined)\n .finally(() => {\n launchingRef.current = false;\n setSettled((n) => n + 1);\n });\n // Only a change of the parameter, or readiness, starts a tour.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlTour, ready]);\n\n useEffect(() => {\n if (navigationInFlight(history, dataRouter, { pathname, search, hash })) return;\n // A link not yet used keeps its parameters: a reload in that window resumes.\n if (pendingRef.current || launchingRef.current) return;\n const tourId = active && activeId ? activeId : null;\n const own = [\"tour\", \"tourStep\", ...(aliases ? aliases.split(\"\\n\") : [])];\n const current = new URLSearchParams(search);\n // Nothing to mirror and nothing to strip: never touch the URL (a no-op replace\n // clobbers a page's own just-issued navigation, keksdose feedback #4).\n if (!tourId && !own.some((key) => current.has(key))) return;\n const next = new URLSearchParams(search);\n // `set` keeps a parameter where it stands; only the aliases (and, at the end, the\n // tour's own) go.\n for (const key of tourId ? own.slice(2) : own) next.delete(key);\n if (tourId) {\n next.set(\"tour\", tourId);\n next.set(\"tourStep\", String(index + 1));\n }\n if (next.toString() === current.toString()) return;\n // Spelled out, not `setSearchParams` (\"?…\" drops the hash, keksdose dev#495).\n void navigate({ pathname, search: next.toString(), hash }, { replace: true, state: location.state });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [active, activeId, index, pathname, search, hash, settled, aliases]);\n}\n\n/** `start` for a launcher run with no `TourProvider` above `useTourUrlSync`. */\nconst noProviderStart: TourStart = () => {\n if (devWarnings()) console.warn(\"[ui-kit] useTourUrlSync: the launcher's start needs a TourProvider above it.\");\n};\n\nconst noProviderCall = () => {\n if (devWarnings()) console.warn(\"[ui-kit] useTourUrlSync: the launcher's engine needs a TourProvider above it.\");\n};\n/** The engine for a launcher run with no `TourProvider` above `useTourUrlSync`: idle. */\nconst NO_PROVIDER_ENGINE: TourEngine = {\n active: false,\n activeId: null,\n start: noProviderStart,\n stop: noProviderCall,\n next: noProviderCall,\n prev: noProviderCall,\n goToStep: noProviderCall,\n index: 0,\n total: 0,\n position: 0,\n count: 0,\n};\nconst ENGINE_KEYS = Object.keys(NO_PROVIDER_ENGINE) as (keyof TourEngine)[];\n\n/* ── useTourCatalogueUrlState ───────────────────────────────────────────────── */\n\n/** The catalogue's state filter. */\nexport type TourStateFilter = \"all\" | \"todo\" | \"done\";\n\n/** The catalogue's filters: the search, the group (`null`: every group), the state. */\nexport interface TourCatalogueFilter {\n query: string;\n group: string | null;\n state: TourStateFilter;\n}\n\nconst STATES: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The catalogue's filters in the URL (§2.8) — keksdose's `?q`, `?topic`, `?state`, each\n * written with `replace` (typing does not bury the Back button), the defaults as the\n * clean URL. Spread it onto the catalogue: `<TourCatalogue {...useTourCatalogueUrlState()} … />`.\n * `params` renames them (kastlan's `?group`). Requires a react-router Router.\n */\nexport function useTourCatalogueUrlState(\n params: { query?: string; group?: string; state?: string } = {},\n): TourCatalogueFilter & { onChange: (patch: Partial<TourCatalogueFilter>) => void } {\n const [filter, setFilter] = useSearchParamsState<TourCatalogueFilter>({\n query: { param: params.query ?? \"q\", default: \"\", history: \"replace\" },\n group: { param: params.group ?? \"topic\", default: null, history: \"replace\", parse: (raw) => raw || undefined },\n state: {\n param: params.state ?? \"state\",\n default: \"all\",\n history: \"replace\",\n parse: (raw) => STATES.find((x) => x === raw),\n },\n });\n // `group: null` is a value to the setter (it clears `?topic`); `undefined` leaves it.\n return { ...filter, onChange: setFilter };\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.50.0",
3
+ "version": "0.50.1",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -7,6 +7,7 @@ import { Spinner } from "./spinner";
7
7
  import { useAnchoredPanel } from "../hooks/use-anchored-panel";
8
8
  import { takeEscape, useDismissLayer } from "../hooks/use-dismiss";
9
9
  import { isImeEnter } from "../lib/ime";
10
+ import { isQuietFocus } from "../lib/quiet-focus";
10
11
  import { useAnchorDir } from "./use-anchor-dir";
11
12
  import {
12
13
  ComboboxFieldLabel,
@@ -268,6 +269,8 @@ export function Autocomplete<V extends string | number = string>(props: Autocomp
268
269
  // either way, so a caller that controls only SOMETIMES (`open={confirming ? false :
269
270
  // undefined}`) hands back a field that is exactly as open as focus says it is.
270
271
  const open = openProp ?? ownOpen;
272
+ // The field took the tour's quiet focus (§11.8) and has not opened since.
273
+ const quietRef = useRef(false);
271
274
  const setOpen = (next: boolean) => {
272
275
  setOwnOpen(next);
273
276
  if (next !== open) onOpenChange?.(next);
@@ -410,11 +413,26 @@ export function Autocomplete<V extends string | number = string>(props: Autocomp
410
413
  onFocus?.(e);
411
414
  // A locked field takes focus to say why, and opens nothing.
412
415
  if (locked) return;
416
+ // The tour's focus on arrival (§11.8) looks nothing up: the list would
417
+ // cover its card (0.50.1). A click, ArrowDown or typing opens it.
418
+ if (isQuietFocus(e.currentTarget)) {
419
+ quietRef.current = true;
420
+ return;
421
+ }
413
422
  // A seeded field (keksdose's `initialQuery`) is looked up as it is
414
423
  // focused; an empty one asks nothing, because of `minChars`.
415
424
  setOpen(true);
416
425
  }}
426
+ // After a quiet focus, the person's click on the already-focused field
427
+ // fires no `focus`: the click is their "open" instead.
428
+ onClick={(e) => {
429
+ rest.onClick?.(e);
430
+ if (!quietRef.current || locked) return;
431
+ quietRef.current = false;
432
+ setOpen(true);
433
+ }}
417
434
  onBlur={(e) => {
435
+ quietRef.current = false;
418
436
  onBlur?.(e);
419
437
  close();
420
438
  }}
@@ -6,6 +6,7 @@ import { cn } from "../lib/cn";
6
6
  import { useDropdown } from "./dropdown";
7
7
  import { takeEscape, useDismissLayer } from "../hooks/use-dismiss";
8
8
  import { isImeEnter } from "../lib/ime";
9
+ import { isQuietFocus } from "../lib/quiet-focus";
9
10
  import { usePhoneLayout } from "../hooks/use-breakpoint";
10
11
  import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
11
12
  import { ComboboxFieldLabel, endHintRowProps, GROUP_HEADING_STICKY, useActiveOptionScroll } from "./combobox-core";
@@ -421,11 +422,14 @@ export function Combobox(props: ComboboxProps) {
421
422
  autoFocus={autoFocus}
422
423
  onBlur={onBlur}
423
424
  onMouseDown={primaryOnly.onMouseDown}
424
- onFocus={() => {
425
+ onFocus={(e) => {
425
426
  // A back/forward mouse button focuses this field on its way to
426
427
  // navigating; it is not a request to open anything (live #309 rework).
427
428
  // A locked field takes focus to say why, and opens nothing.
428
429
  if (primaryOnly.fromAuxButton() || locked) return;
430
+ // The tour's focus on arrival (§11.8) opens nothing: the list would cover
431
+ // its card (0.50.1). The click handler below still opens on a click.
432
+ if (isQuietFocus(e.currentTarget)) return;
429
433
  setOpen(true);
430
434
  setTyping(false);
431
435
  // The sheet carries its own input, so the field behind it must not also
@@ -8,6 +8,7 @@ import { cn } from "../lib/cn";
8
8
  import { useDropdown } from "./dropdown";
9
9
  import { takeEscape, useDismissLayer } from "../hooks/use-dismiss";
10
10
  import { isImeEnter } from "../lib/ime";
11
+ import { isQuietFocus } from "../lib/quiet-focus";
11
12
  import { usePhoneLayout } from "../hooks/use-breakpoint";
12
13
  import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
13
14
  import {
@@ -255,6 +256,8 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
255
256
  // Escape layer is this field's own.
256
257
  const { open, setOpen, wrapperRef, panelRef } = useDropdown({ backCloses: !isPhone, escapeLayer: false });
257
258
  const primaryOnly = usePrimaryPressOnly();
259
+ // The field took the tour's quiet focus (§11.8) and has not opened since.
260
+ const quietRef = useRef(false);
258
261
  const [active, setActive] = useState(-1);
259
262
  // null = not editing → the input shows the selected option's label.
260
263
  const [text, setText] = useState<string | null>(null);
@@ -508,13 +511,27 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
508
511
  // say why, and opens nothing.
509
512
  if (primaryOnly.fromAuxButton() || locked) return;
510
513
  setText(shown);
514
+ // The tour's focus on arrival (§11.8) opens nothing: the list would cover
515
+ // its card (0.50.1). A click, ArrowDown or typing opens it.
516
+ if (isQuietFocus(e.currentTarget)) {
517
+ quietRef.current = true;
518
+ return;
519
+ }
511
520
  e.currentTarget.select();
512
521
  setOpen(true);
513
522
  // The sheet has its own input; the field behind it must not also pull up
514
523
  // the keyboard and scroll the page under the dialog.
515
524
  if (isPhone) sheetInputRef.current?.focus();
516
525
  }}
526
+ // After a quiet focus, the person's click on the already-focused field
527
+ // fires no `focus`: the click is their "open" instead.
528
+ onClick={() => {
529
+ if (!quietRef.current || locked) return;
530
+ quietRef.current = false;
531
+ setOpen(true);
532
+ }}
517
533
  onBlur={() => {
534
+ quietRef.current = false;
518
535
  // On a phone the blur is the SHEET taking focus, not the user leaving the
519
536
  // field — reconciling there would close the sheet the instant it opened.
520
537
  if (!isPhone) reconcile();
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Focus that is not a request to open anything (0.50.1, keksdose B1).
3
+ *
4
+ * A guided tour focuses a field step's first control on arrival (tours §11.8). The kit's
5
+ * pickers that open their list on focus — Combobox (and the category pickers built on it),
6
+ * Autocomplete, InlineEntityCombobox — opened it then too, and the list, a later layer
7
+ * (§11.6), covered the tour card with its Next and "Leave empty".
8
+ *
9
+ * {@link focusQuietly} marks the element for the length of its synchronous `focus()` call;
10
+ * a picker's `onFocus` asks {@link isQuietFocus} and, when it is, takes the focus without
11
+ * opening. React dispatches `onFocus` inside the native `focusin`, which `focus()` fires
12
+ * synchronously, so the mark is seen by exactly that focus and by no later one. A click or
13
+ * Tab of the person's own opens as before; from a quiet focus, ArrowDown, typing or a click
14
+ * opens the list.
15
+ */
16
+ let quiet: Element | null = null;
17
+
18
+ /** `el.focus(options)`, telling the kit's open-on-focus pickers not to open. */
19
+ export function focusQuietly(el: HTMLElement, options?: FocusOptions): void {
20
+ const before = quiet;
21
+ quiet = el;
22
+ try {
23
+ el.focus(options);
24
+ } finally {
25
+ quiet = before;
26
+ }
27
+ }
28
+
29
+ /** True while `el` is being focused by {@link focusQuietly}. */
30
+ export function isQuietFocus(el: Element): boolean {
31
+ return quiet === el;
32
+ }
@@ -24,3 +24,11 @@ export {
24
24
  type StubbedBoxSize,
25
25
  type ViewportOptions,
26
26
  } from "./viewport";
27
+ // 0.50.1 (keksdose N4): a valid `TourStepContext` for a test that calls a step's
28
+ // `beforeStep` or `when` itself — `beforeStep!(tourStepContext())`.
29
+ export {
30
+ tourStepContext,
31
+ type TestTourStepContext,
32
+ type TourStepContextOptions,
33
+ type TourStepVia,
34
+ } from "./tour";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 0.50.1 (keksdose N4): a `TourStepContext` for a test that calls a step's `beforeStep` or
3
+ * `when` itself. 0.50 gave both the context, so `beforeStep!()` is a type error;
4
+ * `beforeStep!(tourStepContext())` is the drop-in.
5
+ *
6
+ * Spelled out here rather than imported from the tour module, so `@eifi1/ui-kit/testing`
7
+ * still reaches no package (the tour module reaches React); the kit's own test pins that it
8
+ * is assignable to `TourStepContext`.
9
+ */
10
+
11
+ /** How a step is entered: `TourStepContext["via"]`. */
12
+ export type TourStepVia = "start" | "at" | "next" | "back" | "goto";
13
+
14
+ /** The shape of `TourStepContext` (`@eifi1/ui-kit/tour`). */
15
+ export interface TestTourStepContext {
16
+ gate(key: string): boolean | undefined;
17
+ via: TourStepVia;
18
+ }
19
+
20
+ export interface TourStepContextOptions {
21
+ /** How the step is entered. Default `"start"`. */
22
+ via?: TourStepVia;
23
+ /**
24
+ * The published gates, as the form would publish them: `{ "txn.transfer": true }`. A key
25
+ * left out answers `undefined` (nobody publishes it). Default: none.
26
+ */
27
+ gates?: Readonly<Record<string, boolean | undefined>>;
28
+ }
29
+
30
+ /**
31
+ * A valid step context: `via: "start"` and no gates published, unless `options` say
32
+ * otherwise. `step.beforeStep!(tourStepContext())`, `step.when!(tourStepContext({ gates:
33
+ * { "txn.transfer": true } }))`, `tourStepContext({ via: "at" })` for a "Guide me" entry.
34
+ */
35
+ export function tourStepContext(options: TourStepContextOptions = {}): TestTourStepContext {
36
+ const gates = options.gates ?? {};
37
+ return {
38
+ gate: (key) => (Object.prototype.hasOwnProperty.call(gates, key) ? gates[key] : undefined),
39
+ via: options.via ?? "start",
40
+ };
41
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 0.50.1 (keksdose N1): the tour engine a launcher is handed as its fourth argument — one
3
+ * object for the life of the provider (or of `useTourUrlSync`) whose fields always read the
4
+ * engine's LATEST render: `engine.active`, `engine.index` after an `await` are current, and
5
+ * `engine.stop()` mid-run stops the run that is up now. Internal: the type is `TourEngine`.
6
+ *
7
+ * `set` is called from a layout effect with each render's engine; `engine` reads it.
8
+ */
9
+ export function liveEngine<T extends object>(
10
+ keys: readonly (keyof T & string)[],
11
+ initial: T | null = null,
12
+ ): { engine: T; set: (current: T) => void } {
13
+ let current = initial;
14
+ const read = () => {
15
+ if (!current)
16
+ throw new Error(
17
+ "[ui-kit] The tour engine was read before its provider rendered.",
18
+ );
19
+ return current;
20
+ };
21
+ const engine = {} as T;
22
+ for (const key of keys) {
23
+ Object.defineProperty(engine, key, {
24
+ enumerable: true,
25
+ get: () => {
26
+ const value = read()[key];
27
+ // A method is looked up when it is CALLED, so a kept `engine.stop` is the latest too.
28
+ return typeof value === "function"
29
+ ? (...args: unknown[]) =>
30
+ (read()[key] as (...a: unknown[]) => unknown)(...args)
31
+ : value;
32
+ },
33
+ });
34
+ }
35
+ return {
36
+ engine,
37
+ set: (next) => {
38
+ current = next;
39
+ },
40
+ };
41
+ }
package/src/tour/tour.tsx CHANGED
@@ -20,11 +20,13 @@ import { useDismissLayer } from "../hooks/use-dismiss";
20
20
  import { sameRect } from "../hooks/use-anchored-rect";
21
21
  import { dirOf, horizontalStep } from "../lib/direction";
22
22
  import { isComposing } from "../lib/ime";
23
+ import { focusQuietly } from "../lib/quiet-focus";
23
24
  import type { Direction } from "../lib/direction";
24
25
  import { DEFAULT_TOUR_LABELS } from "./tour-labels";
25
26
  import type { TourInfo } from "./tour-info";
26
27
  import { completeStored, rememberAccount, rememberMigrate, type UseTourProgressOptions } from "./use-tour-progress";
27
28
  import { createGateRegistry, devWarnings, TourInternalContext, type GateRegistry } from "./tour-gates";
29
+ import { liveEngine } from "./tour-engine";
28
30
  import { DOCK_MAX_WIDTH, dockEdge, dockStyle, readBand, scrollIntoFreeHalf, type DockEdge } from "./tour-dock";
29
31
 
30
32
  export { useTourGate, useTourGateRelease } from "./tour-gates";
@@ -193,15 +195,42 @@ export type TourStart = TourContextValue["start"];
193
195
  * `useTourUrlSync`.
194
196
  *
195
197
  * `start` is the provider's own `start` (keksdose N4), so the launcher needs no ref to a
196
- * component below the provider and may be defined above it. `useTourUrlSync` passes it too
197
- * (under a provider). A two-argument launcher that reaches `start` its own way still works.
198
+ * component below the provider and may be defined above it. `engine` (0.50.1, keksdose N1)
199
+ * is the rest of the engine — `stop`, `next`, `active`, … — read live, see {@link TourEngine}.
200
+ * `useTourUrlSync` passes both (under a provider). A launcher taking fewer arguments still
201
+ * works.
198
202
  */
199
203
  export type TourLauncher = (
200
204
  id: string,
201
205
  opts: { startIndex?: number; at?: TourAt },
202
206
  start: TourStart,
207
+ engine: TourEngine,
203
208
  ) => void | Promise<unknown>;
204
209
 
210
+ /**
211
+ * 0.50.1 (keksdose N1): the whole engine `useTour()` returns, handed to a launcher as its
212
+ * FOURTH argument by `TourProvider` and `useTourUrlSync`, so a tour built above the provider
213
+ * can also `stop`, `next` or read `active` with no `useTour()` registered from below. One
214
+ * object per provider whose fields read the latest render (`engine.active` after an
215
+ * `await` is current); its methods are the provider's own. `start` (the third argument) is
216
+ * `engine.start`.
217
+ */
218
+ export type TourEngine = TourContextValue;
219
+
220
+ const ENGINE_KEYS = [
221
+ "active",
222
+ "activeId",
223
+ "start",
224
+ "stop",
225
+ "next",
226
+ "prev",
227
+ "goToStep",
228
+ "index",
229
+ "total",
230
+ "position",
231
+ "count",
232
+ ] as const satisfies readonly (keyof TourContextValue)[];
233
+
205
234
  interface StartOptions {
206
235
  /** Stable id of the tour, surfaced as `activeId` so the app can mirror it to
207
236
  * the URL (deep-link / evaluation tracking) and record completion. Defaults to
@@ -463,8 +492,11 @@ export function TourProvider({
463
492
  },
464
493
  [arrive, forward, backward],
465
494
  );
466
- // The provider's own `start`, handed to `launch` as its third argument (keksdose N4).
495
+ // The provider's own `start` and the live engine, handed to `launch` as its third and
496
+ // fourth arguments (keksdose N4, N1).
467
497
  const ownStartRef = useRef<TourContextValue["start"] | null>(null);
498
+ const [live] = useState(() => liveEngine<TourEngine>(ENGINE_KEYS));
499
+ const engine = live.engine;
468
500
  const start = useCallback(
469
501
  (what: TourStep[] | string, opts?: StartOptions | { at?: TourAt }) => {
470
502
  if (typeof what !== "string") {
@@ -476,11 +508,11 @@ export function TourProvider({
476
508
  if (devWarnings()) console.warn(`[ui-kit] start("${what}") needs TourProvider's \`launch\` prop.`);
477
509
  return;
478
510
  }
479
- void Promise.resolve(run(what, { at: opts?.at }, ownStartRef.current!)).catch((error: unknown) =>
511
+ void Promise.resolve(run(what, { at: opts?.at }, ownStartRef.current!, engine)).catch((error: unknown) =>
480
512
  console.error(error),
481
513
  );
482
514
  },
483
- [startSteps],
515
+ [startSteps, engine],
484
516
  ) as TourContextValue["start"];
485
517
  useLayoutEffect(() => {
486
518
  ownStartRef.current = start;
@@ -573,23 +605,27 @@ export function TourProvider({
573
605
  });
574
606
  }, [active, gates, arrive, forward, finish]);
575
607
 
608
+ const value: TourContextValue = {
609
+ active,
610
+ activeId,
611
+ start,
612
+ stop,
613
+ next,
614
+ prev,
615
+ goToStep,
616
+ index,
617
+ total,
618
+ position: active ? nav.position : 0,
619
+ count: active ? nav.count : 0,
620
+ };
621
+ // Read by the live engine (`launch`'s fourth argument) after this render commits.
622
+ useLayoutEffect(() => {
623
+ live.set(value);
624
+ });
625
+
576
626
  return (
577
627
  <TourInternalContext.Provider value={internals}>
578
- <TourContext.Provider
579
- value={{
580
- active,
581
- activeId,
582
- start,
583
- stop,
584
- next,
585
- prev,
586
- goToStep,
587
- index,
588
- total,
589
- position: active ? nav.position : 0,
590
- count: active ? nav.count : 0,
591
- }}
592
- >
628
+ <TourContext.Provider value={value}>
593
629
  {children}
594
630
  {active && (
595
631
  <TourOverlay
@@ -1104,11 +1140,14 @@ function TourOverlay({
1104
1140
  if (!ready || !guided || !step.target) return;
1105
1141
  const el = queryVisibleTarget(step.target);
1106
1142
  const control = el ? firstFocusable(el) : null;
1143
+ // Described BEFORE it is focused, so a screen reader reads the step with the field's
1144
+ // name. Focused quietly (0.50.1, keksdose B1): a picker that opens on focus stays
1145
+ // closed, or its list would cover the card; ArrowDown, typing or a click opens it.
1146
+ const previous = control ? control.getAttribute("aria-describedby") : null;
1147
+ if (control) control.setAttribute("aria-describedby", previous ? `${previous} ${bodyId}` : bodyId);
1107
1148
  if (coarsePointer() || !control) cardRef.current?.focus();
1108
- else if (!el!.contains(document.activeElement)) control.focus();
1149
+ else if (!el!.contains(document.activeElement)) focusQuietly(control);
1109
1150
  if (!control) return;
1110
- const previous = control.getAttribute("aria-describedby");
1111
- control.setAttribute("aria-describedby", previous ? `${previous} ${bodyId}` : bodyId);
1112
1151
  return () => {
1113
1152
  if (previous === null) control.removeAttribute("aria-describedby");
1114
1153
  else control.setAttribute("aria-describedby", previous);
@@ -1238,7 +1277,8 @@ function TourOverlay({
1238
1277
  const lockedNext = useCallback(() => {
1239
1278
  const el = step.target ? queryVisibleTarget(step.target) : null;
1240
1279
  const control = el ? firstFocusable(el) : null;
1241
- control?.focus();
1280
+ // Quietly, as on arrival: the hint is read with the card in view, not under a list.
1281
+ if (control) focusQuietly(control);
1242
1282
  announce(gateHint);
1243
1283
  }, [step.target, announce, gateHint]);
1244
1284
  const tryNext = awaitsRelease ? NOOP : locked ? lockedNext : onNext;
@@ -2,7 +2,8 @@ import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState
2
2
  import * as ReactRouter from "react-router";
3
3
  import { useLocation, useNavigate, type Navigator } from "react-router";
4
4
  import { useSearchParamsState } from "../hooks/use-search-param-state";
5
- import { useTourOptional, type TourStart } from "./tour";
5
+ import { useTourOptional, type TourEngine, type TourStart } from "./tour";
6
+ import { liveEngine } from "./tour-engine";
6
7
  import { devWarnings } from "./tour-gates";
7
8
 
8
9
  /* ── useTourUrlSync ─────────────────────────────────────────────────────────── */
@@ -14,9 +15,16 @@ export interface UseTourUrlSyncOptions {
14
15
  * toast) to `useTour().start`. An unknown id should do nothing. May return a promise:
15
16
  * the parameters are kept until it settles. Under a `TourProvider` the third argument is
16
17
  * that provider's `start` (0.50, as `TourProvider launch` gets it), so one launcher serves
17
- * both. Without a provider it is a `start` that does nothing (a development build warns).
18
+ * both; the fourth is its whole engine, read live (0.50.1, `TourEngine`: `stop`, `next`,
19
+ * `active`, …). Without a provider `start` and the engine's methods do nothing (a
20
+ * development build warns) and the engine reads as idle.
18
21
  */
19
- launch: (id: string, options: { startIndex: number }, start: TourStart) => void | Promise<unknown>;
22
+ launch: (
23
+ id: string,
24
+ options: { startIndex: number },
25
+ start: TourStart,
26
+ engine: TourEngine,
27
+ ) => void | Promise<unknown>;
20
28
  /**
21
29
  * Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the
22
30
  * caches a branching tour reads)? A deep link waits for it, keeping its parameters, so
@@ -124,10 +132,12 @@ export function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTo
124
132
 
125
133
  const launchRef = useRef(launch);
126
134
  const startRef = useRef<TourStart>(engine?.start ?? noProviderStart);
135
+ const [live] = useState(() => liveEngine<TourEngine>(ENGINE_KEYS, NO_PROVIDER_ENGINE));
127
136
  const activeIdRef = useRef(activeId);
128
137
  useLayoutEffect(() => {
129
138
  launchRef.current = launch;
130
139
  startRef.current = engine?.start ?? noProviderStart;
140
+ live.set(engine ?? NO_PROVIDER_ENGINE);
131
141
  activeIdRef.current = active ? activeId : null;
132
142
  });
133
143
  /** The `tour` value last seen in the URL: a tour starts when it CHANGES to an id. */
@@ -153,7 +163,7 @@ export function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTo
153
163
  pendingRef.current = null;
154
164
  launchingRef.current = true;
155
165
  void Promise.resolve()
156
- .then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current))
166
+ .then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current, live.engine))
157
167
  .catch(() => undefined)
158
168
  .finally(() => {
159
169
  launchingRef.current = false;
@@ -193,6 +203,25 @@ const noProviderStart: TourStart = () => {
193
203
  if (devWarnings()) console.warn("[ui-kit] useTourUrlSync: the launcher's start needs a TourProvider above it.");
194
204
  };
195
205
 
206
+ const noProviderCall = () => {
207
+ if (devWarnings()) console.warn("[ui-kit] useTourUrlSync: the launcher's engine needs a TourProvider above it.");
208
+ };
209
+ /** The engine for a launcher run with no `TourProvider` above `useTourUrlSync`: idle. */
210
+ const NO_PROVIDER_ENGINE: TourEngine = {
211
+ active: false,
212
+ activeId: null,
213
+ start: noProviderStart,
214
+ stop: noProviderCall,
215
+ next: noProviderCall,
216
+ prev: noProviderCall,
217
+ goToStep: noProviderCall,
218
+ index: 0,
219
+ total: 0,
220
+ position: 0,
221
+ count: 0,
222
+ };
223
+ const ENGINE_KEYS = Object.keys(NO_PROVIDER_ENGINE) as (keyof TourEngine)[];
224
+
196
225
  /* ── useTourCatalogueUrlState ───────────────────────────────────────────────── */
197
226
 
198
227
  /** The catalogue's state filter. */