@marianmeres/stuic 3.64.1 → 3.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/API.md CHANGED
@@ -1482,6 +1482,7 @@ Multi-step onboarding tour built on the spotlight primitive. Define steps centra
1482
1482
  | `borderRadius`| `number` | Cutout border radius (px) |
1483
1483
  | `onEnter` | `() => void` | Called when entering step |
1484
1484
  | `onLeave` | `() => void` | Called when leaving step |
1485
+ | `selector` | `string` | CSS selector to find the target element (alternative to `use:tourStep`) |
1485
1486
 
1486
1487
  **`tourStep` action:** `use:tourStep={[tour, stepId]}`
1487
1488
 
@@ -1504,6 +1505,29 @@ Multi-step onboarding tour built on the spotlight primitive. Define steps centra
1504
1505
  <button onclick={tour.start}>Start Tour</button>
1505
1506
  ```
1506
1507
 
1508
+ **Selector-based targeting:** Steps can target elements by CSS selector instead of `use:tourStep`. Useful when the target lives inside a reusable component that shouldn't know about the tour:
1509
+
1510
+ ```svelte
1511
+ <!-- ReusableComponent.svelte — no tour knowledge -->
1512
+ <button data-tour-id="download">Download</button>
1513
+
1514
+ <!-- Tour config -->
1515
+ <script>
1516
+ const tour = createTour({
1517
+ steps: [
1518
+ {
1519
+ id: "dl-step",
1520
+ title: "Download",
1521
+ content: "Click here to download.",
1522
+ selector: '[data-tour-id="download"]',
1523
+ },
1524
+ ],
1525
+ });
1526
+ </script>
1527
+ ```
1528
+
1529
+ When a step has `selector`, the tour uses `document.querySelector(selector)` to find the target element. If the element isn't in the DOM yet, the tour polls periodically until `waitForElement` ms elapse (same timeout as `use:tourStep`). Steps without `selector` continue to use `use:tourStep` as before — both mechanisms coexist freely.
1530
+
1507
1531
  ---
1508
1532
 
1509
1533
  ## Utilities
@@ -30,6 +30,12 @@ export interface TourStepDef {
30
30
  onEnter?: () => void;
31
31
  /** Called when tour leaves this step */
32
32
  onLeave?: () => void;
33
+ /**
34
+ * CSS selector to find the target element in the DOM.
35
+ * If provided, the tour will use `document.querySelector(selector)`
36
+ * instead of waiting for a `use:tourStep` registration.
37
+ */
38
+ selector?: string;
33
39
  }
34
40
  /**
35
41
  * Default label overrides for the navigation shell buttons.
@@ -147,6 +153,7 @@ export declare function createTour(options: TourOptions): {
147
153
  reset: () => void;
148
154
  _register: (id: string, el: HTMLElement) => void;
149
155
  _unregister: (id: string) => void;
156
+ _registerAction: (id: string) => void;
150
157
  _isCurrentStep: (id: string) => boolean;
151
158
  _getShellContent: (id: string) => THC;
152
159
  };
@@ -36,6 +36,8 @@ export function createTour(options) {
36
36
  : null;
37
37
  // Element registry: stepId -> HTMLElement
38
38
  const registry = new Map();
39
+ // Steps registered via use:tourStep action (to prevent double-spotlight with selector)
40
+ const actionRegistered = new Set();
39
41
  // Wait-for-element mechanism (one pending wait at a time)
40
42
  let pendingStepId = null;
41
43
  let pendingResolve = null;
@@ -63,7 +65,34 @@ export function createTour(options) {
63
65
  return () => document.removeEventListener("keydown", handler);
64
66
  }
65
67
  });
68
+ // Spotlight for selector-based steps (no use:tourStep action to manage it)
69
+ $effect(() => {
70
+ const step = currentStep;
71
+ if (!active || !step?.selector)
72
+ return;
73
+ // If this step was registered via use:tourStep, let the action handle spotlight
74
+ if (actionRegistered.has(step.id))
75
+ return;
76
+ const el = registry.get(step.id);
77
+ if (!el)
78
+ return;
79
+ // spotlight() creates inner $effects; Svelte cleans them up
80
+ // when this outer effect re-runs (step change) or is destroyed (tour end)
81
+ spotlight(el, () => ({
82
+ open: true,
83
+ content: _getShellContent(step.id),
84
+ position: step.position ?? "bottom",
85
+ padding: step.padding,
86
+ borderRadius: step.borderRadius,
87
+ closeOnEscape: false,
88
+ closeOnBackdropClick: false,
89
+ scrollIntoView: true,
90
+ }));
91
+ });
66
92
  // -- Internal API (used by tourStep action) -----------------------------------------
93
+ function _registerAction(id) {
94
+ actionRegistered.add(id);
95
+ }
67
96
  function _register(id, el) {
68
97
  registry.set(id, el);
69
98
  // If we were waiting for this element, resolve immediately
@@ -119,10 +148,25 @@ export function createTour(options) {
119
148
  pendingResolve = null;
120
149
  pendingStepId = null;
121
150
  }
151
+ const step = options.steps.find((s) => s.id === id);
152
+ const selector = step?.selector;
122
153
  return new Promise((resolve) => {
123
154
  pendingStepId = id;
124
155
  pendingResolve = resolve;
156
+ // For selector-based steps, poll the DOM periodically
157
+ let pollInterval = null;
158
+ if (selector) {
159
+ pollInterval = setInterval(() => {
160
+ const el = document.querySelector(selector);
161
+ if (el && pendingStepId === id) {
162
+ _register(id, el);
163
+ // _register() resolves the promise via pendingResolve
164
+ }
165
+ }, 50);
166
+ }
125
167
  setTimeout(() => {
168
+ if (pollInterval)
169
+ clearInterval(pollInterval);
126
170
  if (pendingStepId === id) {
127
171
  console.warn(`[createTour] Step "${id}" element not found after ${options.waitForElement ?? 500}ms — skipping`);
128
172
  pendingStepId = null;
@@ -144,6 +188,13 @@ export function createTour(options) {
144
188
  // Find the nearest available step in the direction of travel
145
189
  while (index >= 0 && index < options.steps.length) {
146
190
  const step = options.steps[index];
191
+ // For selector-based steps, try to find the element in the DOM
192
+ if (step.selector && !registry.has(step.id)) {
193
+ const el = document.querySelector(step.selector);
194
+ if (el) {
195
+ _register(step.id, el);
196
+ }
197
+ }
147
198
  if (!registry.has(step.id)) {
148
199
  const found = await waitForElement(step.id);
149
200
  if (!found) {
@@ -216,6 +267,7 @@ export function createTour(options) {
216
267
  // Internal — used by tourStep action
217
268
  _register,
218
269
  _unregister,
270
+ _registerAction,
219
271
  _isCurrentStep,
220
272
  _getShellContent,
221
273
  };
@@ -235,6 +287,7 @@ export function tourStep(el, args) {
235
287
  const [tour, id] = args;
236
288
  if (!tour)
237
289
  return;
290
+ tour._registerAction(id);
238
291
  tour._register(id, el);
239
292
  spotlight(el, () => {
240
293
  const isActive = tour._isCurrentStep(id);
@@ -19,6 +19,7 @@
19
19
  value?: string | number;
20
20
  disabled?: boolean;
21
21
  onSelect?: (item: TabbedMenuItem) => void;
22
+ orientation?: "horizontal" | "vertical";
22
23
  //
23
24
  class?: string;
24
25
  classItem?: string;
@@ -39,6 +40,7 @@
39
40
  value = $bindable(),
40
41
  disabled,
41
42
  onSelect,
43
+ orientation = "horizontal",
42
44
  //
43
45
  class: classProp,
44
46
  classItem,
@@ -114,6 +116,8 @@
114
116
  bind:this={el}
115
117
  class={twMerge(!unstyled && "stuic-tabbed-menu", classProp)}
116
118
  role="tablist"
119
+ aria-orientation={orientation}
120
+ data-orientation={orientation}
117
121
  {...rest}
118
122
  >
119
123
  {#each items as item (item.id)}
@@ -14,6 +14,7 @@ export interface Props extends Omit<HTMLAttributes<HTMLUListElement>, "children"
14
14
  value?: string | number;
15
15
  disabled?: boolean;
16
16
  onSelect?: (item: TabbedMenuItem) => void;
17
+ orientation?: "horizontal" | "vertical";
17
18
  class?: string;
18
19
  classItem?: string;
19
20
  classButton?: string;
@@ -86,4 +86,29 @@
86
86
  outline: 2px solid var(--stuic-color-ring);
87
87
  outline-offset: 2px;
88
88
  }
89
+
90
+ /* ============================================================================
91
+ VERTICAL ORIENTATION
92
+ ============================================================================ */
93
+
94
+ .stuic-tabbed-menu[data-orientation="vertical"] {
95
+ flex-direction: column;
96
+ }
97
+
98
+ .stuic-tabbed-menu[data-orientation="vertical"] .stuic-tabbed-menu-item {
99
+ max-width: none;
100
+ flex: none;
101
+ width: var(--stuic-tabbed-menu-item-max-width);
102
+ }
103
+
104
+ .stuic-tabbed-menu[data-orientation="vertical"] .stuic-tabbed-menu-tab {
105
+ border-radius: var(--stuic-tabbed-menu-radius, var(--stuic-radius)) 0 0 var(--stuic-tabbed-menu-radius, var(--stuic-radius));
106
+ border: 1px solid var(--stuic-tabbed-menu-border);
107
+ border-right: 0;
108
+ text-align: left;
109
+ }
110
+
111
+ .stuic-tabbed-menu[data-orientation="vertical"] .stuic-tabbed-menu-tab[aria-selected="true"] {
112
+ border-color: var(--stuic-tabbed-menu-border-active);
113
+ }
89
114
  }
@@ -136,7 +136,17 @@ Actions using `$effect()` accept a function returning options:
136
136
  <button onclick={tour.start}>Start Tour</button>
137
137
  ```
138
138
 
139
- Features: step navigation (next/prev/skip), persistent state via `storageKey`, custom shell snippets, `confirmSkip` callback, wait-for-element mechanism, Escape key support, step lifecycle callbacks (`onEnter`/`onLeave`).
139
+ Steps can also target elements by CSS **selector** instead of `use:tourStep` useful when the target lives inside a reusable component:
140
+
141
+ ```svelte
142
+ <!-- Component adds a stable data attribute (no tour knowledge) -->
143
+ <button data-tour-id="download">Download</button>
144
+
145
+ <!-- Tour config references it by selector -->
146
+ { id: "dl-step", title: "Download", content: "...", selector: '[data-tour-id="download"]' }
147
+ ```
148
+
149
+ Features: step navigation (next/prev/skip), selector-based step targeting, persistent state via `storageKey`, custom shell snippets, `confirmSkip` callback, wait-for-element mechanism, Escape key support, step lifecycle callbacks (`onEnter`/`onLeave`).
140
150
 
141
151
  ---
142
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.64.1",
3
+ "version": "3.66.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",