@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.
Files changed (32) hide show
  1. package/README.md +7 -0
  2. package/dist/components/InfoHint.d.ts +47 -0
  3. package/dist/components/InfoHint.vue.d.ts +41 -0
  4. package/dist/components/InfoHint.vue.d.ts.map +1 -0
  5. package/dist/components/UserMenu.vue.d.ts +1 -1
  6. package/dist/components/Walkthrough.d.ts +156 -0
  7. package/dist/components/Walkthrough.vue.d.ts +66 -0
  8. package/dist/components/Walkthrough.vue.d.ts.map +1 -0
  9. package/dist/components/index.d.ts.map +1 -1
  10. package/dist/composables/useReducedMotion.composable.d.ts +13 -0
  11. package/dist/composables/useReducedMotion.composable.d.ts.map +1 -0
  12. package/dist/composables/useWalkthrough.composable.d.ts +19 -0
  13. package/dist/composables/useWalkthrough.composable.d.ts.map +1 -0
  14. package/dist/directives/TourStep.directive.d.ts +4 -0
  15. package/dist/directives/TourStep.directive.d.ts.map +1 -0
  16. package/dist/global.d.ts +4 -0
  17. package/dist/index.cjs +9 -9
  18. package/dist/index.mjs +10003 -9261
  19. package/dist/main.d.ts +9 -0
  20. package/dist/main.d.ts.map +1 -1
  21. package/dist/ui.css +1 -1
  22. package/dist/utils/anchorPosition.helper.d.ts +50 -0
  23. package/dist/utils/anchorPosition.helper.d.ts.map +1 -0
  24. package/dist/utils/tourTarget.helper.d.ts +19 -0
  25. package/dist/utils/tourTarget.helper.d.ts.map +1 -0
  26. package/dist/utils/walkthroughStorage.helper.d.ts +22 -0
  27. package/dist/utils/walkthroughStorage.helper.d.ts.map +1 -0
  28. package/package.json +1 -1
  29. package/src/styles/_theme.scss +26 -0
  30. package/src/utils/anchorPosition.helper.ts +164 -0
  31. package/src/utils/tourTarget.helper.ts +71 -0
  32. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nubisco/ui",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Vue 3 UI component library",
5
5
  "packageManager": "pnpm@11.0.8",
6
6
  "repository": {
@@ -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
+ }