PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / HelpCenter / components / tours / GuidedTour.jsx

GuidedTour.jsx in Extendify 3.2.1, at src/HelpCenter/components/tours/GuidedTour.jsx

510 lines 15.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { Dialog } from '@headlessui/react';
2 import { useGlobalSyncStore } from '@help-center/state/globals-sync';
3 import { useTourStore } from '@help-center/state/tours';
4 import tours from '@help-center/tours/tours';
5 import { Button, Spinner } from '@wordpress/components';
6 import {
7 useCallback,
8 useEffect,
9 useLayoutEffect,
10 useMemo,
11 useRef,
12 useState,
13 } from '@wordpress/element';
14 import { __, isRTL, sprintf } from '@wordpress/i18n';
15 import { close, Icon } from '@wordpress/icons';
16 import classNames from 'classnames';
17 import { AnimatePresence, motion } from 'framer-motion';
18
19 const getBoundingClientRect = (element) => {
20 const { top, right, bottom, left, width, height, x, y } =
21 element.getBoundingClientRect();
22 return { top, right, bottom, left, width, height, x, y };
23 };
24
25 export const GuidedTour = () => {
26 const tourBoxRef = useRef();
27 const {
28 currentTour,
29 currentStep,
30 startTour,
31 closeCurrentTour,
32 getStepData,
33 onTourPage,
34 } = useTourStore();
35 const { settings } = currentTour || {};
36 const { image, title, text, attachTo, events, options } =
37 getStepData(currentStep);
38
39 const { queueTourForRedirect, queuedTour, clearQueuedTour } =
40 useGlobalSyncStore();
41 const { element, frame, offset, position, hook, boxPadding } = attachTo || {};
42
43 const elementSelector = useMemo(
44 () => (typeof element === 'function' ? element() : element),
45 [element],
46 );
47
48 const frameSelector = useMemo(
49 () => (typeof frame === 'function' ? frame() : frame),
50 [frame],
51 );
52
53 const offsetNormalized = useMemo(
54 () => (typeof offset === 'function' ? offset() : offset),
55 [offset],
56 );
57 const hookNormalized = useMemo(
58 () => (typeof hook === 'function' ? hook() : hook),
59 [hook],
60 );
61
62 const initialFocus = useRef();
63 const finishedStepOne = useRef(false);
64 const [targetedElement, setTargetedElement] = useState(null);
65 const [redirecting, setRedirecting] = useState(false);
66 const [visible, setVisible] = useState(false);
67 const [overlayRect, setOverlayRect] = useState(null);
68 const [placement, setPlacement] = useState({
69 x: undefined,
70 y: undefined,
71 ...offsetNormalized,
72 });
73 const setTourBox = useCallback(
74 (x, y) => {
75 // x is 20 on mobile, so exclude the offset here
76 setPlacement(x === 20 ? { x, y } : { x, y, ...offsetNormalized });
77 },
78 [offsetNormalized],
79 );
80 const getOffset = useCallback(() => {
81 const hooks = hookNormalized?.split(' ') || [];
82 return {
83 x: hooks.includes('right') ? tourBoxRef.current?.offsetWidth : 0,
84 y: hooks.includes('bottom') ? tourBoxRef.current?.offsetHeight : 0,
85 };
86 }, [hookNormalized]);
87
88 const startOrRecalc = useCallback(() => {
89 if (!targetedElement) return;
90
91 const frame = frameSelector
92 ? (document.querySelector(frameSelector)?.contentDocument ?? document)
93 : document;
94
95 const rect = getBoundingClientRect(
96 frame.querySelector(elementSelector) ?? targetedElement,
97 );
98
99 // Adjust the frame position if we're in an iframe
100 if (frame !== document) {
101 const frameRect = getBoundingClientRect(frame.defaultView.frameElement);
102 rect.x += frameRect.x;
103 rect.left += frameRect.x;
104 rect.right += frameRect.x;
105 rect.y += frameRect.y;
106 rect.top += frameRect.y;
107 rect.bottom += frameRect.y;
108 }
109
110 if (window.innerWidth <= 960) {
111 closeCurrentTour('closed-resize');
112 return;
113 }
114 if (position?.x === undefined) {
115 setTourBox(undefined, undefined);
116 setOverlayRect(null);
117 setVisible(false);
118 return;
119 }
120 const x = Math.max(20, rect?.[position.x] - getOffset().x);
121 const y = Math.max(20, rect?.[position.y] - getOffset().y);
122 const box = tourBoxRef.current;
123 // make sure it doesn't go off-screen
124 setTourBox(
125 Math.min(x, window.innerWidth - (box?.offsetWidth ?? 0) - 20),
126 Math.min(y, window.innerHeight - (box?.offsetHeight ?? 0) - 20),
127 );
128 setOverlayRect(rect);
129 }, [
130 targetedElement,
131 position,
132 getOffset,
133 setTourBox,
134 frameSelector,
135 elementSelector,
136 closeCurrentTour,
137 ]);
138
139 // Pre-launch check whether to redirect
140 useLayoutEffect(() => {
141 // if the tour has a start from url, redirect there
142 if (!settings?.startFrom) return;
143 if (onTourPage()) return;
144 setRedirecting(true);
145 queueTourForRedirect(currentTour.id);
146 closeCurrentTour('redirected');
147 window.location.assign(settings?.startFrom[0]);
148 if (
149 window.location.href.split('#')[0] === settings.startFrom[0].split('#')[0]
150 ) {
151 // Reload if hash is the only difference
152 window.location.reload();
153 }
154 }, [
155 settings?.startFrom,
156 currentTour,
157 queueTourForRedirect,
158 closeCurrentTour,
159 onTourPage,
160 ]);
161
162 // Check for the inert attribute and remove it if it exists
163 useEffect(() => {
164 if (!currentStep) return;
165 document.querySelectorAll('[inert]').forEach((el) => {
166 el?.removeAttribute('inert');
167 });
168 }, [currentStep]);
169
170 // register a custom event to start the specified tour.
171 useEffect(() => {
172 const handle = (event) => {
173 const { tourSlug } = event.detail;
174 if (!tours[tourSlug]) return;
175
176 requestAnimationFrame(() => {
177 window.dispatchEvent(new CustomEvent('extendify-hc:minimize'));
178 startTour(tours[tourSlug]);
179 });
180 };
181 window.addEventListener('extendify-assist:start-tour', handle);
182 return () => {
183 window.removeEventListener('extendify-assist:start-tour', handle);
184 };
185 }, [startTour]);
186
187 // Possibly start the tour, or wait for the load event
188 useLayoutEffect(() => {
189 if (redirecting) return;
190 const tour = queuedTour;
191 let rafId = 0;
192 if (!tour || !tours[tour]) return clearQueuedTour();
193 const handle = () => {
194 requestAnimationFrame(() => {
195 startTour(tours[tour]);
196 });
197 clearQueuedTour();
198 };
199
200 addEventListener('load', handle);
201 if (document.readyState === 'complete') {
202 // Page is already loaded, so we can start the tour immediately
203 rafId = requestAnimationFrame(handle);
204 }
205 return () => {
206 cancelAnimationFrame(rafId);
207 removeEventListener('load', handle);
208 };
209 }, [startTour, queuedTour, clearQueuedTour, redirecting]);
210
211 useEffect(() => {
212 if (!elementSelector) return;
213 // Find and set the element we are attaching to
214 const frame = frameSelector
215 ? (document.querySelector(frameSelector)?.contentDocument ?? document)
216 : document;
217 const element =
218 frame.querySelector(elementSelector) ??
219 document.querySelector(elementSelector);
220 if (!element) return;
221
222 setTargetedElement(element);
223 return () => setTargetedElement(null);
224 }, [frameSelector, elementSelector]);
225
226 // Start building the tour step
227 useLayoutEffect(() => {
228 if (!targetedElement || redirecting) return;
229 setVisible(true);
230 startOrRecalc();
231 addEventListener('resize', startOrRecalc);
232 if (!options?.allowPointerEvents) {
233 targetedElement.style.pointerEvents = 'none';
234 }
235 return () => {
236 removeEventListener('resize', startOrRecalc);
237 targetedElement.style.pointerEvents = 'auto';
238 };
239 }, [redirecting, targetedElement, startOrRecalc, options]);
240
241 useEffect(() => {
242 if (finishedStepOne.current) return;
243 if (!currentStep) return;
244 finishedStepOne.current = true;
245 }, [currentStep]);
246 // Handle the attach and detach events
247 useEffect(() => {
248 if (currentStep === undefined || !targetedElement) return;
249 events?.onAttach?.(targetedElement);
250 let inner = 0;
251 const id = requestAnimationFrame(() => {
252 targetedElement.scrollIntoView({ block: 'start' });
253 startOrRecalc();
254 inner = requestAnimationFrame(startOrRecalc);
255 });
256 initialFocus?.current?.focus();
257 return () => {
258 events?.onDetach?.(targetedElement);
259 cancelAnimationFrame(id);
260 cancelAnimationFrame(inner);
261 };
262 }, [currentStep, events, targetedElement, startOrRecalc, initialFocus]);
263
264 useLayoutEffect(() => {
265 if (!settings?.allowOverflow) return;
266 document.documentElement.classList.add('ext-force-overflow-auto');
267 return () => {
268 document.documentElement.classList.remove('ext-force-overflow-auto');
269 };
270 }, [settings]);
271
272 if (!visible) return null;
273
274 const rectWithPadding = addPaddingToRect(overlayRect, boxPadding);
275 return (
276 <>
277 <AnimatePresence>
278 {Boolean(currentTour) && (
279 <Dialog
280 as={motion.div}
281 static
282 initialFocus={initialFocus}
283 className="extendify-help-center"
284 open={Boolean(currentTour)}
285 onClose={() => undefined}
286 >
287 <div className="relative z-max">
288 <motion.div
289 ref={tourBoxRef}
290 animate={{ opacity: 1, ...placement }}
291 initial={{ opacity: 0, ...placement }}
292 // TODO: fire another event after animation completes?
293 onAnimationComplete={() => {
294 startOrRecalc();
295 }}
296 transition={{
297 duration: finishedStepOne.current ? 0.5 : 0,
298 ease: 'easeInOut',
299 }}
300 className="fixed left-0 top-0 z-20 flex max-w-xs flex-col bg-transparent shadow-2xl sm:overflow-hidden"
301 style={{
302 minWidth: settings?.minBoxWidth ?? '325px',
303 }}
304 >
305 <button
306 type="button"
307 data-test="close-tour"
308 className="absolute right-0 top-0 z-20 m-2 flex h-6 w-6 items-center justify-center rounded-full border-0 bg-white p-0 leading-none outline-hidden ring-1 ring-gray-200 focus:shadow-none focus:ring-wp focus:ring-design-main rtl:left-0 rtl:right-auto"
309 onClick={() => closeCurrentTour('closed-manually')}
310 aria-label={__('Close Modal', 'extendify-local')}
311 >
312 <Icon icon={close} className="h-4 w-4 fill-current" />
313 </button>
314 <Dialog.Title className="sr-only">
315 {currentTour?.title ?? __('Tour', 'extendify-local')}
316 </Dialog.Title>
317 {image && (
318 <div
319 className="w-full p-6"
320 style={{
321 minHeight: 150,
322 background:
323 'linear-gradient(58.72deg, #485563 7.71%, #29323C 92.87%)',
324 }}
325 >
326 <img src={image} className="block w-full" alt={title} />
327 </div>
328 )}
329 <div className="relative m-0 bg-white p-6 pt-0 text-left rtl:text-right">
330 {title && (
331 <h2 className="mb-2 text-xl font-medium">{title}</h2>
332 )}
333 {text && <p className="mb-6">{text}</p>}
334 <BottomNav initialFocus={initialFocus} />
335 </div>
336 </motion.div>
337 </div>
338 </Dialog>
339 )}
340 </AnimatePresence>
341 {options?.allowPointerEvents || (
342 <div aria-hidden={true} className="fixed inset-0 z-max-1" />
343 )}
344 <AnimatePresence>
345 {Boolean(currentTour) && overlayRect?.left !== undefined && (
346 <>
347 <motion.div
348 initial={{
349 opacity: 0,
350 clipPath:
351 'polygon(0px 0px, 100% 0px, 100% 100%, 0px 100%, 0 0)',
352 }}
353 animate={{
354 opacity: 1,
355 clipPath: `polygon(0px 0px, 100% 0px, 100% 100%, 0px 100%, 0 0, ${rectWithPadding.left}px 0, ${rectWithPadding.left}px ${rectWithPadding?.bottom}px, ${rectWithPadding?.right}px ${rectWithPadding.bottom}px, ${rectWithPadding.right}px ${rectWithPadding.top}px, ${rectWithPadding.left}px ${rectWithPadding.top}px)`,
356 }}
357 transition={{
358 duration: finishedStepOne.current ? 0.5 : 0,
359 ease: 'easeInOut',
360 }}
361 className="fixed inset-0 z-max-1 hidden bg-black/70 lg:block"
362 aria-hidden="true"
363 />
364 <BorderOutline
365 rectWithPadding={rectWithPadding}
366 finishedStepOne={finishedStepOne}
367 />
368 </>
369 )}
370 </AnimatePresence>
371 </>
372 );
373 };
374
375 const BorderOutline = ({ rectWithPadding, finishedStepOne }) => {
376 const [visible, setVisible] = useState(false);
377 return (
378 <motion.div
379 initial={{ ...(rectWithPadding ?? {}) }}
380 animate={{ ...(rectWithPadding ?? {}) }}
381 transition={{
382 duration: finishedStepOne.current ? 0.5 : 0,
383 ease: 'easeInOut',
384 }}
385 onAnimationStart={() => setVisible(false)}
386 onAnimationComplete={() => setVisible(true)}
387 className={classNames('fixed inset-0 z-high hidden border-2 lg:block', {
388 'border-transparent': !visible,
389 'border-design-main': visible,
390 'inset-y-auto right-0': isRTL(),
391 })}
392 aria-hidden="true"
393 />
394 );
395 };
396
397 const BottomNav = ({ initialFocus }) => {
398 const {
399 goToStep,
400 completeCurrentTour,
401 currentStep,
402 preparingStep,
403 getStepData,
404 hasNextStep,
405 nextStep,
406 hasPreviousStep,
407 prevStep,
408 currentTour,
409 } = useTourStore();
410 const { options = {} } = getStepData(currentStep);
411 const { hideBackButton = false } = options;
412 const { steps, settings } = currentTour || {};
413
414 return (
415 <div
416 id="extendify-tour-navigation"
417 className="flex w-full items-center justify-between"
418 >
419 <div className="flex flex-1 justify-start rtl:flex-none">
420 <AnimatePresence>
421 {hasPreviousStep() && !hideBackButton && (
422 <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
423 <button
424 type="button"
425 className="flex h-8 items-center justify-center gap-2 rounded-xs bg-transparent p-0 text-gray-900 ring-design-main hover:bg-transparent focus:outline-hidden focus:ring-wp focus:ring-offset-1 focus:ring-offset-white disabled:opacity-60"
426 onClick={prevStep}
427 disabled={preparingStep > -1}
428 >
429 {preparingStep < currentStep && (
430 <Spinner className="m-0 h-4 text-design-main" />
431 )}
432 <span>{__('Back', 'extendify-local')}</span>
433 </button>
434 </motion.div>
435 )}
436 </AnimatePresence>
437 </div>
438
439 {steps?.length > 2 && !settings?.hideDotsNav ? (
440 <nav
441 aria-label={__('Tour Steps', 'extendify-local')}
442 className="flex flex-1 -translate-x-3 items-center justify-center gap-1"
443 >
444 {steps.map((_step, index) => (
445 <div key={index}>
446 <button
447 type="button"
448 aria-label={sprintf(
449 // translators: %1$s is the current step, %2$s is the total number of steps
450 __('%1$s of %2$s', 'extendify-local'),
451 index + 1,
452 steps.length,
453 )}
454 aria-current={index === currentStep}
455 className={`m-0 block h-2.5 w-2.5 rounded-full p-0 ring-offset-1 ring-offset-white focus:outline-hidden focus:ring-wp focus:ring-design-main ${
456 index === currentStep ? 'bg-design-main' : 'bg-gray-300'
457 }`}
458 onClick={() => goToStep(index)}
459 disabled={preparingStep > -1}
460 />
461 </div>
462 ))}
463 </nav>
464 ) : null}
465
466 <div className="flex flex-1 justify-end rtl:flex-none">
467 {hasNextStep() ? (
468 <Button
469 ref={initialFocus}
470 id="help-center-tour-next-button"
471 data-test="help-center-tour-next-button"
472 onClick={nextStep}
473 disabled={preparingStep > -1}
474 className="flex gap-2 bg-design-main text-design-text focus:text-design-text disabled:opacity-60"
475 variant="primary"
476 >
477 {preparingStep > currentStep && (
478 <Spinner className="m-0 h-4 text-design-main" />
479 )}
480 <span>{__('Next', 'extendify-local')}</span>
481 </Button>
482 ) : (
483 <Button
484 id="help-center-tour-next-button"
485 data-test="help-center-tour-next-button"
486 onClick={() => {
487 completeCurrentTour();
488 }}
489 className="bg-design-main"
490 variant="primary"
491 >
492 {__('Done', 'extendify-local')}
493 </Button>
494 )}
495 </div>
496 </div>
497 );
498 };
499
500 const addPaddingToRect = (rect, padding) => ({
501 top: rect.top - (padding?.top ?? 0),
502 left: rect.left - (padding?.left ?? 0),
503 right: rect.right + (padding?.right ?? 0),
504 bottom: rect.bottom + (padding?.bottom ?? 0),
505 width: rect.width + (padding?.left ?? 0) + (padding?.right ?? 0),
506 height: rect.height + (padding?.top ?? 0) + (padding?.bottom ?? 0),
507 x: rect.x - (padding?.left ?? 0),
508 y: rect.y - (padding?.top ?? 0),
509 });
510