@matthesketh/react-guidetour 1.0.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,277 @@
1
+ import scroll from 'scroll';
2
+ import scrollParent from 'scrollparent';
3
+
4
+ export function canUseDOM() {
5
+ return !!(typeof window !== 'undefined' && window.document?.createElement);
6
+ }
7
+
8
+ /**
9
+ * Find the bounding client rect
10
+ */
11
+ export function getClientRect(element: HTMLElement | null) {
12
+ if (!element) {
13
+ return null;
14
+ }
15
+
16
+ return element.getBoundingClientRect();
17
+ }
18
+
19
+ /**
20
+ * Helper function to get the browser-normalized "document height"
21
+ */
22
+ export function getDocumentHeight(median = false): number {
23
+ const { body, documentElement } = document;
24
+
25
+ if (!body || !documentElement) {
26
+ return 0;
27
+ }
28
+
29
+ if (median) {
30
+ const heights = [
31
+ body.scrollHeight,
32
+ body.offsetHeight,
33
+ documentElement.clientHeight,
34
+ documentElement.scrollHeight,
35
+ documentElement.offsetHeight,
36
+ ].sort((a, b) => a - b);
37
+ const middle = Math.floor(heights.length / 2);
38
+
39
+ if (heights.length % 2 === 0) {
40
+ return (heights[middle - 1] + heights[middle]) / 2;
41
+ }
42
+
43
+ return heights[middle];
44
+ }
45
+
46
+ return Math.max(
47
+ body.scrollHeight,
48
+ body.offsetHeight,
49
+ documentElement.clientHeight,
50
+ documentElement.scrollHeight,
51
+ documentElement.offsetHeight,
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Find and return the target DOM element based on a step's 'target'.
57
+ */
58
+ export function getElement(element: string | HTMLElement): HTMLElement | null {
59
+ if (typeof element === 'string') {
60
+ try {
61
+ return document.querySelector(element);
62
+ } catch (error: any) {
63
+ if (process.env.NODE_ENV !== 'production') {
64
+ // eslint-disable-next-line no-console
65
+ console.error(error);
66
+ }
67
+
68
+ return null;
69
+ }
70
+ }
71
+
72
+ return element;
73
+ }
74
+
75
+ /**
76
+ * Get computed style property
77
+ */
78
+ export function getStyleComputedProperty(el: HTMLElement): CSSStyleDeclaration | null {
79
+ if (!el || el.nodeType !== 1) {
80
+ return null;
81
+ }
82
+
83
+ return getComputedStyle(el);
84
+ }
85
+
86
+ /**
87
+ * Get scroll parent with fix
88
+ */
89
+ export function getScrollParent(
90
+ element: HTMLElement | null,
91
+ skipFix: boolean,
92
+ forListener?: boolean,
93
+ ) {
94
+ if (!element) {
95
+ return scrollDocument();
96
+ }
97
+
98
+ const parent = scrollParent(element) as HTMLElement;
99
+
100
+ if (parent) {
101
+ if (parent.isSameNode(scrollDocument())) {
102
+ if (forListener) {
103
+ return document;
104
+ }
105
+
106
+ return scrollDocument();
107
+ }
108
+
109
+ const hasScrolling = parent.scrollHeight > parent.offsetHeight;
110
+
111
+ if (!hasScrolling && !skipFix) {
112
+ parent.style.overflow = 'initial';
113
+
114
+ return scrollDocument();
115
+ }
116
+ }
117
+
118
+ return parent;
119
+ }
120
+
121
+ /**
122
+ * Check if the element has custom scroll parent
123
+ */
124
+ export function hasCustomScrollParent(element: HTMLElement | null, skipFix: boolean): boolean {
125
+ if (!element) {
126
+ return false;
127
+ }
128
+
129
+ const parent = getScrollParent(element, skipFix);
130
+
131
+ return parent ? !parent.isSameNode(scrollDocument()) : false;
132
+ }
133
+
134
+ /**
135
+ * Check if the element has custom offset parent
136
+ */
137
+ export function hasCustomOffsetParent(element: HTMLElement): boolean {
138
+ return element.offsetParent !== document.body;
139
+ }
140
+
141
+ /**
142
+ * Check if an element has fixed/sticky position
143
+ */
144
+ export function hasPosition(el: HTMLElement | Node | null, type: string = 'fixed'): boolean {
145
+ if (!el || !(el instanceof HTMLElement)) {
146
+ return false;
147
+ }
148
+
149
+ const { nodeName } = el;
150
+ const styles = getStyleComputedProperty(el);
151
+
152
+ if (nodeName === 'BODY' || nodeName === 'HTML') {
153
+ return false;
154
+ }
155
+
156
+ if (styles && styles.position === type) {
157
+ return true;
158
+ }
159
+
160
+ if (!el.parentNode) {
161
+ return false;
162
+ }
163
+
164
+ return hasPosition(el.parentNode, type);
165
+ }
166
+
167
+ /**
168
+ * Check if the element is visible
169
+ */
170
+ export function isElementVisible(element: HTMLElement): element is HTMLElement {
171
+ if (!element) {
172
+ return false;
173
+ }
174
+
175
+ let parentElement: HTMLElement | null = element;
176
+
177
+ while (parentElement) {
178
+ if (parentElement === document.body) {
179
+ break;
180
+ }
181
+
182
+ if (parentElement instanceof HTMLElement) {
183
+ const { display, visibility } = getComputedStyle(parentElement);
184
+
185
+ if (display === 'none' || visibility === 'hidden') {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ parentElement = parentElement.parentElement ?? null;
191
+ }
192
+
193
+ return true;
194
+ }
195
+
196
+ /**
197
+ * Find and return the target DOM element based on a step's 'target'.
198
+ */
199
+ export function getElementPosition(
200
+ element: HTMLElement | null,
201
+ offset: number,
202
+ skipFix: boolean,
203
+ ): number {
204
+ const elementRect = getClientRect(element);
205
+ const parent = getScrollParent(element, skipFix);
206
+ const hasScrollParent = hasCustomScrollParent(element, skipFix);
207
+ const isFixedTarget = hasPosition(element);
208
+ let parentTop = 0;
209
+ let top = elementRect?.top ?? 0;
210
+
211
+ if (hasScrollParent && isFixedTarget) {
212
+ const offsetTop = element?.offsetTop ?? 0;
213
+ const parentScrollTop = (parent as HTMLElement)?.scrollTop ?? 0;
214
+
215
+ top = offsetTop - parentScrollTop;
216
+ } else if (parent instanceof HTMLElement) {
217
+ parentTop = parent.scrollTop;
218
+
219
+ if (!hasScrollParent && !hasPosition(element)) {
220
+ top += parentTop;
221
+ }
222
+
223
+ if (!parent.isSameNode(scrollDocument())) {
224
+ top += scrollDocument().scrollTop;
225
+ }
226
+ }
227
+
228
+ return Math.floor(top - offset);
229
+ }
230
+
231
+ /**
232
+ * Get the scrollTop position
233
+ */
234
+ export function getScrollTo(element: HTMLElement | null, offset: number, skipFix: boolean): number {
235
+ if (!element) {
236
+ return 0;
237
+ }
238
+
239
+ const { offsetTop = 0, scrollTop = 0 } = scrollParent(element) ?? {};
240
+ let top = element.getBoundingClientRect().top + scrollTop;
241
+
242
+ if (!!offsetTop && (hasCustomScrollParent(element, skipFix) || hasCustomOffsetParent(element))) {
243
+ top -= offsetTop;
244
+ }
245
+
246
+ const output = Math.floor(top - offset);
247
+
248
+ return output < 0 ? 0 : output;
249
+ }
250
+
251
+ export function scrollDocument(): Element | HTMLElement {
252
+ return document.scrollingElement ?? document.documentElement;
253
+ }
254
+
255
+ /**
256
+ * Scroll to position
257
+ */
258
+ export function scrollTo(
259
+ value: number,
260
+ options: { duration?: number; element: Element | HTMLElement },
261
+ ): Promise<void> {
262
+ const { duration, element } = options;
263
+
264
+ return new Promise((resolve, reject) => {
265
+ const { scrollTop } = element;
266
+
267
+ const limit = value > scrollTop ? value - scrollTop : scrollTop - value;
268
+
269
+ scroll.top(element as HTMLElement, value, { duration: limit < 100 ? 50 : duration }, error => {
270
+ if (error && error.message !== 'Element already at target scroll position') {
271
+ return reject(error);
272
+ }
273
+
274
+ return resolve();
275
+ });
276
+ });
277
+ }
@@ -0,0 +1,268 @@
1
+ import { cloneElement, FC, isValidElement, ReactElement, ReactNode } from 'react';
2
+ import innerText from 'react-innertext';
3
+ import is from 'is-lite';
4
+
5
+ import { LIFECYCLE } from '~/literals';
6
+
7
+ import { AnyObject, Lifecycle, NarrowPlainObject, Step } from '~/types';
8
+
9
+ import { hasPosition } from './dom';
10
+
11
+ interface GetReactNodeTextOptions {
12
+ defaultValue?: any;
13
+ step?: number;
14
+ steps?: number;
15
+ }
16
+
17
+ interface LogOptions {
18
+ /** The data to be logged */
19
+ data: any;
20
+ /** display the log */
21
+ debug?: boolean;
22
+ /** The title the logger was called from */
23
+ title: string;
24
+ /** If true, the message will be a warning */
25
+ warn?: boolean;
26
+ }
27
+
28
+ interface ShouldScrollOptions {
29
+ isFirstStep: boolean;
30
+ lifecycle: Lifecycle;
31
+ previousLifecycle: Lifecycle;
32
+ scrollToFirstStep: boolean;
33
+ step: Step;
34
+ target: HTMLElement | null;
35
+ }
36
+
37
+ /**
38
+ * Detect Safari browser (used for mix-blend-mode workaround)
39
+ */
40
+ export function isSafari(): boolean {
41
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
42
+ return false;
43
+ }
44
+
45
+ return /(Version\/([\d._]+).*Safari|CriOS|FxiOS| Mobile\/)/.test(navigator.userAgent);
46
+ }
47
+
48
+ /**
49
+ * Get Object type
50
+ */
51
+ export function getObjectType(value: unknown): string {
52
+ return Object.prototype.toString.call(value).slice(8, -1).toLowerCase();
53
+ }
54
+
55
+ export function getReactNodeText(input: ReactNode, options: GetReactNodeTextOptions = {}): string {
56
+ const { defaultValue, step, steps } = options;
57
+ let text = innerText(input);
58
+
59
+ if (!text) {
60
+ if (
61
+ isValidElement(input) &&
62
+ !Object.values((input as any).props).length &&
63
+ getObjectType(input.type) === 'function'
64
+ ) {
65
+ const component = (input.type as FC)({});
66
+
67
+ text = getReactNodeText(component as ReactNode, options);
68
+ } else {
69
+ text = innerText(defaultValue);
70
+ }
71
+ } else if ((text.includes('{step}') || text.includes('{steps}')) && step && steps) {
72
+ text = text.replace('{step}', step.toString()).replace('{steps}', steps.toString());
73
+ }
74
+
75
+ return text;
76
+ }
77
+
78
+ export function hasValidKeys(object: Record<string, unknown>, keys?: Array<string>): boolean {
79
+ if (!is.plainObject(object) || !is.array(keys)) {
80
+ return false;
81
+ }
82
+
83
+ return Object.keys(object).every(d => keys.includes(d));
84
+ }
85
+
86
+ /**
87
+ * Convert hex to RGB
88
+ */
89
+ export function hexToRGB(hex: string): Array<number> {
90
+ const shorthandRegex = /^#?([\da-f])([\da-f])([\da-f])$/i;
91
+ const properHex = hex.replace(shorthandRegex, (_m, r, g, b) => r + r + g + g + b + b);
92
+
93
+ const result = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(properHex);
94
+
95
+ return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : [];
96
+ }
97
+
98
+ /**
99
+ * Decide if the step shouldn't skip the beacon
100
+ */
101
+ export function hideBeacon(step: Step): boolean {
102
+ return step.disableBeacon || step.placement === 'center';
103
+ }
104
+
105
+ /**
106
+ * Log method calls if debug is enabled
107
+ */
108
+ export function log({ data, debug = false, title, warn = false }: LogOptions) {
109
+ /* eslint-disable no-console */
110
+ const logFn = warn ? console.warn || console.error : console.log;
111
+
112
+ if (debug) {
113
+ if (title && data) {
114
+ console.groupCollapsed(
115
+ `%creact-guidetour: ${title}`,
116
+ 'color: #ff0044; font-weight: bold; font-size: 12px;',
117
+ );
118
+
119
+ if (Array.isArray(data)) {
120
+ data.forEach(d => {
121
+ if (is.plainObject(d) && d.key) {
122
+ logFn.apply(console, [d.key, d.value]);
123
+ } else {
124
+ logFn.apply(console, [d]);
125
+ }
126
+ });
127
+ } else {
128
+ logFn.apply(console, [data]);
129
+ }
130
+
131
+ console.groupEnd();
132
+ } else {
133
+ console.error('Missing title or data props');
134
+ }
135
+ }
136
+ /* eslint-enable */
137
+ }
138
+
139
+ /**
140
+ * A function that does nothing.
141
+ */
142
+ export function noop() {
143
+ return undefined;
144
+ }
145
+
146
+ /**
147
+ * Type-safe Object.keys()
148
+ */
149
+ export function objectKeys<T extends AnyObject>(input: T) {
150
+ return Object.keys(input) as Array<keyof T>;
151
+ }
152
+
153
+ /**
154
+ * Remove properties from an object
155
+ */
156
+ export function omit<T extends Record<string, any>, K extends keyof T>(
157
+ input: NarrowPlainObject<T>,
158
+ ...filter: K[]
159
+ ) {
160
+ if (!is.plainObject(input)) {
161
+ throw new TypeError('Expected an object');
162
+ }
163
+
164
+ const output: any = {};
165
+
166
+ for (const key in input) {
167
+ /* istanbul ignore else */
168
+ if ({}.hasOwnProperty.call(input, key)) {
169
+ if (!filter.includes(key as unknown as K)) {
170
+ output[key] = input[key];
171
+ }
172
+ }
173
+ }
174
+
175
+ return output as Omit<T, K>;
176
+ }
177
+
178
+ /**
179
+ * Select properties from an object
180
+ */
181
+ export function pick<T extends Record<string, any>, K extends keyof T>(
182
+ input: NarrowPlainObject<T>,
183
+ ...filter: K[]
184
+ ) {
185
+ if (!is.plainObject(input)) {
186
+ throw new TypeError('Expected an object');
187
+ }
188
+
189
+ if (!filter.length) {
190
+ return input;
191
+ }
192
+
193
+ const output: any = {};
194
+
195
+ for (const key in input) {
196
+ /* istanbul ignore else */
197
+ if ({}.hasOwnProperty.call(input, key)) {
198
+ if (filter.includes(key as unknown as K)) {
199
+ output[key] = input[key];
200
+ }
201
+ }
202
+ }
203
+
204
+ return output as Pick<T, K>;
205
+ }
206
+
207
+ export function replaceLocaleContent(input: ReactNode, step: number, steps: number): ReactNode {
208
+ const replacer = (text: string) =>
209
+ text.replace('{step}', String(step)).replace('{steps}', String(steps));
210
+
211
+ if (getObjectType(input) === 'string') {
212
+ return replacer(input as string);
213
+ }
214
+
215
+ if (!isValidElement(input)) {
216
+ return input;
217
+ }
218
+
219
+ const { children } = (input as any).props;
220
+
221
+ if (getObjectType(children) === 'string' && children.includes('{step}')) {
222
+ return cloneElement(input as ReactElement<any>, {
223
+ children: replacer(children),
224
+ });
225
+ }
226
+
227
+ if (Array.isArray(children)) {
228
+ return cloneElement(input as ReactElement<any>, {
229
+ children: children.map((child: ReactNode) => {
230
+ if (typeof child === 'string') {
231
+ return replacer(child);
232
+ }
233
+
234
+ return replaceLocaleContent(child, step, steps);
235
+ }),
236
+ });
237
+ }
238
+
239
+ if (getObjectType(input.type) === 'function' && !Object.values((input as any).props).length) {
240
+ const component = (input.type as FC)({});
241
+
242
+ return replaceLocaleContent(component as ReactNode, step, steps);
243
+ }
244
+
245
+ return input;
246
+ }
247
+
248
+ export function shouldScroll(options: ShouldScrollOptions): boolean {
249
+ const { isFirstStep, lifecycle, previousLifecycle, scrollToFirstStep, step, target } = options;
250
+
251
+ return (
252
+ !step.disableScrolling &&
253
+ (!isFirstStep || scrollToFirstStep || lifecycle === LIFECYCLE.TOOLTIP) &&
254
+ step.placement !== 'center' &&
255
+ (!step.isFixed || !hasPosition(target)) && // fixed steps don't need to scroll
256
+ previousLifecycle !== lifecycle &&
257
+ ([LIFECYCLE.BEACON, LIFECYCLE.TOOLTIP] as Array<Lifecycle>).includes(lifecycle)
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Block execution
263
+ */
264
+ export function sleep(seconds = 1) {
265
+ return new Promise(resolve => {
266
+ setTimeout(resolve, seconds * 1000);
267
+ });
268
+ }
@@ -0,0 +1,136 @@
1
+ interface ScopeOptions {
2
+ code?: string;
3
+ selector: string | null;
4
+ }
5
+
6
+ export default class Scope {
7
+ element: HTMLElement;
8
+ options: ScopeOptions;
9
+
10
+ constructor(element: HTMLElement, options: ScopeOptions) {
11
+ if (!(element instanceof HTMLElement)) {
12
+ throw new TypeError('Invalid parameter: element must be an HTMLElement');
13
+ }
14
+
15
+ this.element = element;
16
+ this.options = options;
17
+
18
+ window.addEventListener('keydown', this.handleKeyDown, false);
19
+
20
+ this.setFocus();
21
+ }
22
+
23
+ canBeTabbed = (element: HTMLElement): boolean => {
24
+ const { tabIndex } = element;
25
+
26
+ if (tabIndex === null || tabIndex < 0) {
27
+ return false;
28
+ }
29
+
30
+ return this.canHaveFocus(element);
31
+ };
32
+
33
+ canHaveFocus = (element: HTMLElement): boolean => {
34
+ const validTabNodes = /input|select|textarea|button|object/;
35
+ const nodeName = element.nodeName.toLowerCase();
36
+
37
+ const isValid =
38
+ (validTabNodes.test(nodeName) && !element.getAttribute('disabled')) ||
39
+ (nodeName === 'a' && !!element.getAttribute('href'));
40
+
41
+ return isValid && this.isVisible(element);
42
+ };
43
+
44
+ findValidTabElements = (): Array<HTMLElement> =>
45
+ [].slice.call(this.element.querySelectorAll('*'), 0).filter(this.canBeTabbed);
46
+
47
+ handleKeyDown = (event: KeyboardEvent) => {
48
+ const { code = 'Tab' } = this.options;
49
+
50
+ if (event.code === code) {
51
+ this.interceptTab(event);
52
+ }
53
+ };
54
+
55
+ interceptTab = (event: KeyboardEvent) => {
56
+ event.preventDefault();
57
+ const elements = this.findValidTabElements();
58
+ const { shiftKey } = event;
59
+
60
+ if (!elements.length) {
61
+ return;
62
+ }
63
+
64
+ let x = document.activeElement ? elements.indexOf(document.activeElement as HTMLElement) : 0;
65
+
66
+ if (x === -1 || (!shiftKey && x + 1 === elements.length)) {
67
+ x = 0;
68
+ } else if (shiftKey && x === 0) {
69
+ x = elements.length - 1;
70
+ } else {
71
+ x += shiftKey ? -1 : 1;
72
+ }
73
+
74
+ elements[x].focus();
75
+ };
76
+
77
+ // eslint-disable-next-line class-methods-use-this
78
+ isHidden = (element: HTMLElement) => {
79
+ const noSize = element.offsetWidth <= 0 && element.offsetHeight <= 0;
80
+ const style = window.getComputedStyle(element);
81
+
82
+ if (noSize && !element.innerHTML) {
83
+ return true;
84
+ }
85
+
86
+ return (
87
+ (noSize && style.getPropertyValue('overflow') !== 'visible') ||
88
+ style.getPropertyValue('display') === 'none'
89
+ );
90
+ };
91
+
92
+ isVisible = (element: HTMLElement): boolean => {
93
+ let parentElement: HTMLElement | null = element;
94
+
95
+ while (parentElement) {
96
+ if (parentElement instanceof HTMLElement) {
97
+ if (parentElement === document.body) {
98
+ break;
99
+ }
100
+
101
+ if (this.isHidden(parentElement)) {
102
+ return false;
103
+ }
104
+
105
+ parentElement = parentElement.parentNode as HTMLElement;
106
+ }
107
+ }
108
+
109
+ return true;
110
+ };
111
+
112
+ removeScope = () => {
113
+ window.removeEventListener('keydown', this.handleKeyDown);
114
+ };
115
+
116
+ checkFocus = (target: HTMLElement) => {
117
+ if (document.activeElement !== target) {
118
+ target.focus();
119
+ window.requestAnimationFrame(() => this.checkFocus(target));
120
+ }
121
+ };
122
+
123
+ setFocus = () => {
124
+ const { selector } = this.options;
125
+
126
+ if (!selector) {
127
+ return;
128
+ }
129
+
130
+ const target = this.element.querySelector(selector);
131
+
132
+ if (target) {
133
+ window.requestAnimationFrame(() => this.checkFocus(target as HTMLElement));
134
+ }
135
+ };
136
+ }