@nubisco/ui 2.0.1 → 2.1.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/README.md +7 -0
- package/dist/components/InfoHint.d.ts +47 -0
- package/dist/components/InfoHint.vue.d.ts +41 -0
- package/dist/components/InfoHint.vue.d.ts.map +1 -0
- package/dist/components/UserMenu.vue.d.ts +1 -1
- package/dist/components/Walkthrough.d.ts +156 -0
- package/dist/components/Walkthrough.vue.d.ts +66 -0
- package/dist/components/Walkthrough.vue.d.ts.map +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/composables/useReducedMotion.composable.d.ts +13 -0
- package/dist/composables/useReducedMotion.composable.d.ts.map +1 -0
- package/dist/composables/useWalkthrough.composable.d.ts +19 -0
- package/dist/composables/useWalkthrough.composable.d.ts.map +1 -0
- package/dist/directives/TourStep.directive.d.ts +4 -0
- package/dist/directives/TourStep.directive.d.ts.map +1 -0
- package/dist/global.d.ts +4 -0
- package/dist/index.cjs +9 -9
- package/dist/index.mjs +10003 -9261
- package/dist/main.d.ts +9 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/ui.css +1 -1
- package/dist/utils/anchorPosition.helper.d.ts +50 -0
- package/dist/utils/anchorPosition.helper.d.ts.map +1 -0
- package/dist/utils/tourTarget.helper.d.ts +19 -0
- package/dist/utils/tourTarget.helper.d.ts.map +1 -0
- package/dist/utils/walkthroughStorage.helper.d.ts +22 -0
- package/dist/utils/walkthroughStorage.helper.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/styles/_theme.scss +26 -0
- package/src/utils/anchorPosition.helper.ts +164 -0
- package/src/utils/tourTarget.helper.ts +71 -0
- package/src/utils/walkthroughStorage.helper.ts +83 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewport-aware placement for floating surfaces (hint popovers, walkthrough
|
|
3
|
+
* coach-marks) anchored to an element.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately a pure function over plain rectangles rather than a method on a
|
|
6
|
+
* component: placement is the part of a popover that is easy to get subtly
|
|
7
|
+
* wrong and impossible to eyeball in a unit test, so it is kept free of DOM
|
|
8
|
+
* access. Callers pass `getBoundingClientRect()` output and the viewport size;
|
|
9
|
+
* everything else is arithmetic.
|
|
10
|
+
*/
|
|
11
|
+
export type TAnchorSide = 'top' | 'bottom' | 'left' | 'right';
|
|
12
|
+
/** The subset of DOMRect this module needs. Any DOMRect satisfies it. */
|
|
13
|
+
export interface IAnchorRect {
|
|
14
|
+
top: number;
|
|
15
|
+
left: number;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
export interface IAnchorSize {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}
|
|
23
|
+
export interface IAnchorOptions {
|
|
24
|
+
/** Preferred side. Flipped only when it does not fit. */
|
|
25
|
+
side: TAnchorSide;
|
|
26
|
+
/** Distance between the anchor edge and the floating surface. */
|
|
27
|
+
gap?: number;
|
|
28
|
+
/** Minimum distance kept between the surface and the viewport edge. */
|
|
29
|
+
inset?: number;
|
|
30
|
+
viewport: IAnchorSize;
|
|
31
|
+
}
|
|
32
|
+
export interface IAnchorPlacement {
|
|
33
|
+
/** Viewport (`position: fixed`) coordinates. */
|
|
34
|
+
top: number;
|
|
35
|
+
left: number;
|
|
36
|
+
/** The side actually used, after any flip. Drives the arrow direction. */
|
|
37
|
+
side: TAnchorSide;
|
|
38
|
+
/**
|
|
39
|
+
* Where the anchor's centre sits along the surface's cross axis, as a
|
|
40
|
+
* percentage of the surface's size on that axis. After a viewport clamp the
|
|
41
|
+
* surface and the anchor are no longer centred on each other, so an arrow
|
|
42
|
+
* pinned at a fixed 50% points at nothing; consumers feed this into a CSS
|
|
43
|
+
* custom property to re-aim it.
|
|
44
|
+
*/
|
|
45
|
+
arrowOffset: number;
|
|
46
|
+
}
|
|
47
|
+
export declare function placeAnchored(anchor: IAnchorRect, floating: IAnchorSize, { side, gap, inset, viewport }: IAnchorOptions): IAnchorPlacement;
|
|
48
|
+
/** Live viewport size, guarded for SSR / non-DOM environments. */
|
|
49
|
+
export declare function viewportSize(): IAnchorSize;
|
|
50
|
+
//# sourceMappingURL=anchorPosition.helper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"anchorPosition.helper.d.ts","sourceRoot":"","sources":["../../src/utils/anchorPosition.helper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAA;AAE7D,yEAAyE;AACzE,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,cAAc;IAC7B,yDAAyD;IACzD,IAAI,EAAE,WAAW,CAAA;IACjB,iEAAiE;IACjE,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,uEAAuE;IACvE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,WAAW,CAAA;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,gDAAgD;IAChD,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,0EAA0E;IAC1E,IAAI,EAAE,WAAW,CAAA;IACjB;;;;;;OAMG;IACH,WAAW,EAAE,MAAM,CAAA;CACpB;AAsDD,wBAAgB,aAAa,CAC3B,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,WAAW,EACrB,EAAE,IAAI,EAAE,GAAO,EAAE,KAAU,EAAE,QAAQ,EAAE,EAAE,cAAc,GACtD,gBAAgB,CAiDlB;AAED,kEAAkE;AAClE,wBAAgB,YAAY,IAAI,WAAW,CAG1C"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TWalkthroughTarget } from '../components/Walkthrough.d';
|
|
2
|
+
/**
|
|
3
|
+
* Attribute written by the `v-nb-tour-step` directive. Steps reference these
|
|
4
|
+
* ids instead of CSS selectors so a refactor of the markup cannot silently
|
|
5
|
+
* break a tour: the id travels with the element.
|
|
6
|
+
*/
|
|
7
|
+
export declare const TOUR_STEP_ATTRIBUTE = "data-nb-tour-step";
|
|
8
|
+
/** Builds the selector for a tour-step id. Exported for host-side assertions. */
|
|
9
|
+
export declare function tourStepSelector(id: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Resolves a step's `target` to a live element, or null when it is not in the
|
|
12
|
+
* DOM right now (a collapsed sidebar, a route not yet visited). Null is a
|
|
13
|
+
* normal outcome, not an error: the walkthrough skips over steps whose target
|
|
14
|
+
* has gone missing rather than stranding the user on a blank spotlight.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveTourTarget(target: TWalkthroughTarget | undefined, root?: Document | HTMLElement): HTMLElement | null;
|
|
17
|
+
/** True when the element is rendered and has a non-zero box worth spotlighting. */
|
|
18
|
+
export declare function isTargetVisible(element: HTMLElement | null): boolean;
|
|
19
|
+
//# sourceMappingURL=tourTarget.helper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tourTarget.helper.d.ts","sourceRoot":"","sources":["../../src/utils/tourTarget.helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAA;AAEpE;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,sBAAsB,CAAA;AAStD,iFAAiF;AACjF,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEnD;AAQD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,kBAAkB,GAAG,SAAS,EACtC,IAAI,GAAE,QAAQ,GAAG,WAEqB,GACrC,WAAW,GAAG,IAAI,CAyBpB;AAED,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,GAAG,OAAO,CAIpE"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IWalkthroughRecord, IWalkthroughStorage } from '../components/Walkthrough.d';
|
|
2
|
+
export declare const WALKTHROUGH_STORAGE_PREFIX = "nb:walkthrough:";
|
|
3
|
+
/**
|
|
4
|
+
* The default adapter: one localStorage entry per walkthrough id.
|
|
5
|
+
*
|
|
6
|
+
* Every access is wrapped, because localStorage throws rather than degrading
|
|
7
|
+
* in two situations a shipped app will meet: Safari in private mode (quota
|
|
8
|
+
* exceeded on write) and a cross-origin iframe with storage blocked (a
|
|
9
|
+
* SecurityError on mere property access). A tour failing to remember itself
|
|
10
|
+
* is a nuisance; a tour taking the host app down with it is not acceptable,
|
|
11
|
+
* so a broken store reads as "never seen" and writes become no-ops.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createLocalStorageWalkthroughStorage(prefix?: string): IWalkthroughStorage;
|
|
14
|
+
/**
|
|
15
|
+
* Non-persistent adapter. Useful in tests, in Storybook-style docs previews
|
|
16
|
+
* where every reload should show the tour again, and as a fallback in
|
|
17
|
+
* environments with no `window`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createMemoryWalkthroughStorage(seed?: Record<string, IWalkthroughRecord>): IWalkthroughStorage;
|
|
20
|
+
/** Picks the persistent adapter when there is a DOM, memory otherwise (SSR). */
|
|
21
|
+
export declare function createDefaultWalkthroughStorage(): IWalkthroughStorage;
|
|
22
|
+
//# sourceMappingURL=walkthroughStorage.helper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"walkthroughStorage.helper.d.ts","sourceRoot":"","sources":["../../src/utils/walkthroughStorage.helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EACpB,MAAM,4BAA4B,CAAA;AAEnC,eAAO,MAAM,0BAA0B,oBAAoB,CAAA;AAU3D;;;;;;;;;GASG;AACH,wBAAgB,oCAAoC,CAClD,MAAM,GAAE,MAAmC,GAC1C,mBAAmB,CA8BrB;AAED;;;;GAIG;AACH,wBAAgB,8BAA8B,CAC5C,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAM,GAC5C,mBAAmB,CAQrB;AAED,gFAAgF;AAChF,wBAAgB,+BAA+B,IAAI,mBAAmB,CAKrE"}
|
package/package.json
CHANGED
package/src/styles/_theme.scss
CHANGED
|
@@ -237,6 +237,25 @@
|
|
|
237
237
|
// Focus ring — used by all interactive controls via :focus-visible
|
|
238
238
|
--nb-c-focus-ring: var(--nb-c-primary);
|
|
239
239
|
|
|
240
|
+
// ── Scrim / overlay ───────────────────────────────────────────────────
|
|
241
|
+
// The dim laid over the page by anything that takes over the viewport:
|
|
242
|
+
// modals, drawers, and the walkthrough spotlight. Derived from
|
|
243
|
+
// --nb-c-black via color-mix so it stays a semantic token (no literal
|
|
244
|
+
// rgba() at the call site) and one override re-tunes every overlay.
|
|
245
|
+
// --nb-c-scrim-strong is the heavier variant for surfaces that must
|
|
246
|
+
// fully suppress the page behind them (coach-mark spotlights).
|
|
247
|
+
--nb-c-scrim: color-mix(in srgb, var(--nb-c-black) 45%, transparent);
|
|
248
|
+
--nb-c-scrim-strong: color-mix(in srgb, var(--nb-c-black) 62%, transparent);
|
|
249
|
+
|
|
250
|
+
// ── Radii ─────────────────────────────────────────────────────────────
|
|
251
|
+
// Corner scale, expressed against the 8px base unit so a change to
|
|
252
|
+
// --nb-base-unit rescales rounding with everything else.
|
|
253
|
+
--nb-radius-xs: calc(var(--nb-base-unit) / 4); // 2px
|
|
254
|
+
--nb-radius-sm: calc(var(--nb-base-unit) / 2); // 4px
|
|
255
|
+
--nb-radius-md: var(--nb-base-unit); // 8px
|
|
256
|
+
--nb-radius-lg: calc(var(--nb-base-unit) * 1.5); // 12px
|
|
257
|
+
--nb-radius-pill: 9999px;
|
|
258
|
+
|
|
240
259
|
--nb-grid-max-width: #{grid.$grid-max-width}px;
|
|
241
260
|
--nb-grid-columns: #{grid.$grid-columns};
|
|
242
261
|
--nb-grid-gutter: #{grid.$grid-gutter}px;
|
|
@@ -494,5 +513,12 @@
|
|
|
494
513
|
--nb-c-component-active: #{colors.get-color('nouveau-gray', 400)};
|
|
495
514
|
--nb-c-component-disabled: #{colors.get-color('nouveau-gray', 700)};
|
|
496
515
|
--nb-c-component-plain-border: #{colors.get-color('nouveau-gray', 600)};
|
|
516
|
+
|
|
517
|
+
// Scrim — a dark page needs a heavier dim to read as "the page behind
|
|
518
|
+
// this is out of play"; 45% over an already-dark background barely
|
|
519
|
+
// registers. Only the scrim tokens change; the radius scale is
|
|
520
|
+
// theme-independent and stays on the base.
|
|
521
|
+
--nb-c-scrim: color-mix(in srgb, var(--nb-c-black) 65%, transparent);
|
|
522
|
+
--nb-c-scrim-strong: color-mix(in srgb, var(--nb-c-black) 80%, transparent);
|
|
497
523
|
}
|
|
498
524
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewport-aware placement for floating surfaces (hint popovers, walkthrough
|
|
3
|
+
* coach-marks) anchored to an element.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately a pure function over plain rectangles rather than a method on a
|
|
6
|
+
* component: placement is the part of a popover that is easy to get subtly
|
|
7
|
+
* wrong and impossible to eyeball in a unit test, so it is kept free of DOM
|
|
8
|
+
* access. Callers pass `getBoundingClientRect()` output and the viewport size;
|
|
9
|
+
* everything else is arithmetic.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type TAnchorSide = 'top' | 'bottom' | 'left' | 'right'
|
|
13
|
+
|
|
14
|
+
/** The subset of DOMRect this module needs. Any DOMRect satisfies it. */
|
|
15
|
+
export interface IAnchorRect {
|
|
16
|
+
top: number
|
|
17
|
+
left: number
|
|
18
|
+
width: number
|
|
19
|
+
height: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IAnchorSize {
|
|
23
|
+
width: number
|
|
24
|
+
height: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IAnchorOptions {
|
|
28
|
+
/** Preferred side. Flipped only when it does not fit. */
|
|
29
|
+
side: TAnchorSide
|
|
30
|
+
/** Distance between the anchor edge and the floating surface. */
|
|
31
|
+
gap?: number
|
|
32
|
+
/** Minimum distance kept between the surface and the viewport edge. */
|
|
33
|
+
inset?: number
|
|
34
|
+
viewport: IAnchorSize
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface IAnchorPlacement {
|
|
38
|
+
/** Viewport (`position: fixed`) coordinates. */
|
|
39
|
+
top: number
|
|
40
|
+
left: number
|
|
41
|
+
/** The side actually used, after any flip. Drives the arrow direction. */
|
|
42
|
+
side: TAnchorSide
|
|
43
|
+
/**
|
|
44
|
+
* Where the anchor's centre sits along the surface's cross axis, as a
|
|
45
|
+
* percentage of the surface's size on that axis. After a viewport clamp the
|
|
46
|
+
* surface and the anchor are no longer centred on each other, so an arrow
|
|
47
|
+
* pinned at a fixed 50% points at nothing; consumers feed this into a CSS
|
|
48
|
+
* custom property to re-aim it.
|
|
49
|
+
*/
|
|
50
|
+
arrowOffset: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Order in which sides are tried when the requested one does not fit: the
|
|
55
|
+
* opposite side first (visually the least surprising fallback), then the
|
|
56
|
+
* orthogonal pair, which is what rescues anchors sitting in a viewport corner
|
|
57
|
+
* where both sides of the requested axis overflow.
|
|
58
|
+
*/
|
|
59
|
+
const FALLBACK_ORDER: Record<TAnchorSide, readonly TAnchorSide[]> = {
|
|
60
|
+
top: ['bottom', 'right', 'left'],
|
|
61
|
+
bottom: ['top', 'right', 'left'],
|
|
62
|
+
left: ['right', 'bottom', 'top'],
|
|
63
|
+
right: ['left', 'bottom', 'top'],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ARROW_MARGIN = 10
|
|
67
|
+
|
|
68
|
+
function placeFor(
|
|
69
|
+
side: TAnchorSide,
|
|
70
|
+
anchor: IAnchorRect,
|
|
71
|
+
floating: IAnchorSize,
|
|
72
|
+
gap: number,
|
|
73
|
+
): { top: number; left: number } {
|
|
74
|
+
const centerX = anchor.left + anchor.width / 2 - floating.width / 2
|
|
75
|
+
const centerY = anchor.top + anchor.height / 2 - floating.height / 2
|
|
76
|
+
|
|
77
|
+
switch (side) {
|
|
78
|
+
case 'top':
|
|
79
|
+
return { top: anchor.top - floating.height - gap, left: centerX }
|
|
80
|
+
case 'bottom':
|
|
81
|
+
return { top: anchor.top + anchor.height + gap, left: centerX }
|
|
82
|
+
case 'left':
|
|
83
|
+
return { top: centerY, left: anchor.left - floating.width - gap }
|
|
84
|
+
case 'right':
|
|
85
|
+
default:
|
|
86
|
+
return { top: centerY, left: anchor.left + anchor.width + gap }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fits(
|
|
91
|
+
top: number,
|
|
92
|
+
left: number,
|
|
93
|
+
floating: IAnchorSize,
|
|
94
|
+
inset: number,
|
|
95
|
+
viewport: IAnchorSize,
|
|
96
|
+
): boolean {
|
|
97
|
+
return (
|
|
98
|
+
left >= inset &&
|
|
99
|
+
top >= inset &&
|
|
100
|
+
left + floating.width <= viewport.width - inset &&
|
|
101
|
+
top + floating.height <= viewport.height - inset
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function placeAnchored(
|
|
106
|
+
anchor: IAnchorRect,
|
|
107
|
+
floating: IAnchorSize,
|
|
108
|
+
{ side, gap = 8, inset = 12, viewport }: IAnchorOptions,
|
|
109
|
+
): IAnchorPlacement {
|
|
110
|
+
let chosen = side
|
|
111
|
+
let placed = placeFor(side, anchor, floating, gap)
|
|
112
|
+
|
|
113
|
+
// Zero-size floating surfaces show up on the first frame, before layout has
|
|
114
|
+
// measured them. Flipping on those numbers would pick a side at random, so
|
|
115
|
+
// the preferred side is kept until a real measurement arrives.
|
|
116
|
+
if (
|
|
117
|
+
floating.width > 0 &&
|
|
118
|
+
floating.height > 0 &&
|
|
119
|
+
!fits(placed.top, placed.left, floating, inset, viewport)
|
|
120
|
+
) {
|
|
121
|
+
for (const candidate of FALLBACK_ORDER[side]) {
|
|
122
|
+
const next = placeFor(candidate, anchor, floating, gap)
|
|
123
|
+
if (fits(next.top, next.left, floating, inset, viewport)) {
|
|
124
|
+
chosen = candidate
|
|
125
|
+
placed = next
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Backstop for the case where no side fits at all (tiny viewport, long
|
|
132
|
+
// body copy): keep the surface onscreen even if it now overlaps the anchor.
|
|
133
|
+
let { top, left } = placed
|
|
134
|
+
if (floating.width > 0 && floating.height > 0) {
|
|
135
|
+
left = Math.min(
|
|
136
|
+
Math.max(left, inset),
|
|
137
|
+
Math.max(inset, viewport.width - floating.width - inset),
|
|
138
|
+
)
|
|
139
|
+
top = Math.min(
|
|
140
|
+
Math.max(top, inset),
|
|
141
|
+
Math.max(inset, viewport.height - floating.height - inset),
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const horizontal = chosen === 'top' || chosen === 'bottom'
|
|
146
|
+
const extent = horizontal ? floating.width : floating.height
|
|
147
|
+
const anchorCenter = horizontal
|
|
148
|
+
? anchor.left + anchor.width / 2 - left
|
|
149
|
+
: anchor.top + anchor.height / 2 - top
|
|
150
|
+
const arrowOffset =
|
|
151
|
+
extent > 0
|
|
152
|
+
? (Math.min(Math.max(anchorCenter, ARROW_MARGIN), extent - ARROW_MARGIN) /
|
|
153
|
+
extent) *
|
|
154
|
+
100
|
|
155
|
+
: 50
|
|
156
|
+
|
|
157
|
+
return { top, left, side: chosen, arrowOffset }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Live viewport size, guarded for SSR / non-DOM environments. */
|
|
161
|
+
export function viewportSize(): IAnchorSize {
|
|
162
|
+
if (typeof window === 'undefined') return { width: 0, height: 0 }
|
|
163
|
+
return { width: window.innerWidth, height: window.innerHeight }
|
|
164
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { TWalkthroughTarget } from '@/components/Walkthrough.d'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Attribute written by the `v-nb-tour-step` directive. Steps reference these
|
|
5
|
+
* ids instead of CSS selectors so a refactor of the markup cannot silently
|
|
6
|
+
* break a tour: the id travels with the element.
|
|
7
|
+
*/
|
|
8
|
+
export const TOUR_STEP_ATTRIBUTE = 'data-nb-tour-step'
|
|
9
|
+
|
|
10
|
+
function escapeAttributeValue(value: string): string {
|
|
11
|
+
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
12
|
+
return CSS.escape(value)
|
|
13
|
+
}
|
|
14
|
+
return value.replace(/["\\]/g, '\\$&')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Builds the selector for a tour-step id. Exported for host-side assertions. */
|
|
18
|
+
export function tourStepSelector(id: string): string {
|
|
19
|
+
return `[${TOUR_STEP_ATTRIBUTE}="${escapeAttributeValue(id)}"]`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRefLike(
|
|
23
|
+
value: unknown,
|
|
24
|
+
): value is { value: HTMLElement | null | undefined } {
|
|
25
|
+
return !!value && typeof value === 'object' && 'value' in value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolves a step's `target` to a live element, or null when it is not in the
|
|
30
|
+
* DOM right now (a collapsed sidebar, a route not yet visited). Null is a
|
|
31
|
+
* normal outcome, not an error: the walkthrough skips over steps whose target
|
|
32
|
+
* has gone missing rather than stranding the user on a blank spotlight.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveTourTarget(
|
|
35
|
+
target: TWalkthroughTarget | undefined,
|
|
36
|
+
root: Document | HTMLElement = typeof document !== 'undefined'
|
|
37
|
+
? document
|
|
38
|
+
: (undefined as unknown as Document),
|
|
39
|
+
): HTMLElement | null {
|
|
40
|
+
if (!target || !root) return null
|
|
41
|
+
|
|
42
|
+
if (typeof target === 'function') return target() ?? null
|
|
43
|
+
if (typeof target === 'object') {
|
|
44
|
+
if (target instanceof HTMLElement) return target
|
|
45
|
+
if (isRefLike(target)) {
|
|
46
|
+
const value = target.value
|
|
47
|
+
return value instanceof HTMLElement ? value : null
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Tour-step id first, CSS selector second. Trying the id form even for
|
|
53
|
+
// strings that look like selectors costs one failed query and means an id
|
|
54
|
+
// like `nav` is never shadowed by the `<nav>` element.
|
|
55
|
+
const byId = root.querySelector<HTMLElement>(tourStepSelector(target))
|
|
56
|
+
if (byId) return byId
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return root.querySelector<HTMLElement>(target)
|
|
60
|
+
} catch {
|
|
61
|
+
// Not a valid selector either (a bare id containing spaces, say).
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True when the element is rendered and has a non-zero box worth spotlighting. */
|
|
67
|
+
export function isTargetVisible(element: HTMLElement | null): boolean {
|
|
68
|
+
if (!element || !element.isConnected) return false
|
|
69
|
+
const rect = element.getBoundingClientRect()
|
|
70
|
+
return rect.width > 0 && rect.height > 0
|
|
71
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IWalkthroughRecord,
|
|
3
|
+
IWalkthroughStorage,
|
|
4
|
+
} from '@/components/Walkthrough.d'
|
|
5
|
+
|
|
6
|
+
export const WALKTHROUGH_STORAGE_PREFIX = 'nb:walkthrough:'
|
|
7
|
+
|
|
8
|
+
function isRecord(value: unknown): value is IWalkthroughRecord {
|
|
9
|
+
return (
|
|
10
|
+
!!value &&
|
|
11
|
+
typeof value === 'object' &&
|
|
12
|
+
typeof (value as IWalkthroughRecord).version === 'number'
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The default adapter: one localStorage entry per walkthrough id.
|
|
18
|
+
*
|
|
19
|
+
* Every access is wrapped, because localStorage throws rather than degrading
|
|
20
|
+
* in two situations a shipped app will meet: Safari in private mode (quota
|
|
21
|
+
* exceeded on write) and a cross-origin iframe with storage blocked (a
|
|
22
|
+
* SecurityError on mere property access). A tour failing to remember itself
|
|
23
|
+
* is a nuisance; a tour taking the host app down with it is not acceptable,
|
|
24
|
+
* so a broken store reads as "never seen" and writes become no-ops.
|
|
25
|
+
*/
|
|
26
|
+
export function createLocalStorageWalkthroughStorage(
|
|
27
|
+
prefix: string = WALKTHROUGH_STORAGE_PREFIX,
|
|
28
|
+
): IWalkthroughStorage {
|
|
29
|
+
const key = (id: string) => `${prefix}${id}`
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
get(id) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = window.localStorage.getItem(key(id))
|
|
35
|
+
if (!raw) return null
|
|
36
|
+
const parsed = JSON.parse(raw)
|
|
37
|
+
return isRecord(parsed) ? parsed : null
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
set(id, record) {
|
|
43
|
+
try {
|
|
44
|
+
window.localStorage.setItem(key(id), JSON.stringify(record))
|
|
45
|
+
} catch {
|
|
46
|
+
// Storage unavailable or full: the tour still runs, it just will not
|
|
47
|
+
// be remembered across reloads.
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
remove(id) {
|
|
51
|
+
try {
|
|
52
|
+
window.localStorage.removeItem(key(id))
|
|
53
|
+
} catch {
|
|
54
|
+
// See above.
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Non-persistent adapter. Useful in tests, in Storybook-style docs previews
|
|
62
|
+
* where every reload should show the tour again, and as a fallback in
|
|
63
|
+
* environments with no `window`.
|
|
64
|
+
*/
|
|
65
|
+
export function createMemoryWalkthroughStorage(
|
|
66
|
+
seed: Record<string, IWalkthroughRecord> = {},
|
|
67
|
+
): IWalkthroughStorage {
|
|
68
|
+
const store = new Map<string, IWalkthroughRecord>(Object.entries(seed))
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
get: (id) => store.get(id) ?? null,
|
|
72
|
+
set: (id, record) => void store.set(id, record),
|
|
73
|
+
remove: (id) => void store.delete(id),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Picks the persistent adapter when there is a DOM, memory otherwise (SSR). */
|
|
78
|
+
export function createDefaultWalkthroughStorage(): IWalkthroughStorage {
|
|
79
|
+
if (typeof window === 'undefined' || !('localStorage' in window)) {
|
|
80
|
+
return createMemoryWalkthroughStorage()
|
|
81
|
+
}
|
|
82
|
+
return createLocalStorageWalkthroughStorage()
|
|
83
|
+
}
|