@urbicon-ui/blocks 6.7.0 → 6.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,14 +3,15 @@
3
3
  import type { GuideProviderProps } from './index';
4
4
  import { setGuideContext } from './guide.context';
5
5
 
6
- let { storage, controller, children }: GuideProviderProps = $props();
6
+ let { storage, navigate, controller, children }: GuideProviderProps = $props();
7
7
 
8
8
  // Use a consumer-supplied controller (for programmatic access from outside the provider)
9
9
  // or create one. Built once from the initial props — a stable controller/adapter is
10
10
  // expected, so later prop changes are intentionally ignored. SSR-safe: the default
11
- // adapter only touches localStorage inside load/save.
11
+ // adapter only touches localStorage inside load/save. `storage`/`navigate` are ignored
12
+ // when a `controller` is supplied (it already carries them).
12
13
  // svelte-ignore state_referenced_locally
13
- const instance = controller ?? new GuideController({ storage });
14
+ const instance = controller ?? new GuideController({ storage, navigate });
14
15
  setGuideContext(instance);
15
16
  </script>
16
17
 
@@ -30,10 +30,17 @@ import type { GuideArticleSlots, GuideBeaconSlots, GuideBeaconVariants, GuideHin
30
30
  export interface GuideProviderProps {
31
31
  /** Persistence adapter for "seen" state (tours/hints). @default localStorage-backed adapter */
32
32
  storage?: GuideStorageAdapter;
33
+ /**
34
+ * Navigation hook for declarative cross-route tours, forwarded to the internally created
35
+ * controller. A SvelteKit consumer wires `(route) => goto(route)`; a step's `route` then
36
+ * navigates before its spotlight. Ignored when a `controller` is supplied (set it there).
37
+ * @default undefined
38
+ */
39
+ navigate?: (route: string) => void | Promise<void>;
33
40
  /**
34
41
  * Supply a pre-created `GuideController` for programmatic access from outside the provider
35
42
  * (start tours, open the panel, query `hasSeen`). When omitted, the provider creates one
36
- * internally and shares it via context. When supplied, `storage` is ignored.
43
+ * internally and shares it via context. When supplied, `storage` and `navigate` are ignored.
37
44
  */
38
45
  controller?: GuideController;
39
46
  /** App subtree wired to the Guide context. */
@@ -15,6 +15,18 @@ export interface GuideTopicMeta {
15
15
  export interface GuideStep {
16
16
  /** `data-guide` id of the element to anchor to. Omit for a centered, target-less step. */
17
17
  target?: string;
18
+ /**
19
+ * Optional route this step lives on — declarative cross-route touring. When set and different
20
+ * from the current location, the controller calls its injected `navigate` hook to go there
21
+ * **before** the spotlight, then re-anchors once the target appears on the new page (via the
22
+ * surface's existing `reapplyStepHighlight`). Such a tour-internal navigation keeps the tour
23
+ * running; a *foreign* navigation (the user leaving on their own) still stops it. Compared
24
+ * against `window.location.pathname` by default, so use a normalized path (no query/hash);
25
+ * inject a {@link GuideControllerOptions.navigationSource} for a custom router or base path.
26
+ * A `route` step with no `navigate` hook wired logs a DEV warning and stays put (no crash).
27
+ * @see GuideControllerOptions.navigate
28
+ */
29
+ route?: string;
18
30
  /** Step heading. */
19
31
  title?: string;
20
32
  /** Step body text. */
@@ -103,12 +115,45 @@ export interface GuideOverlayStackLike {
103
115
  /** Number of modal overlays currently open — drives the non-modal hide (§3.4). */
104
116
  readonly depth: number;
105
117
  }
118
+ /**
119
+ * Source of the current route and navigation notifications the controller needs for cross-route
120
+ * tours (injectable for tests and custom routers). The default {@link createBrowserNavigationSource}
121
+ * reads `window.location.pathname` and subscribes via the Navigation API, falling back to `popstate`.
122
+ */
123
+ export interface GuideNavigationSource {
124
+ /** The current path, compared against a step's `route` (e.g. `"/expenses"`). `''` on the server. */
125
+ current(): string;
126
+ /**
127
+ * Subscribe to navigations; `onNavigate` receives the new path. Returns an unsubscribe. The
128
+ * controller subscribes only while a tour with at least one `route` step is active, so a tour
129
+ * that never declares a route observes no navigation (preserving the manual-`goto` recipe).
130
+ */
131
+ subscribe(onNavigate: (path: string) => void): () => void;
132
+ }
106
133
  /** Options for {@link GuideController}; every dependency is injectable for testing. */
107
134
  export interface GuideControllerOptions {
108
135
  /** Persistence adapter. @default localStorage-backed adapter */
109
136
  storage?: GuideStorageAdapter;
110
137
  /** Overlay stack to integrate with. @default the shared `overlayStack` singleton */
111
138
  overlayStack?: GuideOverlayStackLike;
139
+ /**
140
+ * Navigation hook for declarative cross-route tours. When a step's `route` differs from the
141
+ * current location, the controller calls this to navigate there before spotlighting the step.
142
+ * Framework-agnostic by injection — a SvelteKit consumer wires `(route) => goto(route)`. May be
143
+ * sync or async; a thrown/rejected navigation is swallowed (DEV-warned). A `route` step with no
144
+ * hook wired logs a DEV warning and stays on the current route (no crash). @default undefined
145
+ */
146
+ navigate?: (route: string) => void | Promise<void>;
147
+ /**
148
+ * Source for the current path + navigation events, used to decide whether a step's `route`
149
+ * needs navigating and to tell a tour-internal navigation from a foreign one (which stops the
150
+ * tour). @default a browser source reading `window.location.pathname` and listening via the
151
+ * Navigation API. Where that API is unavailable it falls back to `popstate` (back/forward only,
152
+ * not `pushState`), so a foreign *forward* navigation may go unobserved — inject a router-backed
153
+ * source (SvelteKit: `afterNavigate` + the `page` store; see docs/GUIDE.md §9) for reliable
154
+ * detection, or to handle a configured base path / custom router.
155
+ */
156
+ navigationSource?: GuideNavigationSource;
112
157
  /** Force DEV-mode warnings on/off. @default `import.meta.env?.DEV ?? false` */
113
158
  dev?: boolean;
114
159
  }
@@ -117,6 +162,13 @@ export interface GuideControllerOptions {
117
162
  * resilient to private-mode / quota failures (mirrors `persistent-state`).
118
163
  */
119
164
  export declare function createLocalStorageAdapter(key?: string): GuideStorageAdapter;
165
+ /**
166
+ * The default {@link GuideNavigationSource}: reads `window.location.pathname` and observes SPA
167
+ * navigations through the Navigation API when available — catching link clicks, programmatic
168
+ * navigation (`goto`), and back/forward — falling back to `popstate` (back/forward only) where it
169
+ * is not. SSR-safe: `current()` returns `''` and `subscribe()` is a no-op without a `window`.
170
+ */
171
+ export declare function createBrowserNavigationSource(): GuideNavigationSource;
120
172
  /**
121
173
  * Headless engine for the Guide system — the UI-free state machine behind every
122
174
  * Guide surface (Panel, Marker, Mention, Hint, Tour).
@@ -42,6 +42,57 @@ export function createLocalStorageAdapter(key = SEEN_STORAGE_KEY) {
42
42
  }
43
43
  };
44
44
  }
45
+ /**
46
+ * The default {@link GuideNavigationSource}: reads `window.location.pathname` and observes SPA
47
+ * navigations through the Navigation API when available — catching link clicks, programmatic
48
+ * navigation (`goto`), and back/forward — falling back to `popstate` (back/forward only) where it
49
+ * is not. SSR-safe: `current()` returns `''` and `subscribe()` is a no-op without a `window`.
50
+ */
51
+ export function createBrowserNavigationSource() {
52
+ let warnedFallback = false;
53
+ return {
54
+ current() {
55
+ return BROWSER ? window.location.pathname : '';
56
+ },
57
+ subscribe(onNavigate) {
58
+ if (!BROWSER)
59
+ return () => { };
60
+ const nav = window.navigation;
61
+ if (nav && typeof nav.addEventListener === 'function') {
62
+ const handler = (event) => {
63
+ if (event.downloadRequest != null)
64
+ return; // a download is not a route change
65
+ const url = event.destination?.url;
66
+ if (typeof url !== 'string')
67
+ return;
68
+ let path;
69
+ try {
70
+ path = new URL(url).pathname;
71
+ }
72
+ catch {
73
+ return; // opaque/cross-origin destination — not a same-app route
74
+ }
75
+ onNavigate(path);
76
+ };
77
+ nav.addEventListener('navigate', handler);
78
+ return () => nav.removeEventListener('navigate', handler);
79
+ }
80
+ // `popstate` only catches back/forward, not `pushState` (a SvelteKit `goto` / intercepted
81
+ // link click), so a foreign *forward* navigation goes unobserved and the tour won't stop.
82
+ // Warn once so a degraded-detection browser is diagnosable; inject a router-backed
83
+ // navigationSource for reliable detection (docs/GUIDE.md §9).
84
+ if (import.meta.env?.DEV && !warnedFallback) {
85
+ warnedFallback = true;
86
+ console.warn('[Guide] the Navigation API is unavailable; cross-route foreign-navigation detection falls back to popstate and will miss pushState navigations (link clicks, goto). Inject a router-backed navigationSource for reliable detection — see docs/GUIDE.md §9.');
87
+ }
88
+ const onPop = () => onNavigate(window.location.pathname);
89
+ window.addEventListener('popstate', onPop);
90
+ return () => window.removeEventListener('popstate', onPop);
91
+ }
92
+ };
93
+ }
94
+ /** DEV-only: grace period for a navigated-to `route` step's target before warning it never showed. */
95
+ const ROUTE_TARGET_TIMEOUT_MS = 4000;
45
96
  let tourIdCounter = 0;
46
97
  /** Opaque id for overlay-stack registration. Never SSR-rendered, so no hydration risk. */
47
98
  function nextOverlayId() {
@@ -101,6 +152,17 @@ export class GuideController {
101
152
  // independently of #activeTour (today they always change in lockstep).
102
153
  #tourOverlayId = $state(null);
103
154
  #tourUnregister = null;
155
+ // ── Cross-route touring ─────────────────────────────────
156
+ #navigate;
157
+ #navSource;
158
+ /** The path a tour-internal navigation is heading to; distinguishes our own nav from a foreign one. */
159
+ #expectedRoute = null;
160
+ /** Last path the tour is known to be on — set at start, updated on each handled navigation. */
161
+ #knownPath = '';
162
+ /** Unsubscribe from the navigation source; non-null only while a route-using tour runs. */
163
+ #navUnsub = null;
164
+ /** DEV-only timer that warns when a navigated-to step's target never appears. */
165
+ #routeTargetTimer = null;
104
166
  // ── Highlight (shared by tour steps + Mention→UI, Direction B) ──
105
167
  #highlightedId = $state(null);
106
168
  // ── Panel state (UI in Phase 3) ─────────────────────────
@@ -112,6 +174,8 @@ export class GuideController {
112
174
  constructor(options = {}) {
113
175
  this.#storage = options.storage ?? createLocalStorageAdapter();
114
176
  this.#overlayStack = options.overlayStack ?? overlayStack;
177
+ this.#navigate = options.navigate;
178
+ this.#navSource = options.navigationSource ?? createBrowserNavigationSource();
115
179
  // `import.meta.env?.DEV` is `boolean | undefined` (undefined outside Vite);
116
180
  // `?? false` keeps `#dev` a strict boolean and means non-Vite consumers
117
181
  // simply get no dev warnings (rather than a crash).
@@ -240,8 +304,14 @@ export class GuideController {
240
304
  this.#stepIndex = 0;
241
305
  this.#tourOverlayId = nextOverlayId();
242
306
  this.#tourUnregister = this.#overlayStack.register(this.#tourOverlayId, () => this.skip());
243
- this.#applyStepHighlight();
244
- this.#emitStep('start');
307
+ // Observe navigation only for tours that declare a step route. This keeps the manual-`goto`
308
+ // recipe (consumer navigates in `onStep`, no `step.route`) untouched, where a navigation is
309
+ // the consumer's own and must NOT be read as a foreign one that stops the tour.
310
+ this.#knownPath = this.#navSource.current();
311
+ if (tour.steps.some((s) => typeof s.route === 'string')) {
312
+ this.#navUnsub = this.#navSource.subscribe((path) => this.#onLocationChange(path));
313
+ }
314
+ this.#activateStep('start');
245
315
  return true;
246
316
  }
247
317
  /** Advance to the next step, or finish the tour if on the last step. */
@@ -253,16 +323,14 @@ export class GuideController {
253
323
  return;
254
324
  }
255
325
  this.#stepIndex += 1;
256
- this.#applyStepHighlight();
257
- this.#emitStep('next');
326
+ this.#activateStep('next');
258
327
  }
259
328
  /** Go back one step. No-op on the first step. */
260
329
  prev() {
261
330
  if (!this.#activeTour || this.isFirstStep)
262
331
  return;
263
332
  this.#stepIndex -= 1;
264
- this.#applyStepHighlight();
265
- this.#emitStep('prev');
333
+ this.#activateStep('prev');
266
334
  }
267
335
  /** Dismiss the tour without completing it — marks it seen, fires `onSkip`. */
268
336
  skip() {
@@ -304,6 +372,132 @@ export class GuideController {
304
372
  if (this.#activeTour)
305
373
  this.#applyStepHighlight();
306
374
  }
375
+ // ─── Cross-route touring ──────────────────────────────────────────────────
376
+ /**
377
+ * Make the now-current step active: trigger its cross-route navigation (if any), apply the
378
+ * highlight, then fire `onStep`. Shared by `startTour`/`next`/`prev`, so every entry point
379
+ * navigates symmetrically — including `prev()` stepping back across a route boundary.
380
+ */
381
+ #activateStep(via) {
382
+ this.#clearRouteTargetWarning();
383
+ this.#maybeNavigate();
384
+ this.#applyStepHighlight();
385
+ this.#emitStep(via);
386
+ }
387
+ /**
388
+ * If the active step lives on a different route, navigate there via the injected hook and record
389
+ * it as the `expectedRoute`, so the resulting navigation is recognized as the tour's own rather
390
+ * than a foreign one. Already on the route → nothing to do. No hook wired → DEV warning, stay put.
391
+ */
392
+ #maybeNavigate() {
393
+ // Each step starts with a clean slate — no navigation is pending until we trigger one below.
394
+ // (A step that doesn't navigate must not inherit the previous step's `expectedRoute`.)
395
+ this.#expectedRoute = null;
396
+ const route = this.currentStep?.route;
397
+ if (route == null)
398
+ return;
399
+ if (route === this.#navSource.current())
400
+ return;
401
+ if (!this.#navigate) {
402
+ if (this.#dev) {
403
+ console.warn(`[Guide] tour "${this.#activeTour?.id}" step ${this.#stepIndex}: step has route "${route}" but no navigate hook was provided to the GuideController — staying on the current route.`);
404
+ }
405
+ return;
406
+ }
407
+ this.#expectedRoute = route;
408
+ this.#scheduleRouteTargetWarning();
409
+ try {
410
+ const result = this.#navigate(route);
411
+ if (result && typeof result.then === 'function') {
412
+ result.catch((err) => {
413
+ if (this.#dev)
414
+ console.warn('[Guide] navigate hook rejected:', err);
415
+ this.#abortPendingNavigation(route);
416
+ });
417
+ }
418
+ }
419
+ catch (err) {
420
+ if (this.#dev)
421
+ console.warn('[Guide] navigate hook threw:', err);
422
+ this.#abortPendingNavigation(route);
423
+ }
424
+ }
425
+ /**
426
+ * Roll back the bookkeeping for a navigation that failed (hook threw/rejected) — but only if it
427
+ * is still the pending one, so a newer step's navigation (the user clicked Next while this hook
428
+ * was in flight) is never clobbered by a late rejection. Clears the now-meaningless "target never
429
+ * appeared" timer too, so it can't later fire a misleading warning for a navigation that never ran.
430
+ */
431
+ #abortPendingNavigation(route) {
432
+ if (this.#expectedRoute !== route)
433
+ return;
434
+ this.#expectedRoute = null;
435
+ this.#clearRouteTargetWarning();
436
+ }
437
+ /**
438
+ * React to a navigation observed while a route-using tour runs. A navigation matching the pending
439
+ * `expectedRoute` is the tour's own → clear the flag and keep running (the ring lands via the
440
+ * surface's `reapplyStepHighlight`). Any other navigation is foreign — the user left, or a
441
+ * youngest-gesture-wins race — and tears the tour down, analytics-silent (`stopTour`).
442
+ */
443
+ #onLocationChange(path) {
444
+ if (!this.#activeTour)
445
+ return;
446
+ if (path === this.#knownPath)
447
+ return; // no pathname change (e.g. a hash/query update) — ignore
448
+ this.#knownPath = path;
449
+ if (this.#expectedRoute !== null && path === this.#expectedRoute) {
450
+ // The tour's own navigation landed — keep running. For a *targeted* step, keep `#expectedRoute`
451
+ // set until the target settles (`#applyStepHighlight`) so a redirecting / multi-hop source (the
452
+ // Navigation API emitting one event per hop) firing a *second* event for the redirect target is
453
+ // still recognized as a mismatch (and DEV-warned) instead of silently stopping. A *targetless*
454
+ // route step has no target to wait for — it is settled on landing, so clear now; otherwise a
455
+ // later foreign navigation would misfire the "navigated toward …" warning. (`#maybeNavigate`
456
+ // also resets it on the next step. Clearing here can't move into `#applyStepHighlight`, which
457
+ // runs synchronously in `#activateStep` before the nav lands — it would mis-read our own nav.)
458
+ if (this.currentStep?.target == null)
459
+ this.#expectedRoute = null;
460
+ return;
461
+ }
462
+ // Any other navigation stops the tour (analytics-silent). When one was pending, a landed path
463
+ // that doesn't match is most often a normalized `step.route` (trailing slash, base/locale
464
+ // prefix) rather than a genuine foreign nav — so surface it instead of a silent teardown on a
465
+ // one-character mismatch. (The strict stop is kept either way: better than running on a wrong page.)
466
+ if (this.#dev && this.#expectedRoute !== null) {
467
+ console.warn(`[Guide] tour "${this.#activeTour?.id}" navigated toward "${this.#expectedRoute}" but landed on "${path}" — stopping as a foreign navigation. If "${path}" is just a normalized form of the step's route (trailing slash, base/locale prefix), set step.route to the router's actual path.`);
468
+ }
469
+ this.#expectedRoute = null;
470
+ this.stopTour();
471
+ }
472
+ /**
473
+ * DEV-only: after navigating for a `route` step, warn if its target never appears so the spotlight
474
+ * can't land (the step still renders, centered over the scrim — it never hangs). Browser-gated, so
475
+ * no timer is ever scheduled under SSR or in node tests.
476
+ */
477
+ #scheduleRouteTargetWarning() {
478
+ if (!this.#dev || !BROWSER)
479
+ return;
480
+ const step = this.currentStep;
481
+ if (!step?.target)
482
+ return;
483
+ this.#clearRouteTargetWarning();
484
+ const index = this.#stepIndex;
485
+ const targetId = step.target;
486
+ const route = step.route;
487
+ this.#routeTargetTimer = setTimeout(() => {
488
+ this.#routeTargetTimer = null;
489
+ if (this.#stepIndex === index && !this.resolveTarget(targetId)) {
490
+ console.warn(`[Guide] tour "${this.#activeTour?.id}" step ${index}: navigated to "${route}" but target "${targetId}" never appeared — showing the step without a spotlight ring.`);
491
+ }
492
+ }, ROUTE_TARGET_TIMEOUT_MS);
493
+ }
494
+ /** Cancel a pending route-target warning (target appeared, the step changed, or teardown). */
495
+ #clearRouteTargetWarning() {
496
+ if (this.#routeTargetTimer !== null) {
497
+ clearTimeout(this.#routeTargetTimer);
498
+ this.#routeTargetTimer = null;
499
+ }
500
+ }
307
501
  #endTour() {
308
502
  const tour = this.#activeTour;
309
503
  this.#teardownTour();
@@ -313,8 +507,12 @@ export class GuideController {
313
507
  if (tour && tour.once !== false)
314
508
  this.markSeen(tour.id);
315
509
  }
316
- /** Reset all tour state and release the overlay-stack registration. No persistence. */
510
+ /** Reset all tour state and release the overlay-stack + navigation registrations. No persistence. */
317
511
  #teardownTour() {
512
+ this.#clearRouteTargetWarning();
513
+ this.#navUnsub?.();
514
+ this.#navUnsub = null;
515
+ this.#expectedRoute = null;
318
516
  this.clearHighlight();
319
517
  this.#tourUnregister?.();
320
518
  this.#tourUnregister = null;
@@ -364,10 +562,16 @@ export class GuideController {
364
562
  // Unlike Direction-B hover (which never scrolls), a tour drives the viewport —
365
563
  // the scroll is reduced-motion-aware inside `highlight`.
366
564
  this.highlight(step.target, { scroll: true });
565
+ this.#clearRouteTargetWarning(); // target landed → no "never appeared" warning needed
566
+ this.#expectedRoute = null; // the navigated-to target resolved → navigation fully settled
367
567
  }
368
568
  else {
369
569
  this.clearHighlight();
370
- if (this.#dev) {
570
+ // A step bound to another route legitimately has no target on the current page — during the
571
+ // navigation gap, or when no navigate hook is wired — so don't warn "not found" there; the
572
+ // missing-hook and never-appeared paths own those diagnostics instead.
573
+ const offRoute = step.route != null && step.route !== this.#navSource.current();
574
+ if (this.#dev && !offRoute) {
371
575
  console.warn(`[Guide] tour "${this.#activeTour?.id}" step ${this.#stepIndex}: target "${step.target}" not found in DOM.`);
372
576
  }
373
577
  }
@@ -2,7 +2,7 @@ export * from './date.js';
2
2
  export * from './draggable.js';
3
3
  export * from './figma-token-export.js';
4
4
  export { arrow, autoUpdate, type ComputePositionReturn, computePosition, flip, type Middleware, offset, type Placement, type Strategy, shift, size } from './floating.js';
5
- export { createLocalStorageAdapter, GuideController, type GuideControllerOptions, type GuideDirection, type GuideEndEvent, type GuideOverlayStackLike, type GuideStep, type GuideStepEvent, type GuideStorageAdapter, type GuideTopicMeta, type GuideTour } from './guide.svelte.js';
5
+ export { createBrowserNavigationSource, createLocalStorageAdapter, GuideController, type GuideControllerOptions, type GuideDirection, type GuideEndEvent, type GuideNavigationSource, type GuideOverlayStackLike, type GuideStep, type GuideStepEvent, type GuideStorageAdapter, type GuideTopicMeta, type GuideTour } from './guide.svelte.js';
6
6
  export * from './id.js';
7
7
  export { observeTargetResolution } from './observe-target.js';
8
8
  export { createOptionalContext } from './optional-context.js';
@@ -2,7 +2,7 @@ export * from './date.js';
2
2
  export * from './draggable.js';
3
3
  export * from './figma-token-export.js';
4
4
  export { arrow, autoUpdate, computePosition, flip, offset, shift, size } from './floating.js';
5
- export { createLocalStorageAdapter, GuideController } from './guide.svelte.js';
5
+ export { createBrowserNavigationSource, createLocalStorageAdapter, GuideController } from './guide.svelte.js';
6
6
  export * from './id.js';
7
7
  export { observeTargetResolution } from './observe-target.js';
8
8
  export { createOptionalContext } from './optional-context.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.7.0",
3
+ "version": "6.8.0",
4
4
  "description": "Svelte 5 UI component library with Tailwind CSS 4, OKLCH design tokens and zero runtime dependencies",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -91,8 +91,8 @@
91
91
  "@sveltejs/package": "^2.5.8",
92
92
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
93
93
  "@tailwindcss/vite": "^4.3.1",
94
- "@urbicon-ui/i18n": "6.7.0",
95
- "@urbicon-ui/shared-types": "6.7.0",
94
+ "@urbicon-ui/i18n": "6.8.0",
95
+ "@urbicon-ui/shared-types": "6.8.0",
96
96
  "prettier": "^3.8.4",
97
97
  "prettier-plugin-svelte": "^4.1.1",
98
98
  "prettier-plugin-tailwindcss": "^0.8.0",