@marianmeres/stuic 3.11.0 → 3.13.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/ # 14 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) — 14 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
 
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Show a dim-behind effect by its registered ID.
3
+ *
4
+ * @param id - The dimBehind ID to activate
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * showDimBehind('highlight-cta');
9
+ * ```
10
+ */
11
+ export declare function showDimBehind(id: string): void;
12
+ /**
13
+ * Hide a dim-behind effect by its registered ID.
14
+ *
15
+ * @param id - The dimBehind ID to deactivate
16
+ */
17
+ export declare function hideDimBehind(id: string): void;
18
+ /**
19
+ * Check if a dim-behind effect is currently active by its registered ID.
20
+ *
21
+ * @param id - The dimBehind ID to check
22
+ * @returns true if the dim-behind effect is active
23
+ */
24
+ export declare function isDimBehindOpen(id: string): boolean;
25
+ /**
26
+ * Options for the dimBehind action.
27
+ */
28
+ export interface DimBehindOptions {
29
+ /** Programmatically control active state (reactive) */
30
+ open?: boolean;
31
+ /** Whether the dim effect can be activated (default: true) */
32
+ enabled?: boolean;
33
+ /** Unique ID for registry-based programmatic control */
34
+ id?: string;
35
+ /** Per-instance z-index for the elevated element (overrides CSS token) */
36
+ zIndex?: number;
37
+ /** Lock body scroll while dimmed (default: false) */
38
+ scrollLock?: boolean;
39
+ /** Close on Escape key (default: true) */
40
+ closeOnEscape?: boolean;
41
+ /** Close when backdrop is clicked (default: true) */
42
+ closeOnBackdropClick?: boolean;
43
+ /** Custom CSS class for the backdrop element */
44
+ classBackdrop?: string;
45
+ /** Callback when dim effect activates */
46
+ onShow?: () => void;
47
+ /** Callback when dim effect deactivates */
48
+ onHide?: () => void;
49
+ /** Debug mode */
50
+ debug?: boolean;
51
+ }
52
+ /**
53
+ * A Svelte action that dims everything behind a target element.
54
+ *
55
+ * Creates a shared backdrop overlay and elevates the target element above it via z-index.
56
+ * A simplified alternative to `spotlight` — no cutout hole, no annotations. Useful for
57
+ * drawing attention to a specific element by dimming the rest of the page.
58
+ *
59
+ * Multiple elements can use dimBehind simultaneously — the backdrop is a singleton with
60
+ * reference counting, while all elevated elements remain above it.
61
+ *
62
+ * @param node - The element to elevate above the backdrop
63
+ * @param fn - Function returning dimBehind options (reactive via $effect)
64
+ *
65
+ * @example
66
+ * ```svelte
67
+ * <!-- Reactive control -->
68
+ * <div use:dimBehind={() => ({ open: isDimmed, onHide: () => isDimmed = false })}>
69
+ * This element floats above the dimmed backdrop
70
+ * </div>
71
+ * ```
72
+ *
73
+ * @example
74
+ * ```svelte
75
+ * <!-- Programmatic control via ID -->
76
+ * <div use:dimBehind={() => ({ id: 'highlight-cta' })}>
77
+ * Call to Action
78
+ * </div>
79
+ * <button onclick={() => showDimBehind('highlight-cta')}>Highlight</button>
80
+ * ```
81
+ */
82
+ export declare function dimBehind(node: HTMLElement, fn?: () => DimBehindOptions): void;
@@ -0,0 +1,245 @@
1
+ import { BodyScroll } from "../../utils/body-scroll-locker.js";
2
+ // --- Singleton Backdrop Manager ---
3
+ let backdropEl = null;
4
+ let activeCount = 0;
5
+ const TRANSITION_SAFETY_MARGIN = 50;
6
+ function getTransitionDuration() {
7
+ const raw = getComputedStyle(document.documentElement)
8
+ .getPropertyValue("--stuic-dim-behind-transition-duration")
9
+ .trim();
10
+ return parseFloat(raw) || 150;
11
+ }
12
+ function getElementZIndex() {
13
+ return (getComputedStyle(document.documentElement)
14
+ .getPropertyValue("--stuic-dim-behind-element-z-index")
15
+ .trim() || "41");
16
+ }
17
+ function showBackdrop(classBackdrop) {
18
+ activeCount++;
19
+ if (activeCount === 1) {
20
+ backdropEl = document.createElement("div");
21
+ backdropEl.classList.add("stuic-dim-behind-backdrop");
22
+ if (classBackdrop) {
23
+ backdropEl.classList.add(...classBackdrop.split(/\s+/).filter(Boolean));
24
+ }
25
+ document.body.appendChild(backdropEl);
26
+ // Force reflow for transition
27
+ backdropEl.offsetHeight;
28
+ backdropEl.classList.add("dim-visible");
29
+ }
30
+ }
31
+ function hideBackdrop() {
32
+ activeCount = Math.max(0, activeCount - 1);
33
+ if (activeCount === 0 && backdropEl) {
34
+ const el = backdropEl;
35
+ el.classList.remove("dim-visible");
36
+ const cleanup = () => {
37
+ el.remove();
38
+ if (backdropEl === el)
39
+ backdropEl = null;
40
+ };
41
+ el.addEventListener("transitionend", cleanup, { once: true });
42
+ // Safety fallback in case transitionend doesn't fire
43
+ setTimeout(cleanup, getTransitionDuration() + TRANSITION_SAFETY_MARGIN);
44
+ }
45
+ }
46
+ // --- Registry ---
47
+ const dimBehindOpenStates = $state({});
48
+ const dimBehindRegistry = new Map();
49
+ /**
50
+ * Show a dim-behind effect by its registered ID.
51
+ *
52
+ * @param id - The dimBehind ID to activate
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * showDimBehind('highlight-cta');
57
+ * ```
58
+ */
59
+ export function showDimBehind(id) {
60
+ dimBehindRegistry.get(id)?.show();
61
+ }
62
+ /**
63
+ * Hide a dim-behind effect by its registered ID.
64
+ *
65
+ * @param id - The dimBehind ID to deactivate
66
+ */
67
+ export function hideDimBehind(id) {
68
+ dimBehindRegistry.get(id)?.hide();
69
+ }
70
+ /**
71
+ * Check if a dim-behind effect is currently active by its registered ID.
72
+ *
73
+ * @param id - The dimBehind ID to check
74
+ * @returns true if the dim-behind effect is active
75
+ */
76
+ export function isDimBehindOpen(id) {
77
+ return dimBehindOpenStates[id] ?? false;
78
+ }
79
+ // --- Action ---
80
+ /**
81
+ * A Svelte action that dims everything behind a target element.
82
+ *
83
+ * Creates a shared backdrop overlay and elevates the target element above it via z-index.
84
+ * A simplified alternative to `spotlight` — no cutout hole, no annotations. Useful for
85
+ * drawing attention to a specific element by dimming the rest of the page.
86
+ *
87
+ * Multiple elements can use dimBehind simultaneously — the backdrop is a singleton with
88
+ * reference counting, while all elevated elements remain above it.
89
+ *
90
+ * @param node - The element to elevate above the backdrop
91
+ * @param fn - Function returning dimBehind options (reactive via $effect)
92
+ *
93
+ * @example
94
+ * ```svelte
95
+ * <!-- Reactive control -->
96
+ * <div use:dimBehind={() => ({ open: isDimmed, onHide: () => isDimmed = false })}>
97
+ * This element floats above the dimmed backdrop
98
+ * </div>
99
+ * ```
100
+ *
101
+ * @example
102
+ * ```svelte
103
+ * <!-- Programmatic control via ID -->
104
+ * <div use:dimBehind={() => ({ id: 'highlight-cta' })}>
105
+ * Call to Action
106
+ * </div>
107
+ * <button onclick={() => showDimBehind('highlight-cta')}>Highlight</button>
108
+ * ```
109
+ */
110
+ export function dimBehind(node, fn) {
111
+ // State
112
+ let isVisible = false;
113
+ let prevOpen = undefined;
114
+ let savedPosition = "";
115
+ let savedZIndex = "";
116
+ let currentOptions = {};
117
+ let do_debug = false;
118
+ const debug = (...args) => {
119
+ if (do_debug)
120
+ console.debug("[dimBehind]", ...args);
121
+ };
122
+ function onEscape(e) {
123
+ if (e.key === "Escape") {
124
+ e.preventDefault();
125
+ e.stopPropagation();
126
+ e.stopImmediatePropagation();
127
+ hide();
128
+ }
129
+ }
130
+ function onBackdropClick(e) {
131
+ if (e.target === backdropEl) {
132
+ hide();
133
+ }
134
+ }
135
+ function show() {
136
+ if (isVisible)
137
+ return;
138
+ if (!currentOptions.enabled)
139
+ return;
140
+ debug("show()");
141
+ isVisible = true;
142
+ if (currentOptions.id) {
143
+ dimBehindOpenStates[currentOptions.id] = true;
144
+ }
145
+ // Save original styles
146
+ savedPosition = node.style.position;
147
+ savedZIndex = node.style.zIndex;
148
+ // Elevate node above backdrop
149
+ const zIndex = currentOptions.zIndex !== undefined
150
+ ? String(currentOptions.zIndex)
151
+ : getElementZIndex();
152
+ node.style.position = "relative";
153
+ node.style.zIndex = zIndex;
154
+ // Show singleton backdrop
155
+ showBackdrop(currentOptions.classBackdrop);
156
+ // Optional scroll lock
157
+ if (currentOptions.scrollLock) {
158
+ BodyScroll.lock();
159
+ }
160
+ // Event listeners
161
+ if (currentOptions.closeOnEscape !== false) {
162
+ document.addEventListener("keydown", onEscape);
163
+ }
164
+ if (currentOptions.closeOnBackdropClick !== false && backdropEl) {
165
+ backdropEl.addEventListener("click", onBackdropClick);
166
+ }
167
+ currentOptions.onShow?.();
168
+ }
169
+ function hide() {
170
+ if (!isVisible)
171
+ return;
172
+ debug("hide()");
173
+ isVisible = false;
174
+ if (currentOptions.id) {
175
+ dimBehindOpenStates[currentOptions.id] = false;
176
+ }
177
+ // Restore original styles
178
+ node.style.position = savedPosition;
179
+ node.style.zIndex = savedZIndex;
180
+ // Remove event listeners
181
+ document.removeEventListener("keydown", onEscape);
182
+ if (backdropEl) {
183
+ backdropEl.removeEventListener("click", onBackdropClick);
184
+ }
185
+ // Optional scroll unlock
186
+ if (currentOptions.scrollLock) {
187
+ BodyScroll.unlock();
188
+ }
189
+ // Hide singleton backdrop
190
+ hideBackdrop();
191
+ currentOptions.onHide?.();
192
+ }
193
+ // Reactive params effect
194
+ $effect(() => {
195
+ const opts = fn?.() || {};
196
+ currentOptions = {
197
+ open: opts.open,
198
+ enabled: opts.enabled ?? true,
199
+ id: opts.id,
200
+ zIndex: opts.zIndex,
201
+ scrollLock: opts.scrollLock ?? false,
202
+ closeOnEscape: opts.closeOnEscape ?? true,
203
+ closeOnBackdropClick: opts.closeOnBackdropClick ?? true,
204
+ classBackdrop: opts.classBackdrop,
205
+ onShow: opts.onShow,
206
+ onHide: opts.onHide,
207
+ debug: opts.debug,
208
+ };
209
+ do_debug = !!opts.debug;
210
+ // Register in global registry if id provided
211
+ if (opts.id) {
212
+ dimBehindRegistry.set(opts.id, { show, hide });
213
+ }
214
+ // Handle programmatic open/close
215
+ const openValue = opts.open;
216
+ if (openValue !== undefined && openValue !== prevOpen) {
217
+ if (openValue && !isVisible) {
218
+ show();
219
+ }
220
+ else if (!openValue && isVisible) {
221
+ hide();
222
+ }
223
+ }
224
+ prevOpen = openValue;
225
+ });
226
+ // Cleanup effect
227
+ $effect(() => {
228
+ return () => {
229
+ if (isVisible) {
230
+ node.style.position = savedPosition;
231
+ node.style.zIndex = savedZIndex;
232
+ if (currentOptions.scrollLock) {
233
+ BodyScroll.unlock();
234
+ }
235
+ hideBackdrop();
236
+ document.removeEventListener("keydown", onEscape);
237
+ }
238
+ // Unregister from registry
239
+ if (currentOptions.id) {
240
+ dimBehindRegistry.delete(currentOptions.id);
241
+ delete dimBehindOpenStates[currentOptions.id];
242
+ }
243
+ };
244
+ });
245
+ }
@@ -0,0 +1,26 @@
1
+ /* ============================================================================
2
+ DIM BEHIND ACTION
3
+ A simplified alternative to spotlight — dims everything behind a target element.
4
+ ============================================================================ */
5
+
6
+ :root {
7
+ --stuic-dim-behind-backdrop-bg: rgb(0 0 0 / 0.5);
8
+ --stuic-dim-behind-backdrop-z-index: 40;
9
+ --stuic-dim-behind-element-z-index: 41;
10
+ --stuic-dim-behind-transition-duration: 150ms;
11
+ }
12
+
13
+ .stuic-dim-behind-backdrop {
14
+ position: fixed;
15
+ inset: 0;
16
+ z-index: var(--stuic-dim-behind-backdrop-z-index);
17
+ background: var(--stuic-dim-behind-backdrop-bg);
18
+ opacity: 0;
19
+ transition-property: opacity;
20
+ transition-duration: var(--stuic-dim-behind-transition-duration);
21
+ pointer-events: auto;
22
+
23
+ &.dim-visible {
24
+ opacity: 1;
25
+ }
26
+ }
@@ -1,11 +1,13 @@
1
1
  export * from "./autogrow.svelte.js";
2
2
  export * from "./autoscroll.js";
3
+ export * from "./dim-behind/dim-behind.svelte.js";
3
4
  export * from "./file-dropzone.svelte.js";
4
5
  export * from "./focus-trap.js";
5
6
  export * from "./highlight-dragover.svelte.js";
6
7
  export * from "./on-submit-validity-check.svelte.js";
7
8
  export * from "./popover/popover.svelte.js";
8
9
  export * from "./resizable-width.svelte.js";
10
+ export * from "./spotlight/spotlight.svelte.js";
9
11
  export * from "./tooltip/tooltip.svelte.js";
10
12
  export * from "./trim.svelte.js";
11
13
  export * from "./typeahead.svelte.js";
@@ -1,11 +1,13 @@
1
1
  export * from "./autogrow.svelte.js";
2
2
  export * from "./autoscroll.js";
3
+ export * from "./dim-behind/dim-behind.svelte.js";
3
4
  export * from "./file-dropzone.svelte.js";
4
5
  export * from "./focus-trap.js";
5
6
  export * from "./highlight-dragover.svelte.js";
6
7
  export * from "./on-submit-validity-check.svelte.js";
7
8
  export * from "./popover/popover.svelte.js";
8
9
  export * from "./resizable-width.svelte.js";
10
+ export * from "./spotlight/spotlight.svelte.js";
9
11
  export * from "./tooltip/tooltip.svelte.js";
10
12
  export * from "./trim.svelte.js";
11
13
  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
+ }
@@ -41,3 +41,6 @@ export { iconLucideSettings as iconSettings } from "@marianmeres/icons-fns/lucid
41
41
  export { iconLucideSquare as iconSquare } from "@marianmeres/icons-fns/lucide/iconLucideSquare.js";
42
42
  export { iconLucideUser as iconUser } from "@marianmeres/icons-fns/lucide/iconLucideUser.js";
43
43
  export { iconLucideX as iconX } from "@marianmeres/icons-fns/lucide/iconLucideX.js";
44
+ export { iconLucideGripHorizontal as iconGripHorizontal } from "@marianmeres/icons-fns/lucide/iconLucideGripHorizontal.js";
45
+ export { iconLucideGripVertical as iconGripVertical } from "@marianmeres/icons-fns/lucide/iconLucideGripVertical.js";
46
+ export { iconLucideGrip as iconGrip } from "@marianmeres/icons-fns/lucide/iconLucideGrip.js";
@@ -45,3 +45,6 @@ export { iconLucideSettings as iconSettings } from "@marianmeres/icons-fns/lucid
45
45
  export { iconLucideSquare as iconSquare } from "@marianmeres/icons-fns/lucide/iconLucideSquare.js";
46
46
  export { iconLucideUser as iconUser } from "@marianmeres/icons-fns/lucide/iconLucideUser.js";
47
47
  export { iconLucideX as iconX } from "@marianmeres/icons-fns/lucide/iconLucideX.js";
48
+ export { iconLucideGripHorizontal as iconGripHorizontal } from "@marianmeres/icons-fns/lucide/iconLucideGripHorizontal.js";
49
+ export { iconLucideGripVertical as iconGripVertical } from "@marianmeres/icons-fns/lucide/iconLucideGripVertical.js";
50
+ export { iconLucideGrip as iconGrip } from "@marianmeres/icons-fns/lucide/iconLucideGrip.js";
package/dist/index.css CHANGED
@@ -56,7 +56,9 @@ In practice:
56
56
  @import "./components/X/index.css";
57
57
 
58
58
  /* Action CSS */
59
+ @import "./actions/dim-behind/index.css";
59
60
  @import "./actions/popover/index.css";
61
+ @import "./actions/spotlight/index.css";
60
62
  @import "./actions/tooltip/index.css";
61
63
 
62
64
  /* 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
+ 14 Svelte actions (directives) for reusable DOM behavior.
6
6
 
7
7
  ---
8
8
 
@@ -14,6 +14,7 @@
14
14
  | `focusTrap` | Keyboard focus containment (modals/dialogs) | `focus-trap.ts` |
15
15
  | `autogrow` | Auto-resize textarea to content | `autogrow.svelte.ts` |
16
16
  | `autoscroll` | Auto-scroll container to bottom | `autoscroll.ts` |
17
+ | `dimBehind` | Dim everything behind a target element (simplified spotlight) | `dim-behind/` |
17
18
  | `fileDropzone` | Drag-and-drop file handling | `file-dropzone.svelte.ts` |
18
19
  | `highlightDragover` | Visual feedback on drag-over | `highlight-dragover.svelte.ts` |
19
20
  | `resizableWidth` | Draggable width resizing | `resizable-width.svelte.ts` |
@@ -21,6 +22,7 @@
21
22
  | `typeahead` | Advanced autocomplete behavior | `typeahead.svelte.ts` |
22
23
  | `onSubmitValidityCheck` | Form submit validation | `on-submit-validity-check.svelte.ts` |
23
24
  | `popover` | Popover positioning | `popover/` |
25
+ | `spotlight` | Spotlight/coach mark overlay with cutout hole | `spotlight/` |
24
26
  | `tooltip` | Tooltip positioning and display | `tooltip/` |
25
27
 
26
28
  ---
@@ -76,6 +78,37 @@ Actions using `$effect()` accept a function returning options:
76
78
  </div>
77
79
  ```
78
80
 
81
+ ### Spotlight
82
+
83
+ ```svelte
84
+ <div
85
+ use:spotlight={() => ({
86
+ content: "Check out this feature!",
87
+ position: "bottom",
88
+ id: "intro-step-1",
89
+ })}
90
+ >
91
+ Target Element
92
+ </div>
93
+
94
+ <button onclick={() => showSpotlight('intro-step-1')}>Start Tour</button>
95
+ ```
96
+
97
+ ### Dim Behind
98
+
99
+ ```svelte
100
+ <div
101
+ use:dimBehind={() => ({
102
+ open: isDimmed,
103
+ onHide: () => isDimmed = false,
104
+ })}
105
+ >
106
+ Highlighted Element
107
+ </div>
108
+
109
+ <button onclick={() => showDimBehind('my-id')}>Highlight</button>
110
+ ```
111
+
79
112
  ### Tooltip
80
113
 
81
114
  ```svelte
@@ -121,4 +154,6 @@ export function focusTrap(el: HTMLElement, options?: Options) {
121
154
  | src/lib/actions/index.ts | All action exports |
122
155
  | src/lib/actions/validate.svelte.ts | Complex action example |
123
156
  | src/lib/actions/focus-trap.ts | Traditional action pattern |
157
+ | src/lib/actions/dim-behind/ | Simplified spotlight alternative |
158
+ | src/lib/actions/spotlight/ | Spotlight/coach mark action |
124
159
  | 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.13.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",