@marianmeres/stuic 3.69.0 → 3.70.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/API.md +5 -1
- package/dist/actions/onboarding/onboarding.svelte.d.ts +2 -0
- package/dist/actions/onboarding/onboarding.svelte.js +22 -2
- package/dist/actions/spotlight/spotlight.svelte.d.ts +35 -0
- package/dist/actions/spotlight/spotlight.svelte.js +67 -1
- package/dist/components/TabbedMenu/TabbedMenu.svelte +27 -3
- package/dist/components/TabbedMenu/TabbedMenu.svelte.d.ts +4 -1
- package/package.json +1 -1
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, {
|
|
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);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
import type { HTMLAttributes } from "svelte/elements";
|
|
3
3
|
import type { THC } from "../Thc/index.js";
|
|
4
|
+
import { tr, type MaybeLocalized } from "../../utils/tr.js";
|
|
4
5
|
import { twMerge } from "../../utils/tw-merge.js";
|
|
5
6
|
import Thc from "../Thc/Thc.svelte";
|
|
6
7
|
|
|
7
8
|
export interface TabbedMenuItem {
|
|
8
9
|
id: string | number;
|
|
9
|
-
label: THC;
|
|
10
|
+
label: THC | MaybeLocalized;
|
|
10
11
|
disabled?: boolean;
|
|
11
12
|
class?: string;
|
|
12
13
|
data?: Record<string, any>;
|
|
@@ -20,6 +21,8 @@
|
|
|
20
21
|
disabled?: boolean;
|
|
21
22
|
onSelect?: (item: TabbedMenuItem) => void;
|
|
22
23
|
orientation?: "horizontal" | "vertical";
|
|
24
|
+
/** Current locale for MaybeLocalized resolution */
|
|
25
|
+
locale?: string;
|
|
23
26
|
//
|
|
24
27
|
class?: string;
|
|
25
28
|
classItem?: string;
|
|
@@ -41,6 +44,7 @@
|
|
|
41
44
|
disabled,
|
|
42
45
|
onSelect,
|
|
43
46
|
orientation = "horizontal",
|
|
47
|
+
locale,
|
|
44
48
|
//
|
|
45
49
|
class: classProp,
|
|
46
50
|
classItem,
|
|
@@ -54,6 +58,26 @@
|
|
|
54
58
|
...rest
|
|
55
59
|
}: Props = $props();
|
|
56
60
|
|
|
61
|
+
// The `label` accepts both THC and MaybeLocalized. Since MaybeLocalized's
|
|
62
|
+
// `Record<string, string>` shape has no discriminator key that Thc recognizes
|
|
63
|
+
// (`text`/`html`/`component`/`snippet`), a locale-keyed object would otherwise
|
|
64
|
+
// fall through to Thc's string-cast fallback and render as `[object Object]`.
|
|
65
|
+
// So we detect that case here and resolve it via `tr()` before handing to Thc.
|
|
66
|
+
function resolveLabel(label: TabbedMenuItem["label"]): THC {
|
|
67
|
+
if (
|
|
68
|
+
label &&
|
|
69
|
+
typeof label === "object" &&
|
|
70
|
+
typeof label !== "function" &&
|
|
71
|
+
!("text" in label) &&
|
|
72
|
+
!("html" in label) &&
|
|
73
|
+
!("component" in label) &&
|
|
74
|
+
!("snippet" in label)
|
|
75
|
+
) {
|
|
76
|
+
return tr(label as MaybeLocalized, locale);
|
|
77
|
+
}
|
|
78
|
+
return label as THC;
|
|
79
|
+
}
|
|
80
|
+
|
|
57
81
|
let buttonEls = $state<Record<string | number, HTMLButtonElement | HTMLAnchorElement>>(
|
|
58
82
|
{}
|
|
59
83
|
);
|
|
@@ -136,11 +160,11 @@
|
|
|
136
160
|
>
|
|
137
161
|
{#if item.href}
|
|
138
162
|
<a href={item.href} {...props} bind:this={buttonEls[item.id]}>
|
|
139
|
-
<Thc thc={item.label} />
|
|
163
|
+
<Thc thc={resolveLabel(item.label)} />
|
|
140
164
|
</a>
|
|
141
165
|
{:else}
|
|
142
166
|
<button type="button" {...props} bind:this={buttonEls[item.id]}>
|
|
143
|
-
<Thc thc={item.label} />
|
|
167
|
+
<Thc thc={resolveLabel(item.label)} />
|
|
144
168
|
</button>
|
|
145
169
|
{/if}
|
|
146
170
|
</li>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { HTMLAttributes } from "svelte/elements";
|
|
2
2
|
import type { THC } from "../Thc/index.js";
|
|
3
|
+
import { type MaybeLocalized } from "../../utils/tr.js";
|
|
3
4
|
export interface TabbedMenuItem {
|
|
4
5
|
id: string | number;
|
|
5
|
-
label: THC;
|
|
6
|
+
label: THC | MaybeLocalized;
|
|
6
7
|
disabled?: boolean;
|
|
7
8
|
class?: string;
|
|
8
9
|
data?: Record<string, any>;
|
|
@@ -15,6 +16,8 @@ export interface Props extends Omit<HTMLAttributes<HTMLUListElement>, "children"
|
|
|
15
16
|
disabled?: boolean;
|
|
16
17
|
onSelect?: (item: TabbedMenuItem) => void;
|
|
17
18
|
orientation?: "horizontal" | "vertical";
|
|
19
|
+
/** Current locale for MaybeLocalized resolution */
|
|
20
|
+
locale?: string;
|
|
18
21
|
class?: string;
|
|
19
22
|
classItem?: string;
|
|
20
23
|
classButton?: string;
|