@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.
- package/README.md +5 -5
- package/dist/components/autocomplete.js +13 -0
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/combobox.js +3 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +12 -0
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/lib/quiet-focus.d.ts +4 -0
- package/dist/lib/quiet-focus.js +19 -0
- package/dist/lib/quiet-focus.js.map +1 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/tour.d.ts +31 -0
- package/dist/testing/tour.js +12 -0
- package/dist/testing/tour.js.map +1 -0
- package/dist/tour/tour-engine.d.ts +12 -0
- package/dist/tour/tour-engine.js +31 -0
- package/dist/tour/tour-engine.js.map +1 -0
- package/dist/tour/tour.d.ts +14 -3
- package/dist/tour/tour.js +60 -45
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-url-sync.d.ts +5 -3
- package/dist/tour/use-tour-url-sync.js +21 -1
- package/dist/tour/use-tour-url-sync.js.map +1 -1
- package/package.json +1 -1
- package/src/components/autocomplete.tsx +18 -0
- package/src/components/combobox.tsx +5 -1
- package/src/components/inline-entity-combobox.tsx +17 -0
- package/src/lib/quiet-focus.ts +32 -0
- package/src/testing/index.ts +8 -0
- package/src/testing/tour.ts +41 -0
- package/src/tour/tour-engine.ts +41 -0
- package/src/tour/tour.tsx +64 -24
- 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":";AAAA,SAAS,eAAe,YAAY,WAAW,iBAAiB,QAAQ,gBAA8B;AACtG,YAAY,iBAAiB;AAC7B,SAAS,aAAa,mBAAmC;AACzD,SAAS,4BAA4B;AACrC,SAAS,uBAAwD;AACjE,SAAS,kBAAkB;AAC3B,SAAS,mBAAmB;AAgD5B,SAAS,mBAAmB,SAAgC,YAA6B,UAAiB;AACxG,QAAM,QAAQ,YAAY,QAAQ;AAClC,MAAI,UAAU,MAAM,YAAY,SAAS,YAAY,OAAQ,QAAO;AACpE,QAAM,OAAO,OAAO,YAAa,SAAqE;AACtG,MAAI,CAAC,KAAM,QAAO;AAClB,SACE,KAAK,aAAa,SAAS,aAAa,KAAK,UAAU,QAAQ,SAAS,WAAW,KAAK,QAAQ,QAAQ,SAAS;AAErH;AAGA,MAAM,gBAAgB,cAA+B,IAAI;AAKzD,MAAM,iBAAiB,cAA+B,IAAI;AAC1D,MAAM,qBAA+C,MAAM;AACzD,MAAI;AACF,WACG,YAAmF,4BACpF;AAAA,EAEJ,QAAQ;AACN,WAAO;AAAA,EACT;AACF,GAAG;AAGH,MAAM,qBAA+C,MAAM;AACzD,MAAI;AACF,WACG,YAAmF,4BACpF;AAAA,EAEJ,QAAQ;AACN,WAAO;AAAA,EACT;AACF,GAAG;AAGH,SAAS,OAAO,QAAyB,SAAoC;AAC3E,QAAM,OAAO,OAAO,OAAO,IAAI,UAAU,CAAC;AAC1C,MAAI,OAAO,IAAI,UAAU,EAAG,QAAO,OAAO,SAAS,IAAI,KAAK,OAAO,IAAI,OAAO,IAAI;AAClF,aAAW,SAAS,SAAS;AAC3B,UAAM,QAAQ,OAAO,OAAO,IAAI,KAAK,CAAC;AACtC,QAAI,OAAO,IAAI,KAAK,EAAG,QAAO,OAAO,SAAS,KAAK,KAAK,QAAQ,IAAI,KAAK,MAAM,KAAK,IAAI;AAAA,EAC1F;AACA,SAAO;AACT;AAgBO,SAAS,eAAe,EAAE,QAAQ,QAAQ,MAAM,cAAc,CAAC,EAAE,GAAgC;AACtG,QAAM,WAAW,YAAY;AAC7B,QAAM,WAAW,YAAY;AAC7B,QAAM,EAAE,UAAU,QAAQ,KAAK,IAAI;AACnC,QAAM,UAAU,WAAW,iBAAiB,GAAG;AAC/C,QAAM,aAAa,WAAW,iBAAiB;AAC/C,QAAM,SAAS,gBAAgB;AAC/B,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,WAAW,QAAQ,YAAY;AACrC,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,UAAU,IAAI,gBAAgB,MAAM,EAAE,IAAI,MAAM;AACtD,QAAM,UAAU,YAAY,KAAK,IAAI;AAErC,QAAM,YAAY,OAAO,MAAM;AAC/B,QAAM,WAAW,OAAkB,QAAQ,SAAS,eAAe;AACnE,QAAM,CAAC,IAAI,IAAI,SAAS,MAAM,WAAuB,aAAa,kBAAkB,CAAC;AACrF,QAAM,cAAc,OAAO,QAAQ;AACnC,kBAAgB,MAAM;AACpB,cAAU,UAAU;AACpB,aAAS,UAAU,QAAQ,SAAS;AACpC,SAAK,IAAI,UAAU,kBAAkB;AACrC,gBAAY,UAAU,SAAS,WAAW;AAAA,EAC5C,CAAC;AAED,QAAM,UAAU,OAAsB,IAAI;AAE1C,QAAM,aAAa,OAAkD,IAAI;AAEzE,QAAM,eAAe,OAAO,KAAK;AAEjC,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,CAAC;AAExC,YAAU,MAAM;AACd,QAAI,YAAY,QAAQ,SAAS;AAC/B,cAAQ,UAAU;AAElB,iBAAW,UACT,WAAW,YAAY,YAAY,UAC/B,EAAE,IAAI,SAAS,YAAY,OAAO,IAAI,gBAAgB,MAAM,GAAG,UAAU,QAAQ,MAAM,IAAI,IAAI,CAAC,CAAC,EAAE,IACnG;AAAA,IACR;AACA,UAAM,UAAU,WAAW;AAC3B,QAAI,CAAC,WAAW,CAAC,MAAO;AACxB,eAAW,UAAU;AACrB,iBAAa,UAAU;AACvB,SAAK,QAAQ,QAAQ,EAClB,KAAK,MAAM,UAAU,QAAQ,QAAQ,IAAI,EAAE,YAAY,QAAQ,WAAW,GAAG,SAAS,SAAS,KAAK,MAAM,CAAC,EAC3G,MAAM,MAAM,MAAS,EACrB,QAAQ,MAAM;AACb,mBAAa,UAAU;AACvB,iBAAW,CAAC,MAAM,IAAI,CAAC;AAAA,IACzB,CAAC;AAAA,EAGL,GAAG,CAAC,SAAS,KAAK,CAAC;AAEnB,YAAU,MAAM;AACd,QAAI,mBAAmB,SAAS,YAAY,EAAE,UAAU,QAAQ,KAAK,CAAC,EAAG;AAEzE,QAAI,WAAW,WAAW,aAAa,QAAS;AAChD,UAAM,SAAS,UAAU,WAAW,WAAW;AAC/C,UAAM,MAAM,CAAC,QAAQ,YAAY,GAAI,UAAU,QAAQ,MAAM,IAAI,IAAI,CAAC,CAAE;AACxE,UAAM,UAAU,IAAI,gBAAgB,MAAM;AAG1C,QAAI,CAAC,UAAU,CAAC,IAAI,KAAK,CAAC,QAAQ,QAAQ,IAAI,GAAG,CAAC,EAAG;AACrD,UAAM,OAAO,IAAI,gBAAgB,MAAM;AAGvC,eAAW,OAAO,SAAS,IAAI,MAAM,CAAC,IAAI,IAAK,MAAK,OAAO,GAAG;AAC9D,QAAI,QAAQ;AACV,WAAK,IAAI,QAAQ,MAAM;AACvB,WAAK,IAAI,YAAY,OAAO,QAAQ,CAAC,CAAC;AAAA,IACxC;AACA,QAAI,KAAK,SAAS,MAAM,QAAQ,SAAS,EAAG;AAE5C,SAAK,SAAS,EAAE,UAAU,QAAQ,KAAK,SAAS,GAAG,KAAK,GAAG,EAAE,SAAS,MAAM,OAAO,SAAS,MAAM,CAAC;AAAA,EAErG,GAAG,CAAC,QAAQ,UAAU,OAAO,UAAU,QAAQ,MAAM,SAAS,OAAO,CAAC;AACxE;AAGA,MAAM,kBAA6B,MAAM;AACvC,MAAI,YAAY,EAAG,SAAQ,KAAK,8EAA8E;AAChH;AAEA,MAAM,iBAAiB,MAAM;AAC3B,MAAI,YAAY,EAAG,SAAQ,KAAK,+EAA+E;AACjH;AAEA,MAAM,qBAAiC;AAAA,EACrC,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,OAAO;AAAA,EACP,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,UAAU;AAAA,EACV,OAAO;AAAA,EACP,OAAO;AAAA,EACP,UAAU;AAAA,EACV,OAAO;AACT;AACA,MAAM,cAAc,OAAO,KAAK,kBAAkB;AAclD,MAAM,SAAqC,CAAC,OAAO,QAAQ,MAAM;AAQ1D,SAAS,yBACd,SAA6D,CAAC,GACqB;AACnF,QAAM,CAAC,QAAQ,SAAS,IAAI,qBAA0C;AAAA,IACpE,OAAO,EAAE,OAAO,OAAO,SAAS,KAAK,SAAS,IAAI,SAAS,UAAU;AAAA,IACrE,OAAO,EAAE,OAAO,OAAO,SAAS,SAAS,SAAS,MAAM,SAAS,WAAW,OAAO,CAAC,QAAQ,OAAO,OAAU;AAAA,IAC7G,OAAO;AAAA,MACL,OAAO,OAAO,SAAS;AAAA,MACvB,SAAS;AAAA,MACT,SAAS;AAAA,MACT,OAAO,CAAC,QAAQ,OAAO,KAAK,CAAC,MAAM,MAAM,GAAG;AAAA,IAC9C;AAAA,EACF,CAAC;AAED,SAAO,EAAE,GAAG,QAAQ,UAAU,UAAU;AAC1C;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eifi1/ui-kit",
|
|
3
|
-
"version": "0.50.
|
|
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
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -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. `
|
|
197
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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: (
|
|
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. */
|