@marianmeres/stuic 3.69.0 → 3.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/API.md CHANGED
@@ -1398,6 +1398,7 @@ Spotlight/coach mark overlay that highlights a target element by dimming everyth
1398
1398
  | `scrollIntoView` | `boolean` | `true` | Scroll target into view before showing |
1399
1399
  | `open` | `boolean` | `undefined` | Reactive programmatic control |
1400
1400
  | `id` | `string` | `undefined` | ID for registry-based control |
1401
+ | `autoTrack` | `boolean` | `true` | Per-frame rAF compare-loop that keeps the spotlight glued to its target through layout shifts caused by sibling/ancestor movement (not just resize/scroll). Set to `false` to opt out. |
1401
1402
  | `onShow` | `() => void` | `undefined` | Callback when spotlight opens |
1402
1403
  | `onHide` | `() => void` | `undefined` | Callback when spotlight hides |
1403
1404
 
@@ -1405,6 +1406,7 @@ Spotlight/coach mark overlay that highlights a target element by dimming everyth
1405
1406
 
1406
1407
  - `showSpotlight(id)` — Show a spotlight by ID
1407
1408
  - `hideSpotlight(id)` — Hide a spotlight by ID
1409
+ - `repositionSpotlight(id)` — Force re-measure and reposition the spotlight (use after layout shifts `autoTrack` can't observe, or when `autoTrack: false`)
1408
1410
  - `isSpotlightOpen(id)` — Check if a spotlight is open
1409
1411
 
1410
1412
  ```svelte
@@ -1468,7 +1470,9 @@ Multi-step onboarding tour built on the spotlight primitive. Define steps centra
1468
1470
  | `onSkip` | `() => void` | — | Called when tour is skipped |
1469
1471
  | `onStepChange` | `(step, index) => void` | — | Called on every step change |
1470
1472
 
1471
- **Returns:** `{ start(), stop(), next(), prev(), skip(), reset(), active, currentStep, currentIndex }`
1473
+ **Returns:** `{ start(), stop(), next(), prev(), skip(), reset(), reposition(), active, currentStep, currentIndex }`
1474
+
1475
+ `reposition()` forces the active step's spotlight to re-measure its target and re-apply the cutout/anchor. Useful after a layout shift the spotlight's auto-tracking can't observe (or when a step opted out of it).
1472
1476
 
1473
1477
  **`TourStepDef`:**
1474
1478
 
@@ -151,11 +151,13 @@ export declare function createTour(options: TourOptions): {
151
151
  prev: () => void;
152
152
  skip: () => Promise<void>;
153
153
  reset: () => void;
154
+ reposition: () => void;
154
155
  _register: (id: string, el: HTMLElement) => void;
155
156
  _unregister: (id: string) => void;
156
157
  _registerAction: (id: string) => void;
157
158
  _isCurrentStep: (id: string) => boolean;
158
159
  _getShellContent: (id: string) => THC;
160
+ _getSpotlightId: (stepId: string) => string;
159
161
  };
160
162
  export type TourInstance = ReturnType<typeof createTour>;
161
163
  /**
@@ -1,4 +1,4 @@
1
- import { spotlight } from "../spotlight/spotlight.svelte.js";
1
+ import { spotlight, repositionSpotlight } from "../spotlight/spotlight.svelte.js";
2
2
  import OnboardingShell, {} from "./OnboardingShell.svelte";
3
3
  import { StorageAbstraction } from "../../utils/storage-abstraction.js";
4
4
  /**
@@ -30,6 +30,10 @@ export function createTour(options) {
30
30
  let currentIndex = $state(-1);
31
31
  const active = $derived(currentIndex >= 0);
32
32
  const currentStep = $derived(options.steps[currentIndex] ?? null);
33
+ // Unique prefix so each tour instance's spotlights are individually
34
+ // addressable via the spotlight registry (e.g. for `tour.reposition()`).
35
+ const tourId = Math.random().toString(36).slice(2);
36
+ const _getSpotlightId = (stepId) => `__stuic-tour-${tourId}-${stepId}`;
33
37
  // Optional persistence store
34
38
  const store = options.storageKey
35
39
  ? new StorageAbstraction(options.storage ?? "local")
@@ -79,6 +83,7 @@ export function createTour(options) {
79
83
  // spotlight() creates inner $effects; Svelte cleans them up
80
84
  // when this outer effect re-runs (step change) or is destroyed (tour end)
81
85
  spotlight(el, () => ({
86
+ id: _getSpotlightId(step.id),
82
87
  open: true,
83
88
  content: _getShellContent(step.id),
84
89
  position: step.position ?? "bottom",
@@ -246,6 +251,17 @@ export function createTour(options) {
246
251
  function reset() {
247
252
  store?.remove(options.storageKey);
248
253
  }
254
+ /**
255
+ * Force the current step's spotlight to re-measure its target and
256
+ * reposition. Useful after layout shifts that the spotlight's own
257
+ * auto-tracking can't observe, or when auto-tracking is disabled.
258
+ */
259
+ function reposition() {
260
+ const step = currentStep;
261
+ if (!step)
262
+ return;
263
+ repositionSpotlight(_getSpotlightId(step.id));
264
+ }
249
265
  return {
250
266
  get active() {
251
267
  return active;
@@ -264,12 +280,14 @@ export function createTour(options) {
264
280
  prev,
265
281
  skip,
266
282
  reset,
283
+ reposition,
267
284
  // Internal — used by tourStep action
268
285
  _register,
269
286
  _unregister,
270
287
  _registerAction,
271
288
  _isCurrentStep,
272
289
  _getShellContent,
290
+ _getSpotlightId,
273
291
  };
274
292
  }
275
293
  /**
@@ -289,12 +307,14 @@ export function tourStep(el, args) {
289
307
  return;
290
308
  tour._registerAction(id);
291
309
  tour._register(id, el);
310
+ const spotlightId = tour._getSpotlightId(id);
292
311
  spotlight(el, () => {
293
312
  const isActive = tour._isCurrentStep(id);
294
313
  if (!isActive) {
295
- return { open: false };
314
+ return { id: spotlightId, open: false };
296
315
  }
297
316
  return {
317
+ id: spotlightId,
298
318
  open: true,
299
319
  content: tour._getShellContent(id),
300
320
  position: tour.currentStep?.position ?? "bottom",
@@ -1,4 +1,12 @@
1
1
  import type { THC } from "../../components/Thc/Thc.svelte";
2
+ /**
3
+ * Imperative handle for a registered spotlight.
4
+ */
5
+ export interface SpotlightControl {
6
+ show: () => void;
7
+ hide: () => void;
8
+ reposition: () => void;
9
+ }
2
10
  /**
3
11
  * Show a spotlight by its registered ID.
4
12
  *
@@ -16,6 +24,20 @@ export declare function showSpotlight(id: string): void;
16
24
  * @param id - The spotlight ID to hide
17
25
  */
18
26
  export declare function hideSpotlight(id: string): void;
27
+ /**
28
+ * Force a registered spotlight to re-measure its target and reposition the
29
+ * clip-path hole, anchor, and annotation. Useful after layout shifts that
30
+ * auto-tracking cannot observe (or when `autoTrack` is disabled).
31
+ *
32
+ * @param id - The spotlight ID to reposition
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * // after toggling a collapsible sibling
37
+ * requestAnimationFrame(() => repositionSpotlight('onboarding-step-1'));
38
+ * ```
39
+ */
40
+ export declare function repositionSpotlight(id: string): void;
19
41
  /**
20
42
  * Check if a spotlight is currently open by its registered ID.
21
43
  *
@@ -61,6 +83,19 @@ export interface SpotlightOptions {
61
83
  open?: boolean;
62
84
  /** Unique ID for registry-based programmatic control */
63
85
  id?: string;
86
+ /**
87
+ * Keep the spotlight glued to its target by running a per-frame
88
+ * `requestAnimationFrame` compare-loop while visible. Catches layout
89
+ * shifts that `ResizeObserver` + window resize/scroll miss — e.g. a
90
+ * sibling collapsing in a flex/grid row and pushing the target sideways.
91
+ *
92
+ * The loop reads `getBoundingClientRect()` once per frame and only calls
93
+ * the internal reposition when the rect actually changed, so cost while
94
+ * idle is negligible. Set to `false` to keep the old behavior.
95
+ *
96
+ * Default: `true`.
97
+ */
98
+ autoTrack?: boolean;
64
99
  /** Debug mode */
65
100
  debug?: boolean;
66
101
  }
@@ -30,6 +30,22 @@ export function showSpotlight(id) {
30
30
  export function hideSpotlight(id) {
31
31
  spotlightRegistry.get(id)?.hide();
32
32
  }
33
+ /**
34
+ * Force a registered spotlight to re-measure its target and reposition the
35
+ * clip-path hole, anchor, and annotation. Useful after layout shifts that
36
+ * auto-tracking cannot observe (or when `autoTrack` is disabled).
37
+ *
38
+ * @param id - The spotlight ID to reposition
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * // after toggling a collapsible sibling
43
+ * requestAnimationFrame(() => repositionSpotlight('onboarding-step-1'));
44
+ * ```
45
+ */
46
+ export function repositionSpotlight(id) {
47
+ spotlightRegistry.get(id)?.reposition();
48
+ }
33
49
  /**
34
50
  * Check if a spotlight is currently open by its registered ID.
35
51
  *
@@ -171,6 +187,8 @@ export function spotlight(targetEl, fn) {
171
187
  let do_debug = false;
172
188
  let prevOpen = undefined;
173
189
  let resizeObserver = null;
190
+ let rafId = null;
191
+ let lastRect = null;
174
192
  // Unique identifiers
175
193
  const rnd = Math.random().toString(36).slice(2);
176
194
  const anchorName = `--anchor-spotlight-${rnd}`;
@@ -217,6 +235,42 @@ export function spotlight(targetEl, fn) {
217
235
  positionAnnotationFallback(rect, padding);
218
236
  }
219
237
  }
238
+ /**
239
+ * Per-frame loop that re-measures the target and reposition the hole/anchor
240
+ * if the rect diverges from the last-seen rect. This catches movement that
241
+ * `ResizeObserver` (size only) and window resize/scroll (viewport only) miss
242
+ * — e.g. a sibling element collapsing in a flex/grid row and pushing the
243
+ * target sideways.
244
+ */
245
+ function trackFrame() {
246
+ if (!isVisible) {
247
+ rafId = null;
248
+ return;
249
+ }
250
+ const r = targetEl.getBoundingClientRect();
251
+ if (!lastRect ||
252
+ r.left !== lastRect.left ||
253
+ r.top !== lastRect.top ||
254
+ r.width !== lastRect.width ||
255
+ r.height !== lastRect.height) {
256
+ lastRect = r;
257
+ updateHolePosition();
258
+ }
259
+ rafId = requestAnimationFrame(trackFrame);
260
+ }
261
+ function startAutoTrack() {
262
+ if (rafId != null)
263
+ return;
264
+ lastRect = targetEl.getBoundingClientRect();
265
+ rafId = requestAnimationFrame(trackFrame);
266
+ }
267
+ function stopAutoTrack() {
268
+ if (rafId != null) {
269
+ cancelAnimationFrame(rafId);
270
+ rafId = null;
271
+ }
272
+ lastRect = null;
273
+ }
220
274
  /**
221
275
  * Position annotation without CSS Anchor Positioning (fallback).
222
276
  */
@@ -375,6 +429,11 @@ export function spotlight(targetEl, fn) {
375
429
  resizeObserver.observe(targetEl);
376
430
  window.addEventListener("resize", updateHolePosition);
377
431
  window.addEventListener("scroll", updateHolePosition, true);
432
+ // 8. Per-frame compare-loop to catch layout shifts (sibling collapses,
433
+ // animated ancestors, etc.) that the observers above don't see.
434
+ if (currentOptions.autoTrack !== false) {
435
+ startAutoTrack();
436
+ }
378
437
  });
379
438
  }
380
439
  function hide() {
@@ -391,6 +450,7 @@ export function spotlight(targetEl, fn) {
391
450
  window.removeEventListener("scroll", updateHolePosition, true);
392
451
  resizeObserver?.disconnect();
393
452
  resizeObserver = null;
453
+ stopAutoTrack();
394
454
  // Unlock body scroll
395
455
  BodyScroll.unlock();
396
456
  // Transition out
@@ -428,6 +488,7 @@ export function spotlight(targetEl, fn) {
428
488
  closeOnEscape: opts.closeOnEscape ?? true,
429
489
  closeOnBackdropClick: opts.closeOnBackdropClick ?? true,
430
490
  scrollIntoView: opts.scrollIntoView ?? true,
491
+ autoTrack: opts.autoTrack ?? true,
431
492
  onShow: opts.onShow,
432
493
  onHide: opts.onHide,
433
494
  debug: opts.debug,
@@ -436,7 +497,11 @@ export function spotlight(targetEl, fn) {
436
497
  do_debug = !!opts.debug;
437
498
  // Register in global registry if id provided
438
499
  if (opts.id) {
439
- spotlightRegistry.set(opts.id, { show, hide });
500
+ spotlightRegistry.set(opts.id, {
501
+ show,
502
+ hide,
503
+ reposition: updateHolePosition,
504
+ });
440
505
  }
441
506
  // Update if visible
442
507
  if (isVisible) {
@@ -478,6 +543,7 @@ export function spotlight(targetEl, fn) {
478
543
  backdropEl?.remove();
479
544
  annotationEl?.remove();
480
545
  resizeObserver?.disconnect();
546
+ stopAutoTrack();
481
547
  document.removeEventListener("keydown", onEscape);
482
548
  window.removeEventListener("resize", updateHolePosition);
483
549
  window.removeEventListener("scroll", updateHolePosition, true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.69.0",
3
+ "version": "3.70.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",