@pablo2410/shared-ui 0.19.1 → 0.21.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.
@@ -0,0 +1,198 @@
1
+ import * as React from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * Shared types for the Opi onboarding tour engine.
6
+ *
7
+ * Deliberately minimal for the lightweight ship (2026-09-11): a linear list
8
+ * of steps, one per granted service, shown as an overlay on the Portal
9
+ * Service Hub. No cross-origin resume, no setup-task step kind yet — those
10
+ * belong to the heavier enterprise-setup-concierge track, which is scoped
11
+ * and content-authored separately before it gets an engine.
12
+ */
13
+ type TourStepKind = "intro" | "service" | "outro";
14
+ /**
15
+ * Where a "service"-kind step should highlight a live element on the page,
16
+ * instead of rendering as a centered modal. Spotlighting is a rendering mode
17
+ * of a service step, not a distinct step kind — intro/outro never spotlight
18
+ * anything, and a service step authored without this still renders as the
19
+ * original centered modal.
20
+ *
21
+ * Deliberately same-origin only: the consumer's own DOM must contain an
22
+ * element carrying `data-tour-target="<selector>"`. A cross-origin tour (a
23
+ * subdomain spotlighting its own features) mints its own selector values —
24
+ * this type doesn't know or care which app is consuming it.
25
+ */
26
+ interface TourSpotlightTarget {
27
+ /** Matches a `data-tour-target` attribute value in the consumer's DOM. */
28
+ selector: string;
29
+ /** Where to place the explanation panel relative to the highlighted
30
+ * element. "auto" (default) picks based on available viewport space,
31
+ * falling back bottom -> top -> right -> left. */
32
+ placement?: "top" | "bottom" | "left" | "right" | "auto";
33
+ }
34
+ interface TourStep {
35
+ /** Stable id — used for progress persistence and analytics, not display. */
36
+ id: string;
37
+ kind: TourStepKind;
38
+ /** Short label shown in the step dots / progress indicator. */
39
+ label: string;
40
+ /** Scripted fallback narration — always renders first; an AI narration
41
+ * pass (via ai.askOpi) may replace it once it resolves. Never blocks. */
42
+ fallbackText: string;
43
+ /** 2-4 bullets shown under the narration. Empty for intro/outro steps.
44
+ * Superseded by `features` when both are present — kept for back-compat
45
+ * with any step that hasn't been upgraded to the richer shape. */
46
+ highlights?: string[];
47
+ /** Named feature call-outs, richer than a flat bullet — rendered instead
48
+ * of `highlights` when present. Each `name` is also the natural spotlight
49
+ * target label a later in-app tour (inside the service itself) can reuse. */
50
+ features?: {
51
+ name: string;
52
+ detail: string;
53
+ }[];
54
+ /** The one action this step offers, if any. */
55
+ action?: {
56
+ label: string;
57
+ /** Absolute URL (cross-origin service) or in-app path. */
58
+ href: string;
59
+ external?: boolean;
60
+ };
61
+ /** Which catalog service slug this step is about, for narration context / readiness lookups. Absent for intro/outro. */
62
+ serviceSlug?: string;
63
+ /** Highlight a live element on the page instead of a centered modal. See
64
+ * TourSpotlightTarget's own doc comment. Absent = today's centered modal. */
65
+ spotlight?: TourSpotlightTarget;
66
+ }
67
+ type TourStatus = "unseen" | "in-progress" | "done" | "dismissed";
68
+ interface TourProgress {
69
+ status: TourStatus;
70
+ /** id of the step the user was last on. */
71
+ lastStepId: string | null;
72
+ updatedAt: string;
73
+ }
74
+
75
+ interface TourShellSkin {
76
+ accent: string;
77
+ overlayBg: string;
78
+ panelBg: string;
79
+ panelBorder: string;
80
+ mutedText: string;
81
+ bodyText: string;
82
+ softText: string;
83
+ }
84
+ interface TourShellProps {
85
+ step: TourStep;
86
+ index: number;
87
+ total: number;
88
+ isFirst: boolean;
89
+ isLast: boolean;
90
+ onNext: () => void;
91
+ onBack: () => void;
92
+ onDismiss: () => void;
93
+ /** Narration to render — pass the live AI reply once resolved; while
94
+ * pending or on failure, pass `step.fallbackText` so the tour never
95
+ * shows a blank state. */
96
+ narration: string;
97
+ /** True while an AI narration pass is in flight — shows a subtle pulse, never blocks Next. */
98
+ narrationPending?: boolean;
99
+ /** Rendered below the narration/highlights — e.g. a docked OpiAskBar. */
100
+ children?: ReactNode;
101
+ skin?: TourShellSkin;
102
+ /** Orb / avatar mark shown in the header — pass OpiOrb from the consumer. */
103
+ orb?: ReactNode;
104
+ }
105
+ /**
106
+ * Full-viewport dismissible overlay with a centered panel. Esc and the
107
+ * backdrop both dismiss; focus is trapped to the panel while open.
108
+ */
109
+ declare function TourShell({ step, index, total, isFirst, isLast, onNext, onBack, onDismiss, narration, narrationPending, children, skin, orb, }: TourShellProps): React.JSX.Element;
110
+
111
+ interface UseTourOptions {
112
+ /** Called whenever the current step changes (not on initial mount). */
113
+ onStepChange?: (step: TourStep, index: number) => void;
114
+ /** Called once when the user reaches the last step and advances past it. */
115
+ onComplete?: () => void;
116
+ /** Called when the user dismisses the tour before completion. */
117
+ onDismiss?: () => void;
118
+ /** Step id to resume on, if the tour was already in progress. */
119
+ initialStepId?: string | null;
120
+ }
121
+ interface UseTourResult {
122
+ step: TourStep;
123
+ index: number;
124
+ total: number;
125
+ isFirst: boolean;
126
+ isLast: boolean;
127
+ next: () => void;
128
+ back: () => void;
129
+ goto: (stepId: string) => void;
130
+ dismiss: () => void;
131
+ }
132
+ declare function useTour(steps: TourStep[], opts?: UseTourOptions): UseTourResult;
133
+
134
+ /**
135
+ * Pure geometry behind the spotlight rendering mode — no React, no DOM
136
+ * access, just numbers in and numbers out. Factored out so it's testable
137
+ * without jsdom (this package has none — see tourMachine.ts for the same
138
+ * split/precedent on the step-index state machine).
139
+ *
140
+ * TourShell is the consumer: given a target element's measured rect (from
141
+ * useSpotlight.ts) and the current viewport size, it needs to know (a) the
142
+ * four backdrop-frame rectangles that surround the target without ever
143
+ * covering it, and (b) where to place the explanation panel so it doesn't
144
+ * overflow the viewport, honoring a preferred placement when there's room.
145
+ */
146
+ interface Rect {
147
+ top: number;
148
+ left: number;
149
+ width: number;
150
+ height: number;
151
+ }
152
+ interface Size {
153
+ width: number;
154
+ height: number;
155
+ }
156
+ type Placement = "top" | "bottom" | "left" | "right" | "auto";
157
+ /** The four backdrop segments that frame a target rect without covering it.
158
+ * Each is a plain rect in viewport (fixed-position) coordinates. A segment
159
+ * with zero width or height (e.g. the target touches a viewport edge) is
160
+ * still returned — the caller can skip rendering a degenerate rect, but
161
+ * the geometry itself doesn't special-case it. */
162
+ interface SpotlightFrame {
163
+ top: Rect;
164
+ bottom: Rect;
165
+ left: Rect;
166
+ right: Rect;
167
+ }
168
+ /** Frames `target` within a `viewport` sized 0,0 to width,height. */
169
+ declare function computeSpotlightFrame(target: Rect, viewport: Size): SpotlightFrame;
170
+ type ConcretePlacement = Exclude<Placement, "auto">;
171
+ interface PanelPosition {
172
+ top: number;
173
+ left: number;
174
+ placement: ConcretePlacement;
175
+ }
176
+ /**
177
+ * Where to place a panel of `panelSize` next to `target`, preferring
178
+ * `placement` but falling back through the other three sides (then finally
179
+ * clamping into the viewport) if the preferred side doesn't have room.
180
+ */
181
+ declare function computePanelPosition(target: Rect, panelSize: Size, viewport: Size, placement?: Placement): PanelPosition;
182
+
183
+ interface UseSpotlightResult {
184
+ /** The target's current viewport-relative rect, or null while unresolved. */
185
+ rect: Rect | null;
186
+ /** False if no element in the DOM currently carries this selector —
187
+ * callers must fall back to the non-spotlight rendering path, never
188
+ * throw or render a spotlight around nothing. */
189
+ found: boolean;
190
+ }
191
+ /**
192
+ * @param selector The `data-tour-target` attribute value to look for
193
+ * (e.g. "service-card-policy-deployment"). Pass `null` to disable
194
+ * resolution entirely (e.g. the current step has no spotlight).
195
+ */
196
+ declare function useSpotlight(selector: string | null): UseSpotlightResult;
197
+
198
+ export { type PanelPosition, type Placement, type Rect, type Size, type SpotlightFrame, type TourProgress, TourShell, type TourShellProps, type TourShellSkin, type TourSpotlightTarget, type TourStatus, type TourStep, type TourStepKind, type UseSpotlightResult, type UseTourOptions, type UseTourResult, computePanelPosition, computeSpotlightFrame, useSpotlight, useTour };
@@ -0,0 +1,443 @@
1
+ // src/tour/TourShell.tsx
2
+ import { useEffect, useRef } from "react";
3
+ import {
4
+ X,
5
+ ChevronLeft,
6
+ ChevronRight,
7
+ ArrowRight,
8
+ ExternalLink
9
+ } from "lucide-react";
10
+ import { jsx, jsxs } from "react/jsx-runtime";
11
+ var defaultSkin = {
12
+ accent: "#8C34E9",
13
+ overlayBg: "rgba(10,14,26,0.72)",
14
+ panelBg: "#0e1624",
15
+ panelBorder: "1px solid #1e2738",
16
+ mutedText: "#596475",
17
+ bodyText: "#c0c6d0",
18
+ softText: "#8890a0"
19
+ };
20
+ function TourShell({
21
+ step,
22
+ index,
23
+ total,
24
+ isFirst,
25
+ isLast,
26
+ onNext,
27
+ onBack,
28
+ onDismiss,
29
+ narration,
30
+ narrationPending,
31
+ children,
32
+ skin = defaultSkin,
33
+ orb
34
+ }) {
35
+ const panelRef = useRef(null);
36
+ useEffect(() => {
37
+ const onKeyDown = (e) => {
38
+ if (e.key === "Escape") onDismiss();
39
+ };
40
+ document.addEventListener("keydown", onKeyDown);
41
+ return () => document.removeEventListener("keydown", onKeyDown);
42
+ }, [onDismiss]);
43
+ useEffect(() => {
44
+ panelRef.current?.focus();
45
+ }, [step.id]);
46
+ return /* @__PURE__ */ jsx(
47
+ "div",
48
+ {
49
+ className: "fixed inset-0 z-50 flex items-center justify-center p-4",
50
+ style: { background: skin.overlayBg },
51
+ onClick: (e) => {
52
+ if (e.target === e.currentTarget) onDismiss();
53
+ },
54
+ children: /* @__PURE__ */ jsxs(
55
+ "div",
56
+ {
57
+ ref: panelRef,
58
+ tabIndex: -1,
59
+ role: "dialog",
60
+ "aria-modal": "true",
61
+ "aria-label": step.label,
62
+ className: "w-full max-w-md rounded-xl overflow-hidden flex flex-col max-h-[85vh] outline-none",
63
+ style: { background: skin.panelBg, border: skin.panelBorder },
64
+ children: [
65
+ /* @__PURE__ */ jsxs(
66
+ "div",
67
+ {
68
+ className: "flex items-center justify-between gap-3 px-5 py-4 shrink-0",
69
+ style: {
70
+ background: "linear-gradient(135deg, #2a1450 0%, #1a0e33 100%)"
71
+ },
72
+ children: [
73
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2.5 min-w-0", children: [
74
+ orb,
75
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-bold text-white truncate", children: step.label })
76
+ ] }),
77
+ /* @__PURE__ */ jsx(
78
+ "button",
79
+ {
80
+ onClick: onDismiss,
81
+ className: "p-1 rounded hover:bg-white/10 transition-colors shrink-0",
82
+ title: "Skip tour",
83
+ children: /* @__PURE__ */ jsx(X, { size: 16, className: "text-white/70" })
84
+ }
85
+ )
86
+ ]
87
+ }
88
+ ),
89
+ /* @__PURE__ */ jsx(
90
+ "div",
91
+ {
92
+ className: "flex items-center gap-1.5 px-5 py-3 shrink-0",
93
+ style: { borderBottom: skin.panelBorder },
94
+ children: Array.from({ length: total }, (_, i) => /* @__PURE__ */ jsx(
95
+ "span",
96
+ {
97
+ className: "h-1.5 rounded-full transition-all",
98
+ style: {
99
+ width: i === index ? 20 : 8,
100
+ background: i === index ? skin.accent : skin.mutedText,
101
+ opacity: i === index ? 1 : 0.4
102
+ }
103
+ },
104
+ i
105
+ ))
106
+ }
107
+ ),
108
+ /* @__PURE__ */ jsxs("div", { className: "overflow-y-auto px-5 py-4 space-y-4", children: [
109
+ /* @__PURE__ */ jsx(
110
+ "p",
111
+ {
112
+ className: "text-[13px] leading-relaxed transition-opacity",
113
+ style: {
114
+ color: skin.bodyText,
115
+ opacity: narrationPending ? 0.7 : 1
116
+ },
117
+ children: narration
118
+ }
119
+ ),
120
+ step.highlights && step.highlights.length > 0 && /* @__PURE__ */ jsx("ul", { className: "space-y-1.5", children: step.highlights.map((h, i) => /* @__PURE__ */ jsxs(
121
+ "li",
122
+ {
123
+ className: "text-[12.5px] leading-relaxed pl-3 relative",
124
+ style: { color: skin.softText },
125
+ children: [
126
+ /* @__PURE__ */ jsx(
127
+ "span",
128
+ {
129
+ className: "absolute left-0",
130
+ style: { color: skin.mutedText },
131
+ children: "\u2022"
132
+ }
133
+ ),
134
+ h
135
+ ]
136
+ },
137
+ i
138
+ )) }),
139
+ step.action && /* @__PURE__ */ jsxs(
140
+ "a",
141
+ {
142
+ href: step.action.href,
143
+ target: step.action.external ? "_blank" : void 0,
144
+ rel: step.action.external ? "noopener noreferrer" : void 0,
145
+ className: "inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-[13px] font-semibold transition-opacity hover:opacity-90",
146
+ style: { background: `${skin.accent}20`, color: skin.accent },
147
+ children: [
148
+ step.action.label,
149
+ step.action.external ? /* @__PURE__ */ jsx(ExternalLink, { size: 13 }) : /* @__PURE__ */ jsx(ArrowRight, { size: 13 })
150
+ ]
151
+ }
152
+ ),
153
+ children
154
+ ] }),
155
+ /* @__PURE__ */ jsxs(
156
+ "div",
157
+ {
158
+ className: "flex items-center justify-between gap-2 px-5 py-3.5 shrink-0",
159
+ style: { borderTop: skin.panelBorder },
160
+ children: [
161
+ /* @__PURE__ */ jsx(
162
+ "button",
163
+ {
164
+ onClick: onDismiss,
165
+ className: "text-[12px] font-medium transition-colors hover:underline",
166
+ style: { color: skin.mutedText },
167
+ children: "Skip tour"
168
+ }
169
+ ),
170
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
171
+ !isFirst && /* @__PURE__ */ jsxs(
172
+ "button",
173
+ {
174
+ onClick: onBack,
175
+ className: "flex items-center gap-1 px-3 py-1.5 rounded-md text-[12.5px] font-medium transition-colors",
176
+ style: { color: skin.bodyText, border: skin.panelBorder },
177
+ children: [
178
+ /* @__PURE__ */ jsx(ChevronLeft, { size: 13 }),
179
+ "Back"
180
+ ]
181
+ }
182
+ ),
183
+ /* @__PURE__ */ jsxs(
184
+ "button",
185
+ {
186
+ onClick: onNext,
187
+ className: "flex items-center gap-1 px-3.5 py-1.5 rounded-md text-[12.5px] font-bold text-white transition-opacity hover:opacity-90",
188
+ style: {
189
+ background: "linear-gradient(135deg, #8C34E9 0%, #5B1FA6 100%)"
190
+ },
191
+ children: [
192
+ isLast ? "Done" : "Next",
193
+ !isLast && /* @__PURE__ */ jsx(ChevronRight, { size: 13 })
194
+ ]
195
+ }
196
+ )
197
+ ] })
198
+ ]
199
+ }
200
+ )
201
+ ]
202
+ }
203
+ )
204
+ }
205
+ );
206
+ }
207
+
208
+ // src/tour/useTour.ts
209
+ import { useCallback, useState } from "react";
210
+
211
+ // src/tour/tourMachine.ts
212
+ function resolveInitialIndex(steps, initialStepId) {
213
+ if (!initialStepId) return 0;
214
+ const found = steps.findIndex((s) => s.id === initialStepId);
215
+ return found === -1 ? 0 : found;
216
+ }
217
+ function nextIndex(steps, current) {
218
+ if (current >= steps.length - 1) {
219
+ return { index: current, completed: true };
220
+ }
221
+ return { index: current + 1, completed: false };
222
+ }
223
+ function backIndex(current) {
224
+ return Math.max(0, current - 1);
225
+ }
226
+ function gotoIndex(steps, stepId, current) {
227
+ const target = steps.findIndex((s) => s.id === stepId);
228
+ return target === -1 ? current : target;
229
+ }
230
+
231
+ // src/tour/useTour.ts
232
+ function useTour(steps, opts = {}) {
233
+ if (steps.length === 0) {
234
+ throw new Error("useTour requires at least one step");
235
+ }
236
+ const [index, setIndex] = useState(
237
+ () => resolveInitialIndex(steps, opts.initialStepId)
238
+ );
239
+ const next = useCallback(() => {
240
+ const result = nextIndex(steps, index);
241
+ if (result.completed) {
242
+ opts.onComplete?.();
243
+ return;
244
+ }
245
+ setIndex(result.index);
246
+ opts.onStepChange?.(steps[result.index], result.index);
247
+ }, [index, steps]);
248
+ const back = useCallback(() => {
249
+ const target = backIndex(index);
250
+ if (target === index) return;
251
+ setIndex(target);
252
+ opts.onStepChange?.(steps[target], target);
253
+ }, [index, steps]);
254
+ const goto = useCallback(
255
+ (stepId) => {
256
+ const target = gotoIndex(steps, stepId, index);
257
+ if (target === index) return;
258
+ setIndex(target);
259
+ opts.onStepChange?.(steps[target], target);
260
+ },
261
+ [steps, index]
262
+ );
263
+ const dismiss = useCallback(() => {
264
+ opts.onDismiss?.();
265
+ }, []);
266
+ return {
267
+ step: steps[index],
268
+ index,
269
+ total: steps.length,
270
+ isFirst: index === 0,
271
+ isLast: index === steps.length - 1,
272
+ next,
273
+ back,
274
+ goto,
275
+ dismiss
276
+ };
277
+ }
278
+
279
+ // src/tour/useSpotlight.ts
280
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
281
+ var RESIZE_DEBOUNCE_MS = 100;
282
+ function useSpotlight(selector) {
283
+ const [rect, setRect] = useState2(null);
284
+ const [found, setFound] = useState2(false);
285
+ const scrolledForSelector = useRef2(null);
286
+ useEffect2(() => {
287
+ if (!selector) {
288
+ setRect(null);
289
+ setFound(false);
290
+ return;
291
+ }
292
+ let resizeTimer;
293
+ let cancelled = false;
294
+ const measure = () => {
295
+ if (cancelled) return;
296
+ const el = document.querySelector(
297
+ `[data-tour-target="${selector}"]`
298
+ );
299
+ if (!el) {
300
+ setRect(null);
301
+ setFound(false);
302
+ return;
303
+ }
304
+ const domRect = el.getBoundingClientRect();
305
+ setRect({
306
+ top: domRect.top,
307
+ left: domRect.left,
308
+ width: domRect.width,
309
+ height: domRect.height
310
+ });
311
+ setFound(true);
312
+ if (scrolledForSelector.current !== selector) {
313
+ scrolledForSelector.current = selector;
314
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
315
+ }
316
+ };
317
+ measure();
318
+ const initialFrame = requestAnimationFrame(measure);
319
+ const onResize = () => {
320
+ if (resizeTimer) clearTimeout(resizeTimer);
321
+ resizeTimer = setTimeout(measure, RESIZE_DEBOUNCE_MS);
322
+ };
323
+ const onScroll = () => measure();
324
+ window.addEventListener("resize", onResize);
325
+ window.addEventListener("scroll", onScroll, { capture: true });
326
+ const observer = new MutationObserver(measure);
327
+ observer.observe(document.body, {
328
+ childList: true,
329
+ subtree: true,
330
+ attributes: true
331
+ });
332
+ return () => {
333
+ cancelled = true;
334
+ cancelAnimationFrame(initialFrame);
335
+ if (resizeTimer) clearTimeout(resizeTimer);
336
+ window.removeEventListener("resize", onResize);
337
+ window.removeEventListener("scroll", onScroll, { capture: true });
338
+ observer?.disconnect();
339
+ };
340
+ }, [selector]);
341
+ return { rect, found };
342
+ }
343
+
344
+ // src/tour/spotlightMath.ts
345
+ function computeSpotlightFrame(target, viewport) {
346
+ const targetBottom = target.top + target.height;
347
+ const targetRight = target.left + target.width;
348
+ return {
349
+ // Full-width strip above the target.
350
+ top: {
351
+ top: 0,
352
+ left: 0,
353
+ width: viewport.width,
354
+ height: Math.max(0, target.top)
355
+ },
356
+ // Full-width strip below the target.
357
+ bottom: {
358
+ top: targetBottom,
359
+ left: 0,
360
+ width: viewport.width,
361
+ height: Math.max(0, viewport.height - targetBottom)
362
+ },
363
+ // Strip to the left of the target, spanning only the target's own row
364
+ // (the top/bottom strips already cover the rest of those rows).
365
+ left: {
366
+ top: target.top,
367
+ left: 0,
368
+ width: Math.max(0, target.left),
369
+ height: target.height
370
+ },
371
+ // Strip to the right of the target, same row-only span as `left`.
372
+ right: {
373
+ top: target.top,
374
+ left: targetRight,
375
+ width: Math.max(0, viewport.width - targetRight),
376
+ height: target.height
377
+ }
378
+ };
379
+ }
380
+ var FALLBACK_ORDER = {
381
+ bottom: ["bottom", "top", "right", "left"],
382
+ top: ["top", "bottom", "right", "left"],
383
+ right: ["right", "left", "bottom", "top"],
384
+ left: ["left", "right", "bottom", "top"]
385
+ };
386
+ var AUTO_ORDER = FALLBACK_ORDER.bottom;
387
+ var PANEL_GAP = 12;
388
+ function computePanelPosition(target, panelSize, viewport, placement = "auto") {
389
+ const order = placement === "auto" ? AUTO_ORDER : FALLBACK_ORDER[placement];
390
+ for (const candidate of order) {
391
+ const pos2 = placeAt(candidate, target, panelSize);
392
+ if (fits(pos2, panelSize, viewport)) {
393
+ return { ...pos2, placement: candidate };
394
+ }
395
+ }
396
+ const fallback = order[0];
397
+ const pos = placeAt(fallback, target, panelSize);
398
+ return {
399
+ top: clamp(pos.top, 0, Math.max(0, viewport.height - panelSize.height)),
400
+ left: clamp(pos.left, 0, Math.max(0, viewport.width - panelSize.width)),
401
+ placement: fallback
402
+ };
403
+ }
404
+ function placeAt(side, target, panelSize) {
405
+ const targetCenterX = target.left + target.width / 2;
406
+ const targetCenterY = target.top + target.height / 2;
407
+ switch (side) {
408
+ case "bottom":
409
+ return {
410
+ top: target.top + target.height + PANEL_GAP,
411
+ left: targetCenterX - panelSize.width / 2
412
+ };
413
+ case "top":
414
+ return {
415
+ top: target.top - panelSize.height - PANEL_GAP,
416
+ left: targetCenterX - panelSize.width / 2
417
+ };
418
+ case "right":
419
+ return {
420
+ top: targetCenterY - panelSize.height / 2,
421
+ left: target.left + target.width + PANEL_GAP
422
+ };
423
+ case "left":
424
+ return {
425
+ top: targetCenterY - panelSize.height / 2,
426
+ left: target.left - panelSize.width - PANEL_GAP
427
+ };
428
+ }
429
+ }
430
+ function fits(pos, panelSize, viewport) {
431
+ return pos.top >= 0 && pos.left >= 0 && pos.top + panelSize.height <= viewport.height && pos.left + panelSize.width <= viewport.width;
432
+ }
433
+ function clamp(value, min, max) {
434
+ return Math.min(Math.max(value, min), max);
435
+ }
436
+ export {
437
+ TourShell,
438
+ computePanelPosition,
439
+ computeSpotlightFrame,
440
+ useSpotlight,
441
+ useTour
442
+ };
443
+ //# sourceMappingURL=index.js.map