@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 +2 -2
- package/API.md +61 -0
- package/dist/actions/dim-behind/dim-behind.svelte.d.ts +82 -0
- package/dist/actions/dim-behind/dim-behind.svelte.js +245 -0
- package/dist/actions/dim-behind/index.css +26 -0
- package/dist/actions/index.d.ts +2 -0
- package/dist/actions/index.js +2 -0
- package/dist/actions/spotlight/SpotlightContent.svelte +15 -0
- package/dist/actions/spotlight/SpotlightContent.svelte.d.ts +7 -0
- package/dist/actions/spotlight/index.css +55 -0
- package/dist/actions/spotlight/spotlight.svelte.d.ts +111 -0
- package/dist/actions/spotlight/spotlight.svelte.js +491 -0
- package/dist/icons/index.d.ts +3 -0
- package/dist/icons/index.js +3 -0
- package/dist/index.css +2 -0
- package/docs/domains/actions.md +36 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
```
|
|
25
25
|
src/lib/
|
|
26
26
|
├── components/ # 39 UI components
|
|
27
|
-
├── 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) —
|
|
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
|
+
}
|
package/dist/actions/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/actions/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/icons/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/icons/index.js
CHANGED
|
@@ -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 */
|
package/docs/domains/actions.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
|
|
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 |
|