PluginProbe
Extendify / 3.0.6
Extendify v3.0.6
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 / Agent / components / GuidedTour.jsx

GuidedTour.jsx in Extendify 3.0.6, at src/Agent/components/GuidedTour.jsx

503 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { useGlobalStore } from '@agent/state/global';
2 import { useTourStore } from '@agent/state/tours';
3 import tours from '@agent/tours/tours';
4 import { Dialog } from '@headlessui/react';
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, isMobile } =
40 useGlobalStore();
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 startTour(tours[tourSlug]);
178 });
179 };
180 window.addEventListener('extendify-assist:start-tour', handle);
181 return () => {
182 window.removeEventListener('extendify-assist:start-tour', handle);
183 };
184 }, [startTour]);
185
186 // Possibly start the tour, or wait for the load event
187 useLayoutEffect(() => {
188 if (redirecting) return;
189 const tour = queuedTour;
190 let rafId = 0;
191 if (!tour || !tours[tour]) return clearQueuedTour();
192 const handle = () => {
193 requestAnimationFrame(() => {
194 startTour(tours[tour]);
195 });
196 clearQueuedTour();
197 };
198
199 addEventListener('load', handle);
200 if (document.readyState === 'complete') {
201 // Page is already loaded, so we can start the tour immediately
202 rafId = requestAnimationFrame(handle);
203 }
204 return () => {
205 cancelAnimationFrame(rafId);
206 removeEventListener('load', handle);
207 };
208 }, [startTour, queuedTour, clearQueuedTour, redirecting]);
209
210 useEffect(() => {
211 if (!elementSelector) return;
212 // Find and set the element we are attaching to
213 const frame = frameSelector
214 ? (document.querySelector(frameSelector)?.contentDocument ?? document)
215 : document;
216 const element =
217 frame.querySelector(elementSelector) ??
218 document.querySelector(elementSelector);
219 if (!element) return;
220
221 setTargetedElement(element);
222 return () => setTargetedElement(null);
223 }, [frameSelector, elementSelector]);
224
225 // Start building the tour step
226 useLayoutEffect(() => {
227 if (!targetedElement || redirecting) return;
228 setVisible(true);
229 startOrRecalc();
230 addEventListener('resize', startOrRecalc);
231 if (!options?.allowPointerEvents) {
232 targetedElement.style.pointerEvents = 'none';
233 }
234 return () => {
235 removeEventListener('resize', startOrRecalc);
236 targetedElement.style.pointerEvents = 'auto';
237 };
238 }, [redirecting, targetedElement, startOrRecalc, options]);
239
240 useEffect(() => {
241 if (finishedStepOne.current) return;
242 if (!currentStep) return;
243 finishedStepOne.current = true;
244 }, [currentStep]);
245 // Handle the attach and detach events
246 useEffect(() => {
247 if (currentStep === undefined || !targetedElement) return;
248 events?.onAttach?.(targetedElement);
249 let inner = 0;
250 const id = requestAnimationFrame(() => {
251 targetedElement.scrollIntoView({ block: 'center' });
252 startOrRecalc();
253 inner = requestAnimationFrame(startOrRecalc);
254 });
255 initialFocus?.current?.focus();
256 return () => {
257 events?.onDetach?.(targetedElement);
258 cancelAnimationFrame(id);
259 cancelAnimationFrame(inner);
260 };
261 }, [currentStep, events, targetedElement, startOrRecalc, initialFocus]);
262
263 useLayoutEffect(() => {
264 if (!settings?.allowOverflow) return;
265 document.documentElement.classList.add('ext-force-overflow-auto');
266 return () => {
267 document.documentElement.classList.remove('ext-force-overflow-auto');
268 };
269 }, [settings]);
270
271 if (!visible || isMobile) return null;
272
273 const rectWithPadding = addPaddingToRect(overlayRect, boxPadding);
274 return (
275 <AnimatePresence>
276 {Boolean(currentTour) && (
277 <Dialog
278 as={motion.div}
279 static
280 initialFocus={initialFocus}
281 className="extendify-agent"
282 open={Boolean(currentTour)}
283 onClose={() => undefined}
284 >
285 <div className="relative z-max">
286 <motion.div
287 ref={tourBoxRef}
288 animate={{ opacity: 1, ...placement }}
289 initial={{ opacity: 0, ...placement }}
290 // TODO: fire another event after animation completes?
291 onAnimationComplete={() => {
292 startOrRecalc();
293 }}
294 transition={{
295 duration: finishedStepOne.current ? 0.5 : 0,
296 ease: 'easeInOut',
297 }}
298 className="fixed left-0 top-0 z-20 flex max-w-xs flex-col bg-transparent shadow-2xl sm:overflow-hidden"
299 style={{
300 minWidth: settings?.minBoxWidth ?? '325px',
301 }}
302 >
303 <button
304 type="button"
305 data-test="close-tour"
306 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"
307 onClick={() => closeCurrentTour('closed-manually')}
308 aria-label={__('Close Modal', 'extendify-local')}
309 >
310 <Icon icon={close} className="h-4 w-4 fill-current" />
311 </button>
312 <Dialog.Title className="sr-only">
313 {currentTour?.title ?? __('Tour', 'extendify-local')}
314 </Dialog.Title>
315 {image && (
316 <div
317 className="w-full p-6"
318 style={{
319 minHeight: 150,
320 background:
321 'linear-gradient(58.72deg, #485563 7.71%, #29323C 92.87%)',
322 }}
323 >
324 <img src={image} className="block w-full" alt={title} />
325 </div>
326 )}
327 <div className="relative m-0 bg-white p-6 pt-0 text-left rtl:text-right">
328 {title && <h2 className="mb-2 text-xl font-medium">{title}</h2>}
329 {text && <p className="mb-6">{text}</p>}
330 <BottomNav initialFocus={initialFocus} />
331 </div>
332 </motion.div>
333 </div>
334 {options?.allowPointerEvents || (
335 <div aria-hidden={true} className="fixed inset-0 z-max-1" />
336 )}
337 {Boolean(currentTour) && overlayRect?.left !== undefined && (
338 <>
339 <motion.div
340 initial={{
341 opacity: 0,
342 clipPath:
343 'polygon(0px 0px, 100% 0px, 100% 100%, 0px 100%, 0 0)',
344 }}
345 animate={{
346 opacity: 1,
347 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)`,
348 }}
349 transition={{
350 duration: finishedStepOne.current ? 0.5 : 0,
351 ease: 'easeInOut',
352 }}
353 className="fixed inset-0 z-max-1 hidden bg-black/70 lg:block"
354 aria-hidden="true"
355 />
356 <BorderOutline
357 rectWithPadding={rectWithPadding}
358 finishedStepOne={finishedStepOne}
359 />
360 </>
361 )}
362 </Dialog>
363 )}
364 </AnimatePresence>
365 );
366 };
367
368 const BorderOutline = ({ rectWithPadding, finishedStepOne }) => {
369 const [visible, setVisible] = useState(false);
370 return (
371 <motion.div
372 initial={{ ...(rectWithPadding ?? {}) }}
373 animate={{ ...(rectWithPadding ?? {}) }}
374 transition={{
375 duration: finishedStepOne.current ? 0.5 : 0,
376 ease: 'easeInOut',
377 }}
378 onAnimationStart={() => setVisible(false)}
379 onAnimationComplete={() => setVisible(true)}
380 className={classNames('fixed inset-0 z-high hidden border-2 lg:block', {
381 'border-transparent': !visible,
382 'border-design-main': visible,
383 'inset-y-auto right-0': isRTL(),
384 })}
385 aria-hidden="true"
386 />
387 );
388 };
389
390 const BottomNav = ({ initialFocus }) => {
391 const {
392 goToStep,
393 completeCurrentTour,
394 currentStep,
395 preparingStep,
396 getStepData,
397 hasNextStep,
398 nextStep,
399 hasPreviousStep,
400 prevStep,
401 currentTour,
402 } = useTourStore();
403 const { options = {} } = getStepData(currentStep);
404 const { hideBackButton = false } = options;
405 const { steps, settings } = currentTour || {};
406
407 return (
408 <div
409 id="extendify-tour-navigation"
410 className="flex w-full items-center justify-between"
411 >
412 <div className="flex flex-1 justify-start rtl:flex-none">
413 <AnimatePresence>
414 {hasPreviousStep() && !hideBackButton && (
415 <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
416 <button
417 type="button"
418 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"
419 onClick={prevStep}
420 disabled={preparingStep > -1}
421 >
422 {preparingStep < currentStep && (
423 <Spinner className="m-0 h-4 text-design-main" />
424 )}
425 <span>{__('Back', 'extendify-local')}</span>
426 </button>
427 </motion.div>
428 )}
429 </AnimatePresence>
430 </div>
431
432 {steps?.length > 2 && !settings?.hideDotsNav ? (
433 <nav
434 aria-label={__('Tour Steps', 'extendify-local')}
435 className="flex flex-1 -translate-x-3 items-center justify-center gap-1"
436 >
437 {steps.map((_step, index) => (
438 <div key={index}>
439 <button
440 type="button"
441 aria-label={sprintf(
442 // translators: %1$s is the current step, %2$s is the total number of steps
443 __('%1$s of %2$s', 'extendify-local'),
444 index + 1,
445 steps.length,
446 )}
447 aria-current={index === currentStep}
448 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 ${
449 index === currentStep ? 'bg-design-main' : 'bg-gray-300'
450 }`}
451 onClick={() => goToStep(index)}
452 disabled={preparingStep > -1}
453 />
454 </div>
455 ))}
456 </nav>
457 ) : null}
458
459 <div className="flex flex-1 justify-end rtl:flex-none">
460 {hasNextStep() ? (
461 <Button
462 ref={initialFocus}
463 id="agent-tour-next-button"
464 data-test="agent-tour-next-button"
465 onClick={nextStep}
466 disabled={preparingStep > -1}
467 className="flex gap-2 bg-design-main text-design-text focus:text-design-text disabled:opacity-60"
468 variant="primary"
469 >
470 {preparingStep > currentStep && (
471 <Spinner className="m-0 h-4 text-design-main" />
472 )}
473 <span>{__('Next', 'extendify-local')}</span>
474 </Button>
475 ) : (
476 <Button
477 id="agent-tour-next-button"
478 data-test="agent-tour-next-button"
479 onClick={() => {
480 completeCurrentTour();
481 }}
482 className="bg-design-main"
483 variant="primary"
484 >
485 {__('Done', 'extendify-local')}
486 </Button>
487 )}
488 </div>
489 </div>
490 );
491 };
492
493 const addPaddingToRect = (rect, padding) => ({
494 top: rect.top - (padding?.top ?? 0),
495 left: rect.left - (padding?.left ?? 0),
496 right: rect.right + (padding?.right ?? 0),
497 bottom: rect.bottom + (padding?.bottom ?? 0),
498 width: rect.width + (padding?.left ?? 0) + (padding?.right ?? 0),
499 height: rect.height + (padding?.top ?? 0) + (padding?.bottom ?? 0),
500 x: rect.x - (padding?.left ?? 0),
501 y: rect.y - (padding?.top ?? 0),
502 });
503