@marianmeres/stuic 3.11.0 → 3.12.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/AGENTS.md CHANGED
@@ -24,7 +24,7 @@
24
24
  ```
25
25
  src/lib/
26
26
  ├── components/ # 39 UI components
27
- ├── actions/ # 12 Svelte actions
27
+ ├── actions/ # 13 Svelte actions
28
28
  ├── utils/ # 42 utility functions
29
29
  ├── themes/ # 26 theme definitions (.ts) + generated CSS (css/)
30
30
  ├── icons/ # Icon re-exports
@@ -74,7 +74,7 @@ src/lib/
74
74
  ### Domain Docs
75
75
  - [Components](./docs/domains/components.md) — 38 components, Props pattern, snippets
76
76
  - [Theming](./docs/domains/theming.md) — CSS tokens, dark mode, themes
77
- - [Actions](./docs/domains/actions.md) — 12 Svelte directives
77
+ - [Actions](./docs/domains/actions.md) — 13 Svelte directives
78
78
  - [Utils](./docs/domains/utils.md) — 42 utility functions
79
79
 
80
80
  ### Reference
package/API.md CHANGED
@@ -831,6 +831,66 @@ Anchored popover positioning.
831
831
  </button>
832
832
  ```
833
833
 
834
+ ### `spotlight`
835
+
836
+ Spotlight/coach mark overlay that highlights a target element by dimming everything else behind a backdrop with a cutout hole. Includes built-in annotation support positioned next to the target.
837
+
838
+ **Options:**
839
+
840
+ | Option | Type | Default | Description |
841
+ |--------|------|---------|-------------|
842
+ | `enabled` | `boolean` | `true` | Whether the spotlight is enabled |
843
+ | `content` | `THC \| null` | `undefined` | Annotation content (string, {html}, {component, props}, snippet) |
844
+ | `position` | `SpotlightPosition` | `"bottom"` | Annotation placement relative to target |
845
+ | `padding` | `number` | `8` | Padding around target in the cutout (px) |
846
+ | `borderRadius` | `number` | `8` | Border radius of the cutout hole (px) |
847
+ | `class` | `string` | `undefined` | Custom class for annotation |
848
+ | `classBackdrop` | `string` | `undefined` | Custom class for backdrop |
849
+ | `offset` | `string` | `"0.5rem"` | Annotation offset from target (CSS value) |
850
+ | `closeOnEscape` | `boolean` | `true` | Close on Escape key |
851
+ | `closeOnBackdropClick` | `boolean` | `true` | Close on backdrop click |
852
+ | `scrollIntoView` | `boolean` | `true` | Scroll target into view before showing |
853
+ | `open` | `boolean` | `undefined` | Reactive programmatic control |
854
+ | `id` | `string` | `undefined` | ID for registry-based control |
855
+ | `onShow` | `() => void` | `undefined` | Callback when spotlight opens |
856
+ | `onHide` | `() => void` | `undefined` | Callback when spotlight hides |
857
+
858
+ **Registry functions:**
859
+
860
+ - `showSpotlight(id)` — Show a spotlight by ID
861
+ - `hideSpotlight(id)` — Hide a spotlight by ID
862
+ - `isSpotlightOpen(id)` — Check if a spotlight is open
863
+
864
+ ```svelte
865
+ <script>
866
+ import { spotlight, showSpotlight } from "@marianmeres/stuic";
867
+ </script>
868
+
869
+ <!-- Attach to target, control via registry -->
870
+ <div
871
+ use:spotlight={() => ({
872
+ content: "Check out this feature!",
873
+ position: "bottom",
874
+ id: "intro",
875
+ })}
876
+ >
877
+ Target
878
+ </div>
879
+
880
+ <button onclick={() => showSpotlight("intro")}>Show</button>
881
+
882
+ <!-- Or control via reactive open prop -->
883
+ <div
884
+ use:spotlight={() => ({
885
+ content: { html: "<p>Step 1 of 3</p>" },
886
+ open: tourStep === 1,
887
+ onHide: () => { tourStep = 0; },
888
+ })}
889
+ >
890
+ Tour Target
891
+ </div>
892
+ ```
893
+
834
894
  ### `tooltip`
835
895
 
836
896
  Tooltip display from `aria-label`.
@@ -1298,6 +1358,7 @@ Each component defines customization tokens. Override globally in `:root {}` or
1298
1358
  | Tooltip | `--stuic-tooltip-*` | `bg`, `text` |
1299
1359
  | Popover | `--stuic-popover-*` | `bg`, `text`, `border` |
1300
1360
  | Skeleton | `--stuic-skeleton-*` | `bg`, `bg-highlight`, `duration` |
1361
+ | Spotlight | `--stuic-spotlight-*` | `backdrop-bg`, `annotation-bg`, `annotation-text`, `annotation-border` |
1301
1362
  | Cart | `--stuic-cart-*` | `gap`, `item-padding`, `item-radius`, `item-border-color`, `item-bg`, `thumbnail-size`, `quantity-border-color`, `remove-color`, `summary-border-color`, `compact-max-height`, `transition` |
1302
1363
  | Checkout | `--stuic-checkout-*` | `input-border`, `input-bg`, `input-focus-ring`, `input-radius`, `card-border`, `card-bg`, `card-radius`, `step-gap`, `progress-*`, `summary-*`, `guest-*`, `login-*`, `address-*`, `delivery-*`, `review-*`, `confirmation-*` |
1303
1364
 
@@ -6,6 +6,7 @@ export * from "./highlight-dragover.svelte.js";
6
6
  export * from "./on-submit-validity-check.svelte.js";
7
7
  export * from "./popover/popover.svelte.js";
8
8
  export * from "./resizable-width.svelte.js";
9
+ export * from "./spotlight/spotlight.svelte.js";
9
10
  export * from "./tooltip/tooltip.svelte.js";
10
11
  export * from "./trim.svelte.js";
11
12
  export * from "./typeahead.svelte.js";
@@ -6,6 +6,7 @@ export * from "./highlight-dragover.svelte.js";
6
6
  export * from "./on-submit-validity-check.svelte.js";
7
7
  export * from "./popover/popover.svelte.js";
8
8
  export * from "./resizable-width.svelte.js";
9
+ export * from "./spotlight/spotlight.svelte.js";
9
10
  export * from "./tooltip/tooltip.svelte.js";
10
11
  export * from "./trim.svelte.js";
11
12
  export * from "./typeahead.svelte.js";
@@ -0,0 +1,15 @@
1
+ <script lang="ts" module>
2
+ import type { THC } from "../../components/Thc/Thc.svelte";
3
+
4
+ export interface Props {
5
+ thc: THC;
6
+ }
7
+ </script>
8
+
9
+ <script lang="ts">
10
+ import Thc from "../../components/Thc/Thc.svelte";
11
+
12
+ let { thc }: Props = $props();
13
+ </script>
14
+
15
+ <Thc {thc} />
@@ -0,0 +1,7 @@
1
+ import type { THC } from "../../components/Thc/Thc.svelte";
2
+ export interface Props {
3
+ thc: THC;
4
+ }
5
+ declare const SpotlightContent: import("svelte").Component<Props, {}, "">;
6
+ type SpotlightContent = ReturnType<typeof SpotlightContent>;
7
+ export default SpotlightContent;
@@ -0,0 +1,55 @@
1
+ /* Spotlight action tokens */
2
+ /* Note: style props will not work as with regular components, because spotlight elements are created outside of the anchor DOM tree */
3
+ :root {
4
+ --stuic-spotlight-backdrop-bg: rgba(0, 0, 0, 0.5);
5
+ --stuic-spotlight-annotation-bg: var(--stuic-color-surface);
6
+ --stuic-spotlight-annotation-text: var(--stuic-color-foreground);
7
+ --stuic-spotlight-annotation-border: var(--stuic-color-border);
8
+ }
9
+
10
+ /* Backdrop overlay with clip-path hole */
11
+ .stuic-spotlight-backdrop {
12
+ opacity: 0;
13
+ transition-property: opacity;
14
+ &.spot-visible {
15
+ opacity: 1;
16
+ }
17
+ }
18
+
19
+ /* Annotation element (positioned via CSS Anchor Positioning) */
20
+ .stuic-spotlight-annotation {
21
+ position: fixed;
22
+ display: none;
23
+ opacity: 0;
24
+ transition-property: opacity;
25
+ }
26
+
27
+ @supports (anchor-name: --anchor) {
28
+ .stuic-spotlight-annotation {
29
+ position-try-order: most-width;
30
+ position-try-fallbacks:
31
+ flip-block, flip-inline, flip-block flip-inline;
32
+
33
+ &.spot-block {
34
+ display: block;
35
+ opacity: 0;
36
+ &.spot-visible {
37
+ opacity: 1;
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ /* Fallback annotation (no CSS Anchor Positioning) */
44
+ .stuic-spotlight-annotation-fallback {
45
+ display: none;
46
+ opacity: 0;
47
+ transition-property: opacity;
48
+ &.spot-block {
49
+ display: block;
50
+ opacity: 0;
51
+ &.spot-visible {
52
+ opacity: 1;
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,111 @@
1
+ import type { THC } from "../../components/Thc/Thc.svelte";
2
+ /**
3
+ * Show a spotlight by its registered ID.
4
+ *
5
+ * @param id - The spotlight ID to show
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * showSpotlight('onboarding-step-1');
10
+ * ```
11
+ */
12
+ export declare function showSpotlight(id: string): void;
13
+ /**
14
+ * Hide a spotlight by its registered ID.
15
+ *
16
+ * @param id - The spotlight ID to hide
17
+ */
18
+ export declare function hideSpotlight(id: string): void;
19
+ /**
20
+ * Check if a spotlight is currently open by its registered ID.
21
+ *
22
+ * @param id - The spotlight ID to check
23
+ * @returns true if the spotlight is open, false otherwise
24
+ */
25
+ export declare function isSpotlightOpen(id: string): boolean;
26
+ /**
27
+ * Valid positions for annotation placement relative to the spotlight target.
28
+ */
29
+ export type SpotlightPosition = "top" | "top-left" | "top-right" | "top-span-left" | "top-span-right" | "bottom" | "bottom-left" | "bottom-right" | "bottom-span-left" | "bottom-span-right" | "left" | "right";
30
+ /**
31
+ * Options for the spotlight action.
32
+ */
33
+ export interface SpotlightOptions {
34
+ /** Whether the spotlight is enabled */
35
+ enabled?: boolean;
36
+ /** Annotation content (THC format: string, {text}, {html}, {component, props}, {snippet}, or Snippet) */
37
+ content?: THC | null;
38
+ /** Preferred position of the annotation relative to the target */
39
+ position?: SpotlightPosition;
40
+ /** Padding around the target in the cutout (px) */
41
+ padding?: number;
42
+ /** Border radius of the cutout hole (px) */
43
+ borderRadius?: number;
44
+ /** Custom class for the annotation container */
45
+ class?: string;
46
+ /** Custom class for the backdrop overlay */
47
+ classBackdrop?: string;
48
+ /** Offset/margin of the annotation from the target (CSS value, e.g., "0.5rem") */
49
+ offset?: string;
50
+ /** Close on Escape key */
51
+ closeOnEscape?: boolean;
52
+ /** Close on click on the backdrop (outside the target hole) */
53
+ closeOnBackdropClick?: boolean;
54
+ /** Scroll target into view before showing */
55
+ scrollIntoView?: boolean;
56
+ /** Callback when spotlight opens */
57
+ onShow?: () => void;
58
+ /** Callback when spotlight hides */
59
+ onHide?: () => void;
60
+ /** Programmatically control open state (reactive) */
61
+ open?: boolean;
62
+ /** Unique ID for registry-based programmatic control */
63
+ id?: string;
64
+ /** Debug mode */
65
+ debug?: boolean;
66
+ }
67
+ /**
68
+ * A Svelte action that highlights a target element with a spotlight effect.
69
+ *
70
+ * Creates a dimmed backdrop overlay with a cutout hole around the target element,
71
+ * optionally showing annotation content positioned next to it. Useful for onboarding
72
+ * tutorials, feature tours, and drawing attention to specific UI elements.
73
+ *
74
+ * The cutout uses `clip-path` with an SVG path, so pointer events in the hole area
75
+ * pass through to the target element naturally.
76
+ *
77
+ * @param targetEl - The element to spotlight
78
+ * @param fn - Function returning spotlight options (reactive)
79
+ *
80
+ * @example
81
+ * ```svelte
82
+ * <!-- Basic usage with programmatic control -->
83
+ * <button
84
+ * use:spotlight={() => ({
85
+ * content: "Click here to get started!",
86
+ * position: "bottom",
87
+ * id: "intro-step-1",
88
+ * })}
89
+ * >
90
+ * Get Started
91
+ * </button>
92
+ *
93
+ * <button onclick={() => showSpotlight('intro-step-1')}>
94
+ * Start Tour
95
+ * </button>
96
+ * ```
97
+ *
98
+ * @example
99
+ * ```svelte
100
+ * <!-- Reactive open control -->
101
+ * <div use:spotlight={() => ({
102
+ * content: { component: TourStep, props: { step: 1 } },
103
+ * position: "right",
104
+ * open: showTour,
105
+ * onHide: () => showTour = false,
106
+ * })}>
107
+ * Target content
108
+ * </div>
109
+ * ```
110
+ */
111
+ export declare function spotlight(targetEl: HTMLElement, fn?: () => SpotlightOptions): void;
@@ -0,0 +1,491 @@
1
+ import { mount, unmount } from "svelte";
2
+ import { twMerge } from "../../utils/tw-merge.js";
3
+ import { addAnchorName, removeAnchorName } from "../../utils/anchor-name.js";
4
+ import { BodyScroll } from "../../utils/body-scroll-locker.js";
5
+ import SpotlightContent from "./SpotlightContent.svelte";
6
+ //
7
+ const TRANSITION = 200;
8
+ // Reactive state tracking which spotlights are open by ID
9
+ const spotlightOpenStates = $state({});
10
+ // Registry of spotlights by ID for programmatic control
11
+ const spotlightRegistry = new Map();
12
+ /**
13
+ * Show a spotlight by its registered ID.
14
+ *
15
+ * @param id - The spotlight ID to show
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * showSpotlight('onboarding-step-1');
20
+ * ```
21
+ */
22
+ export function showSpotlight(id) {
23
+ spotlightRegistry.get(id)?.show();
24
+ }
25
+ /**
26
+ * Hide a spotlight by its registered ID.
27
+ *
28
+ * @param id - The spotlight ID to hide
29
+ */
30
+ export function hideSpotlight(id) {
31
+ spotlightRegistry.get(id)?.hide();
32
+ }
33
+ /**
34
+ * Check if a spotlight is currently open by its registered ID.
35
+ *
36
+ * @param id - The spotlight ID to check
37
+ * @returns true if the spotlight is open, false otherwise
38
+ */
39
+ export function isSpotlightOpen(id) {
40
+ return spotlightOpenStates[id] ?? false;
41
+ }
42
+ /**
43
+ * Checks if the browser supports CSS Anchor Positioning for annotation placement.
44
+ */
45
+ function isAnchorPositioningSupported() {
46
+ return (CSS.supports("anchor-name", "--anchor") &&
47
+ CSS.supports("position-area", "top") &&
48
+ CSS.supports("position-try", "top") &&
49
+ CSS.supports("position-try-fallbacks", "top"));
50
+ }
51
+ const POSITION_MAP = {
52
+ top: "top",
53
+ "top-left": "top left",
54
+ "top-right": "top right",
55
+ "top-span-left": "top span-left",
56
+ "top-span-right": "top span-right",
57
+ bottom: "bottom",
58
+ "bottom-left": "bottom left",
59
+ "bottom-right": "bottom right",
60
+ "bottom-span-left": "bottom span-left",
61
+ "bottom-span-right": "bottom span-right",
62
+ left: "left",
63
+ right: "right",
64
+ };
65
+ const _classAnnotation = `
66
+ bg-(--stuic-spotlight-annotation-bg) text-(--stuic-spotlight-annotation-text)
67
+ shadow-lg rounded-md
68
+ border border-(--stuic-spotlight-annotation-border)
69
+ z-50
70
+ `;
71
+ /**
72
+ * Builds the clip-path value for the backdrop overlay with a rounded-rectangle hole.
73
+ */
74
+ function buildClipPath(rect, padding, borderRadius) {
75
+ const vw = window.innerWidth;
76
+ const vh = window.innerHeight;
77
+ const x = rect.left - padding;
78
+ const y = rect.top - padding;
79
+ const w = rect.width + padding * 2;
80
+ const h = rect.height + padding * 2;
81
+ const r = Math.min(borderRadius, w / 2, h / 2);
82
+ if (r <= 0) {
83
+ // Simple rectangular hole (no rounding)
84
+ return `polygon(evenodd, 0 0, ${vw}px 0, ${vw}px ${vh}px, 0 ${vh}px, 0 0, ${x}px ${y}px, ${x}px ${y + h}px, ${x + w}px ${y + h}px, ${x + w}px ${y}px, ${x}px ${y}px)`;
85
+ }
86
+ // Rounded rectangular hole using SVG path syntax
87
+ return `path(evenodd, "M 0 0 L ${vw} 0 L ${vw} ${vh} L 0 ${vh} Z M ${x + r} ${y} L ${x + w - r} ${y} A ${r} ${r} 0 0 1 ${x + w} ${y + r} L ${x + w} ${y + h - r} A ${r} ${r} 0 0 1 ${x + w - r} ${y + h} L ${x + r} ${y + h} A ${r} ${r} 0 0 1 ${x} ${y + h - r} L ${x} ${y + r} A ${r} ${r} 0 0 1 ${x + r} ${y} Z")`;
88
+ }
89
+ /**
90
+ * Checks if content is simple (string/html) vs complex (component/snippet).
91
+ */
92
+ function isSimpleContent(content) {
93
+ if (!content)
94
+ return true;
95
+ if (typeof content === "string")
96
+ return true;
97
+ if (typeof content === "object") {
98
+ if ("text" in content || "html" in content)
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+ /**
104
+ * Extracts string content for simple THC types.
105
+ */
106
+ function getStringContent(content) {
107
+ if (!content)
108
+ return "";
109
+ if (typeof content === "string")
110
+ return content;
111
+ if (typeof content === "object") {
112
+ if ("html" in content)
113
+ return content.html;
114
+ if ("text" in content)
115
+ return content.text;
116
+ }
117
+ return "";
118
+ }
119
+ /**
120
+ * A Svelte action that highlights a target element with a spotlight effect.
121
+ *
122
+ * Creates a dimmed backdrop overlay with a cutout hole around the target element,
123
+ * optionally showing annotation content positioned next to it. Useful for onboarding
124
+ * tutorials, feature tours, and drawing attention to specific UI elements.
125
+ *
126
+ * The cutout uses `clip-path` with an SVG path, so pointer events in the hole area
127
+ * pass through to the target element naturally.
128
+ *
129
+ * @param targetEl - The element to spotlight
130
+ * @param fn - Function returning spotlight options (reactive)
131
+ *
132
+ * @example
133
+ * ```svelte
134
+ * <!-- Basic usage with programmatic control -->
135
+ * <button
136
+ * use:spotlight={() => ({
137
+ * content: "Click here to get started!",
138
+ * position: "bottom",
139
+ * id: "intro-step-1",
140
+ * })}
141
+ * >
142
+ * Get Started
143
+ * </button>
144
+ *
145
+ * <button onclick={() => showSpotlight('intro-step-1')}>
146
+ * Start Tour
147
+ * </button>
148
+ * ```
149
+ *
150
+ * @example
151
+ * ```svelte
152
+ * <!-- Reactive open control -->
153
+ * <div use:spotlight={() => ({
154
+ * content: { component: TourStep, props: { step: 1 } },
155
+ * position: "right",
156
+ * open: showTour,
157
+ * onHide: () => showTour = false,
158
+ * })}>
159
+ * Target content
160
+ * </div>
161
+ * ```
162
+ */
163
+ export function spotlight(targetEl, fn) {
164
+ const isSupported = isAnchorPositioningSupported();
165
+ // State
166
+ let backdropEl = null;
167
+ let annotationEl = null;
168
+ let anchorEl = null; // invisible anchor for CSS Anchor Positioning
169
+ let mountedComponent = null;
170
+ let isVisible = false;
171
+ let do_debug = false;
172
+ let prevOpen = undefined;
173
+ let resizeObserver = null;
174
+ // Unique identifiers
175
+ const rnd = Math.random().toString(36).slice(2);
176
+ const anchorName = `--anchor-spotlight-${rnd}`;
177
+ // Current options
178
+ let currentOptions = {};
179
+ const debug = (...args) => {
180
+ if (do_debug)
181
+ console.debug("[spotlight]", rnd, ...args);
182
+ };
183
+ function onEscape(e) {
184
+ if (e.key === "Escape") {
185
+ e.preventDefault();
186
+ e.stopPropagation();
187
+ e.stopImmediatePropagation();
188
+ hide();
189
+ }
190
+ }
191
+ function onBackdropClick(e) {
192
+ // Only close if clicking the backdrop itself (not the annotation or the hole)
193
+ if (e.target === backdropEl) {
194
+ hide();
195
+ }
196
+ }
197
+ /**
198
+ * Update the clip-path hole position to match the current target rect.
199
+ */
200
+ function updateHolePosition() {
201
+ if (!backdropEl || !isVisible)
202
+ return;
203
+ const rect = targetEl.getBoundingClientRect();
204
+ const padding = currentOptions.padding ?? 8;
205
+ const borderRadius = currentOptions.borderRadius ?? 8;
206
+ debug("updateHolePosition()", rect);
207
+ backdropEl.style.clipPath = buildClipPath(rect, padding, borderRadius);
208
+ // Update the invisible anchor element position
209
+ if (anchorEl) {
210
+ anchorEl.style.left = `${rect.left - padding}px`;
211
+ anchorEl.style.top = `${rect.top - padding}px`;
212
+ anchorEl.style.width = `${rect.width + padding * 2}px`;
213
+ anchorEl.style.height = `${rect.height + padding * 2}px`;
214
+ }
215
+ // Update fallback annotation position
216
+ if (annotationEl && !isSupported) {
217
+ positionAnnotationFallback(rect, padding);
218
+ }
219
+ }
220
+ /**
221
+ * Position annotation without CSS Anchor Positioning (fallback).
222
+ */
223
+ function positionAnnotationFallback(rect, padding) {
224
+ if (!annotationEl)
225
+ return;
226
+ const pos = currentOptions.position || "bottom";
227
+ const offset = 8; // px fallback offset
228
+ const x = rect.left - padding;
229
+ const y = rect.top - padding;
230
+ const w = rect.width + padding * 2;
231
+ const h = rect.height + padding * 2;
232
+ // Reset position
233
+ annotationEl.style.left = "";
234
+ annotationEl.style.top = "";
235
+ annotationEl.style.right = "";
236
+ annotationEl.style.bottom = "";
237
+ if (pos.startsWith("top")) {
238
+ annotationEl.style.left = `${x}px`;
239
+ annotationEl.style.bottom = `${window.innerHeight - y + offset}px`;
240
+ }
241
+ else if (pos.startsWith("bottom")) {
242
+ annotationEl.style.left = `${x}px`;
243
+ annotationEl.style.top = `${y + h + offset}px`;
244
+ }
245
+ else if (pos === "left") {
246
+ annotationEl.style.right = `${window.innerWidth - x + offset}px`;
247
+ annotationEl.style.top = `${y}px`;
248
+ }
249
+ else if (pos === "right") {
250
+ annotationEl.style.left = `${x + w + offset}px`;
251
+ annotationEl.style.top = `${y}px`;
252
+ }
253
+ }
254
+ function renderContent() {
255
+ if (!annotationEl || !currentOptions.content)
256
+ return;
257
+ debug("renderContent()");
258
+ if (mountedComponent) {
259
+ unmount(mountedComponent);
260
+ mountedComponent = null;
261
+ }
262
+ annotationEl.innerHTML = "";
263
+ const content = currentOptions.content;
264
+ if (isSimpleContent(content)) {
265
+ annotationEl.innerHTML = getStringContent(content);
266
+ }
267
+ else {
268
+ mountedComponent = mount(SpotlightContent, {
269
+ target: annotationEl,
270
+ props: { thc: content },
271
+ });
272
+ }
273
+ }
274
+ function show() {
275
+ debug("show()", {
276
+ enabled: currentOptions.enabled,
277
+ content: currentOptions.content,
278
+ });
279
+ if (!currentOptions.enabled)
280
+ return;
281
+ if (isVisible)
282
+ return;
283
+ isVisible = true;
284
+ if (currentOptions.id) {
285
+ spotlightOpenStates[currentOptions.id] = true;
286
+ }
287
+ const padding = currentOptions.padding ?? 8;
288
+ const borderRadius = currentOptions.borderRadius ?? 8;
289
+ const offsetValue = currentOptions.offset || "0.5rem";
290
+ // Optionally scroll target into view
291
+ if (currentOptions.scrollIntoView !== false) {
292
+ targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
293
+ }
294
+ // Wait for potential scroll to settle before measuring
295
+ requestAnimationFrame(() => {
296
+ if (!isVisible)
297
+ return; // may have been hidden in the meantime
298
+ const rect = targetEl.getBoundingClientRect();
299
+ // 1. Create backdrop overlay
300
+ backdropEl = document.createElement("div");
301
+ backdropEl.style.cssText = `
302
+ position: fixed;
303
+ inset: 0;
304
+ z-index: 50;
305
+ background: var(--stuic-spotlight-backdrop-bg);
306
+ transition-duration: ${TRANSITION}ms;
307
+ `;
308
+ backdropEl.classList.add(...twMerge("stuic-spotlight-backdrop", currentOptions.classBackdrop).split(/\s/));
309
+ backdropEl.style.clipPath = buildClipPath(rect, padding, borderRadius);
310
+ document.body.appendChild(backdropEl);
311
+ // 2. Create invisible anchor element for CSS Anchor Positioning
312
+ anchorEl = document.createElement("div");
313
+ anchorEl.style.cssText = `
314
+ position: fixed;
315
+ left: ${rect.left - padding}px;
316
+ top: ${rect.top - padding}px;
317
+ width: ${rect.width + padding * 2}px;
318
+ height: ${rect.height + padding * 2}px;
319
+ pointer-events: none;
320
+ z-index: -1;
321
+ `;
322
+ addAnchorName(anchorEl, anchorName);
323
+ document.body.appendChild(anchorEl);
324
+ // 3. Create annotation element (if content provided)
325
+ if (currentOptions.content) {
326
+ annotationEl = document.createElement("div");
327
+ annotationEl.setAttribute("role", "dialog");
328
+ if (isSupported) {
329
+ annotationEl.style.cssText = `
330
+ position: fixed;
331
+ position-anchor: ${anchorName};
332
+ position-area: ${POSITION_MAP[currentOptions.position || "bottom"] || "bottom"};
333
+ transition-duration: ${TRANSITION}ms;
334
+ margin: ${offsetValue};
335
+ z-index: 50;
336
+ `;
337
+ annotationEl.classList.add(...twMerge("stuic-spotlight-annotation", _classAnnotation, currentOptions.class).split(/\s/));
338
+ }
339
+ else {
340
+ // Fallback positioning
341
+ annotationEl.style.cssText = `
342
+ position: fixed;
343
+ transition-duration: ${TRANSITION}ms;
344
+ z-index: 50;
345
+ max-width: 90vw;
346
+ `;
347
+ annotationEl.classList.add(...twMerge("stuic-spotlight-annotation-fallback", _classAnnotation, currentOptions.class).split(/\s/));
348
+ positionAnnotationFallback(rect, padding);
349
+ }
350
+ document.body.appendChild(annotationEl);
351
+ renderContent();
352
+ }
353
+ // 4. Lock body scroll
354
+ BodyScroll.lock();
355
+ // 5. Transition in
356
+ requestAnimationFrame(() => {
357
+ backdropEl?.classList.add("spot-visible");
358
+ if (annotationEl) {
359
+ annotationEl.classList.add("spot-block");
360
+ requestAnimationFrame(() => {
361
+ annotationEl?.classList.add("spot-visible");
362
+ });
363
+ }
364
+ currentOptions.onShow?.();
365
+ });
366
+ // 6. Event listeners
367
+ if (currentOptions.closeOnEscape !== false) {
368
+ document.addEventListener("keydown", onEscape);
369
+ }
370
+ if (currentOptions.closeOnBackdropClick !== false) {
371
+ backdropEl.addEventListener("click", onBackdropClick);
372
+ }
373
+ // 7. Watch for target position changes
374
+ resizeObserver = new ResizeObserver(updateHolePosition);
375
+ resizeObserver.observe(targetEl);
376
+ window.addEventListener("resize", updateHolePosition);
377
+ window.addEventListener("scroll", updateHolePosition, true);
378
+ });
379
+ }
380
+ function hide() {
381
+ debug("hide()");
382
+ if (!isVisible)
383
+ return;
384
+ isVisible = false;
385
+ if (currentOptions.id) {
386
+ spotlightOpenStates[currentOptions.id] = false;
387
+ }
388
+ // Remove event listeners
389
+ document.removeEventListener("keydown", onEscape);
390
+ window.removeEventListener("resize", updateHolePosition);
391
+ window.removeEventListener("scroll", updateHolePosition, true);
392
+ resizeObserver?.disconnect();
393
+ resizeObserver = null;
394
+ // Unlock body scroll
395
+ BodyScroll.unlock();
396
+ // Transition out
397
+ backdropEl?.classList.remove("spot-visible");
398
+ annotationEl?.classList.remove("spot-visible");
399
+ setTimeout(() => {
400
+ if (mountedComponent) {
401
+ unmount(mountedComponent);
402
+ mountedComponent = null;
403
+ }
404
+ if (anchorEl) {
405
+ removeAnchorName(anchorEl, anchorName);
406
+ anchorEl.remove();
407
+ anchorEl = null;
408
+ }
409
+ backdropEl?.remove();
410
+ annotationEl?.remove();
411
+ backdropEl = null;
412
+ annotationEl = null;
413
+ currentOptions.onHide?.();
414
+ }, TRANSITION);
415
+ }
416
+ // Reactive params effect
417
+ $effect(() => {
418
+ const opts = fn?.() || {};
419
+ currentOptions = {
420
+ enabled: opts.enabled ?? true,
421
+ content: opts.content,
422
+ position: opts.position || "bottom",
423
+ padding: opts.padding ?? 8,
424
+ borderRadius: opts.borderRadius ?? 8,
425
+ class: opts.class,
426
+ classBackdrop: opts.classBackdrop,
427
+ offset: opts.offset,
428
+ closeOnEscape: opts.closeOnEscape ?? true,
429
+ closeOnBackdropClick: opts.closeOnBackdropClick ?? true,
430
+ scrollIntoView: opts.scrollIntoView ?? true,
431
+ onShow: opts.onShow,
432
+ onHide: opts.onHide,
433
+ debug: opts.debug,
434
+ id: opts.id,
435
+ };
436
+ do_debug = !!opts.debug;
437
+ // Register in global registry if id provided
438
+ if (opts.id) {
439
+ spotlightRegistry.set(opts.id, { show, hide });
440
+ }
441
+ // Update if visible
442
+ if (isVisible) {
443
+ updateHolePosition();
444
+ if (annotationEl && isSupported) {
445
+ annotationEl.style.setProperty("position-area", POSITION_MAP[currentOptions.position || "bottom"] || "bottom");
446
+ }
447
+ if (currentOptions.content) {
448
+ renderContent();
449
+ }
450
+ }
451
+ // Handle programmatic open/close
452
+ const openValue = opts.open;
453
+ if (openValue !== undefined && openValue !== prevOpen) {
454
+ if (openValue && !isVisible) {
455
+ show();
456
+ }
457
+ else if (!openValue && isVisible) {
458
+ hide();
459
+ }
460
+ }
461
+ prevOpen = openValue;
462
+ });
463
+ // Cleanup effect
464
+ $effect(() => {
465
+ return () => {
466
+ // Cleanup on unmount
467
+ if (mountedComponent) {
468
+ unmount(mountedComponent);
469
+ mountedComponent = null;
470
+ }
471
+ if (isVisible) {
472
+ BodyScroll.unlock();
473
+ }
474
+ if (anchorEl) {
475
+ removeAnchorName(anchorEl, anchorName);
476
+ anchorEl.remove();
477
+ }
478
+ backdropEl?.remove();
479
+ annotationEl?.remove();
480
+ resizeObserver?.disconnect();
481
+ document.removeEventListener("keydown", onEscape);
482
+ window.removeEventListener("resize", updateHolePosition);
483
+ window.removeEventListener("scroll", updateHolePosition, true);
484
+ // Unregister from registry
485
+ if (currentOptions.id) {
486
+ spotlightRegistry.delete(currentOptions.id);
487
+ delete spotlightOpenStates[currentOptions.id];
488
+ }
489
+ };
490
+ });
491
+ }
package/dist/index.css CHANGED
@@ -57,6 +57,7 @@ In practice:
57
57
 
58
58
  /* Action CSS */
59
59
  @import "./actions/popover/index.css";
60
+ @import "./actions/spotlight/index.css";
60
61
  @import "./actions/tooltip/index.css";
61
62
 
62
63
  /* Base styles for STUIC components */
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- 12 Svelte actions (directives) for reusable DOM behavior.
5
+ 13 Svelte actions (directives) for reusable DOM behavior.
6
6
 
7
7
  ---
8
8
 
@@ -21,6 +21,7 @@
21
21
  | `typeahead` | Advanced autocomplete behavior | `typeahead.svelte.ts` |
22
22
  | `onSubmitValidityCheck` | Form submit validation | `on-submit-validity-check.svelte.ts` |
23
23
  | `popover` | Popover positioning | `popover/` |
24
+ | `spotlight` | Spotlight/coach mark overlay with cutout hole | `spotlight/` |
24
25
  | `tooltip` | Tooltip positioning and display | `tooltip/` |
25
26
 
26
27
  ---
@@ -76,6 +77,22 @@ Actions using `$effect()` accept a function returning options:
76
77
  </div>
77
78
  ```
78
79
 
80
+ ### Spotlight
81
+
82
+ ```svelte
83
+ <div
84
+ use:spotlight={() => ({
85
+ content: "Check out this feature!",
86
+ position: "bottom",
87
+ id: "intro-step-1",
88
+ })}
89
+ >
90
+ Target Element
91
+ </div>
92
+
93
+ <button onclick={() => showSpotlight('intro-step-1')}>Start Tour</button>
94
+ ```
95
+
79
96
  ### Tooltip
80
97
 
81
98
  ```svelte
@@ -121,4 +138,5 @@ export function focusTrap(el: HTMLElement, options?: Options) {
121
138
  | src/lib/actions/index.ts | All action exports |
122
139
  | src/lib/actions/validate.svelte.ts | Complex action example |
123
140
  | src/lib/actions/focus-trap.ts | Traditional action pattern |
141
+ | src/lib/actions/spotlight/ | Spotlight/coach mark action |
124
142
  | src/lib/actions/tooltip/ | Multi-file action example |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",