diff --git a/.changeset/three-rice-repair.md b/.changeset/three-rice-repair.md new file mode 100644 index 00000000000..19d1f4cdc35 --- /dev/null +++ b/.changeset/three-rice-repair.md @@ -0,0 +1,5 @@ +--- +'@razorpay/blade': minor +--- + +feat(blade): added SpotlightPopoverTour web implementation diff --git a/packages/blade/src/components/SpotlightPopoverTour/Tour.native.tsx b/packages/blade/src/components/SpotlightPopoverTour/Tour.native.tsx new file mode 100644 index 00000000000..8a46c88aff1 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/Tour.native.tsx @@ -0,0 +1,14 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import type { SpotlightPopoverTourProps } from './types'; +import { throwBladeError } from '~utils/logger'; + +const Tour = (_props: SpotlightPopoverTourProps): React.ReactElement => { + throwBladeError({ + message: 'Tour is not yet implemented for native', + moduleName: 'Tour', + }); + + return <>; +}; + +export { Tour }; diff --git a/packages/blade/src/components/SpotlightPopoverTour/Tour.stories.tsx b/packages/blade/src/components/SpotlightPopoverTour/Tour.stories.tsx new file mode 100644 index 00000000000..9dbe4785d49 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/Tour.stories.tsx @@ -0,0 +1,947 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import type { Meta, ComponentStory } from '@storybook/react'; +import React from 'react'; +import { Title } from '@storybook/addon-docs'; +import isChromatic from 'chromatic'; +import type { + SpotlightPopoverStepRenderProps, + SpotlightPopoverTourProps, + SpotlightPopoverTourSteps, +} from './types'; +import { SpotlightPopoverTourStep } from './TourStep'; +import { SpotlightPopoverTourFooter } from './TourFooter'; +import { SpotlightPopoverTour } from '.'; +import { Button } from '~components/Button'; +import { Box } from '~components/Box'; +import { Code, Text } from '~components/Typography'; +import { InfoIcon } from '~components/Icons'; +import StoryPageWrapper from '~utils/storybook/StoryPageWrapper'; +import { Sandbox } from '~utils/storybook/Sandbox'; +import { Card, CardBody } from '~components/Card'; +import { Amount } from '~components/Amount'; +import { Link } from '~components/Link'; + +const Page = (): React.ReactElement => { + return ( + + Usage + + {` + import React from 'react'; + import { Tour, TourStep, TourFooter, Box, Text, Button } from '@razorpay/blade/components'; + + function App(): React.ReactElement { + const [activeStep, setActiveStep] = React.useState(0); + const [isOpen, setIsOpen] = React.useState(false); + + return ( + + + { + console.log('finished'); + setActiveStep(0); + setIsOpen(false); + }} + onOpenChange={({ isOpen }) => { + console.log('open change', isOpen); + setIsOpen(isOpen); + }} + onStepChange={(step) => { + console.log('step change', step); + setActiveStep(step); + }} + > + + + + Step 1 + + + + + Step 2 + + + + + + ); + } + + export default App; + `} + + + ); +}; + +const propsCategory = { + TOUR: 'Tour Props', + TOUR_STEPS: 'Single Tour Step Props', +}; + +type StoryControlProps = SpotlightPopoverTourProps & { + tourStepsTitle: SpotlightPopoverTourSteps[number]['title']; + tourStepsContent: SpotlightPopoverTourSteps[number]['content']; + tourStepsPlacement: SpotlightPopoverTourSteps[number]['placement']; + tourStepsFooter: SpotlightPopoverTourSteps[number]['footer']; + tourStepsTitleLeading: SpotlightPopoverTourSteps[number]['titleLeading']; + tourStepsName: SpotlightPopoverTourSteps[number]['name']; +}; + +export default { + title: 'Components/SpotlightPopoverTour', + component: SpotlightPopoverTour, + argTypes: { + tourStepsTitle: { + name: 'steps[0].title', + type: 'string', + defaultValue: 'Overview of Refunds', + table: { category: propsCategory.TOUR_STEPS }, + }, + tourStepsContent: { + name: 'steps[0].content', + type: 'string', + defaultValue: + 'You can issue refunds for various reasons, like when a customer returns a product or cancels a service.', + table: { category: propsCategory.TOUR_STEPS }, + }, + tourStepsPlacement: { + name: 'steps[0].placement', + defaultValue: 'bottom', + control: { + type: 'select', + options: [ + 'bottom', + 'top', + 'left', + 'right', + 'bottom-start', + 'bottom-end', + 'top-start', + 'top-end', + 'left-start', + 'left-end', + 'right-start', + 'right-end', + ], + }, + table: { category: propsCategory.TOUR_STEPS }, + }, + tourStepsName: { + name: 'steps[0].name', + type: 'string', + defaultValue: 'step-1', + control: { + disable: true, + }, + table: { category: propsCategory.TOUR_STEPS }, + }, + tourStepsTitleLeading: { + name: 'steps[0].titleLeading', + control: { + disable: true, + }, + table: { category: propsCategory.TOUR_STEPS }, + }, + tourStepsFooter: { + name: 'steps[0].footer', + control: { + disable: true, + }, + table: { category: propsCategory.TOUR_STEPS }, + }, + children: { + control: { + disable: true, + }, + table: { category: propsCategory.TOUR }, + }, + onFinish: { + control: { + disable: true, + }, + table: { category: propsCategory.TOUR }, + }, + onOpenChange: { + control: { + disable: true, + }, + table: { category: propsCategory.TOUR }, + }, + onStepChange: { + control: { + disable: true, + }, + table: { category: propsCategory.TOUR }, + }, + steps: { + control: { + disable: true, + }, + table: { category: propsCategory.TOUR }, + }, + isOpen: { + control: { + disable: true, + }, + table: { category: propsCategory.TOUR }, + }, + activeStep: { + control: { + disable: true, + }, + table: { category: propsCategory.TOUR }, + }, + }, + parameters: { + docs: { + page: Page, + }, + }, +} as Meta; + +const Center = ({ children }: { children: React.ReactNode }): React.ReactElement => { + return ( + + {children} + + ); +}; + +const CustomTourFooter = ({ + activeStep, + totalSteps, + goToNext, + goToPrevious, + stopTour, +}: SpotlightPopoverStepRenderProps) => { + const isLast = activeStep === totalSteps - 1; + const isFirst = activeStep === 0; + return ( + + ); +}; + +const TourTemplate: ComponentStory<(props: StoryControlProps) => React.ReactElement> = (args) => { + const [activeStep, setActiveStep] = React.useState(0); + const [isOpen, setIsOpen] = React.useState(!!isChromatic()); + + const steps = React.useMemo( + () => [ + { + name: 'step-1', + title: args.tourStepsTitle, + content: () => { + return ( + + {args.tourStepsContent} + + You can also issue partial refunds - for example, if a customer purchased multiple + items. + + + ); + }, + placement: args.tourStepsPlacement, + footer: CustomTourFooter, + }, + { + name: 'step-2', + title: 'Overview of Disputes', + content: () => { + return ( + + + Disputes are raised by customers when they have a problem with a transaction. + + + ); + }, + placement: 'bottom', + footer: CustomTourFooter, + }, + { + name: 'step-3', + title: 'Dispute Statuses', + content: () => { + return ( + + Disputes which are open or under review will be shown here. You can also review them + by clicking on the button. + + ); + }, + placement: 'bottom', + footer: CustomTourFooter, + }, + ], + [args.tourStepsContent, args.tourStepsPlacement, args.tourStepsTitle], + ); + + return ( + + + { + console.log('finished'); + setActiveStep(0); + setIsOpen(false); + }} + onOpenChange={({ isOpen }) => { + console.log('open change', isOpen); + setIsOpen(isOpen); + }} + onStepChange={(step) => { + console.log('step change', step); + setActiveStep(step); + }} + > + + + + + + + + Refunds + + + + 3 Processed + + + + + + + + + + + + Disputes + + + + + + 0 Open | 0 Under review + + + + + + + + + + + + ); +}; + +export const Default = TourTemplate.bind({}); +Default.storyName = 'Default'; + +export const CustomPlacement = () => { + const [activeStep, setActiveStep] = React.useState(0); + const [isOpen, setIsOpen] = React.useState(false); + const steps = React.useMemo( + () => [ + { + name: 'top', + title: 'Top', + content: () => { + return ( + + Top + + ); + }, + placement: 'top', + footer: CustomTourFooter, + }, + { + name: 'bottom', + content: () => { + return ( + + Bottom + + ); + }, + placement: 'bottom', + footer: CustomTourFooter, + }, + { + name: 'left', + content: () => { + return ( + + Left + + ); + }, + placement: 'left', + footer: CustomTourFooter, + }, + { + name: 'right', + content: () => { + return ( + + Right + + ); + }, + placement: 'right', + footer: CustomTourFooter, + }, + ], + [], + ); + + return ( + + + { + console.log('finished'); + setActiveStep(0); + setIsOpen(false); + }} + onOpenChange={({ isOpen }) => { + console.log('open change', isOpen); + setIsOpen(isOpen); + }} + onStepChange={(step) => { + console.log('step change', step); + setActiveStep(step); + }} + > + + You can pass individual placement values to each step in the popover. It supports same + placement values as Popover (top, bottom, left, right, top-start, top-end, bottom-start, + bottom-end, left-start, left-end, right-start, right-end) + +
+ + + + top + + + + + bottom + + + + + left + + + + + right + + + +
+
+
+ ); +}; +CustomPlacement.storyName = 'Custom Placement'; + +export const WithScrollablePage = () => { + const [activeStep, setActiveStep] = React.useState(0); + const [isOpen, setIsOpen] = React.useState(false); + const steps = React.useMemo( + () => [ + { + name: 'razorpay-dashboard', + title: 'Powerful Dashboard', + content: () => { + return ( + + + Razorpay provides a Powerful Dashboard for you to get reports and detailed + statistics on payments, settlements, refunds and much more for you to take better + business decisions. + + + ); + }, + placement: 'bottom', + footer: CustomTourFooter, + }, + { + name: 'amazon-aws', + title: 'Infrastructure At Scale', + content: () => { + return ( + + + With Amazon AWS, we are built for scale. To ensure that products built with Razorpay + are always available, we have a highly scalable and reliable infrastructure. + + + ); + }, + placement: 'bottom', + footer: CustomTourFooter, + }, + { + name: 'razorpay-docs', + title: 'Developer Friendly APIs', + content: () => { + return ( + + + With SDKs and documentation for all major languages and platforms, Razorpay is built + for developers. + + + ); + }, + placement: 'left', + footer: CustomTourFooter, + }, + ], + [], + ); + + return ( + + + { + console.log('finished'); + setActiveStep(0); + setIsOpen(false); + }} + onOpenChange={({ isOpen }) => { + console.log('open change', isOpen); + setIsOpen(isOpen); + }} + onStepChange={(step) => { + console.log('step change', step); + setActiveStep(step); + }} + > + + You can pass individual placement values to each step in the popover. It supports same + placement values as Popover (top, bottom, left, right, top-start, top-end, bottom-start, + bottom-end, left-start, left-end, right-start, right-end) + + + + A{' '} + + Powerful Dashboard + {' '} + for you to get reports and detailed statistics on payments, settlements, refunds and + much more for you to take better business decisions. + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum + has been the industry's standard dummy text ever since the 1500s, when an unknown + printer took a galley of type and scrambled it to make a type specimen book. It has + survived not only five centuries, but also the leap into electronic typesetting, + remaining essentially unchanged. It was popularised in the 1960s with the release of + Letraset sheets containing Lorem Ipsum passages, and more recently with desktop + publishing software like Aldus PageMaker including versions of Lorem Ipsum. + + + The standard Lorem Ipsum passage, used since the 1500s "Lorem ipsum dolor sit amet, + consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi + ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat + cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est + laborum." Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 + BC "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium + doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis + et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia + voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos + qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum + quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi + tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad + minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut + aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea + voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum + fugiat quo voluptas nulla pariatur?" 1914 translation by H. Rackham "But I must + explain to you how all this mistaken idea of denouncing pleasure and praising pain was + born and I will give you a complete account of the system, and expound the actual + teachings of the great explorer of the truth, the master-builder of human happiness. + No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but + because those who do not know how to pursue pleasure rationally encounter consequences + that are extremely painful. Nor again is there anyone who loves or pursues or desires + to obtain pain of itself, because it is pain, but because occasionally circumstances + occur in which toil and pain can procure him some great pleasure. To take a trivial + example, which of us ever undertakes laborious physical exercise, except to obtain + some advantage from it? But who has any right to find fault with a man who chooses to + enjoy a pleasure that has no annoying consequences, or one who avoids a pain that + produces no resultant pleasure?" Section 1.10.33 of "de Finibus Bonorum et Malorum", + written by Cicero in 45 BC "At vero eos et accusamus et iusto odio dignissimos ducimus + qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas + molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa + qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem + rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est + eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, + omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et + aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates + repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a + sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut + perferendis doloribus asperiores repellat." + + + 1914 translation by H. Rackham "On the other hand, we denounce with righteous + indignation and dislike men who are so beguiled and demoralized by the charms of + pleasure of the moment, so blinded by desire, that they cannot foresee the pain and + trouble that are bound to ensue; and equal blame belongs to those who fail in their + duty through weakness of will, which is the same as saying through shrinking from toil + and pain. These cases are perfectly simple and easy to distinguish. In a free hour, + when our power of choice is untrammelled and when nothing prevents our being able to + do what we like best, every pleasure is to be welcomed and every pain avoided. But in + certain circumstances and owing to the claims of duty or the obligations of business + it will frequently occur that pleasures have to be repudiated and annoyances accepted. + The wise man therefore always holds in these matters to this principle of selection: + he rejects pleasures to secure other greater pleasures, or else he endures pains to + avoid worse pains." + + + + Over the last couple of years, we have worked hard with our banking partners so you + don’t have to. Razorpay's servers are completely hosted on + +  Amazon AWS + {' '} + with auto-scaling systems that scale up to handle any traffic that you throw at it today + or in the future. + + + Built for Developers: Robust, clean,{' '} + + developer friendly APIs + {' '} + , plugins and libraries for all major languages and platforms that let you focus on + building great products. + + + Section 1.10.33 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC "At vero + eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum + deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati + cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, + id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita + distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit + quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis + dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum + necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non + recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis + voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat." + + + + + ); +}; +WithScrollablePage.storyName = 'With Scrollable Page'; + +const InterruptibleTourFooter = ({ + activeStep, + goToNext, + goToStep, + stopTour, + goToPrevious, + totalSteps, + setIsTourSkipped, +}: SpotlightPopoverStepRenderProps & { + setIsTourSkipped: React.Dispatch>; +}): React.ReactElement => { + const isLast = activeStep === totalSteps - 1; + const isFirst = activeStep === 0; + return ( + + + {activeStep + 1} / {totalSteps} + + + + {!isFirst ? ( + + ) : null} + {isLast ? ( + + ) : ( + + )} + + + ); +}; + +export const InterruptibleTour = () => { + const [activeStep, setActiveStep] = React.useState(0); + const [isTourSkipped, setIsTourSkipped] = React.useState(false); + const [isOpen, setIsOpen] = React.useState(false); + const steps = React.useMemo( + () => [ + { + name: 'step-1', + title: 'Step 1', + content: () => { + return ( + + This is step 1, press skip + + ); + }, + placement: 'top', + footer: (props) => ( + + ), + }, + { + name: 'step-2', + title: 'Step 2', + content: () => { + return ( + + This is step 2 + + ); + }, + placement: 'bottom', + footer: (props) => ( + + ), + }, + isTourSkipped + ? { + name: 'start-tour', + title: 'Tour Incomplete!', + content: () => { + return ( + + We reccommend that you complete the tour to make the most of the new features. You + can find it here when you want to take it. + + ); + }, + footer: ({ stopTour }) => { + return ( + + ); + }, + } + : { + name: 'start-tour', + title: 'Tour Complete!', + content: () => { + return ( + + You have completed the tour. You can find it here when you want to take it. + + ); + }, + footer: ({ stopTour }) => { + return ( + + ); + }, + }, + ], + [isTourSkipped], + ); + + return ( + + { + console.log('finished'); + setIsOpen(false); + setIsTourSkipped(false); + setActiveStep(0); + }} + onOpenChange={({ isOpen }) => { + console.log('open change', isOpen); + setIsOpen(isOpen); + }} + onStepChange={(step) => { + console.log('step change', step); + setActiveStep(step); + }} + > + + + + + You can create complex flows like interruptible tours by dynamically modifying the steps + array, and changing it's contents. + + + Compose and make use of methods provided by the tour component like{' '} + stopTour, goToStep,{' '} + goToNext etc to control the behaviour of the current tour step + +
+ + + + Step 1 + + + + + Step 2 + + + +
+
+
+ ); +}; +InterruptibleTour.storyName = 'Product Usecase: Interruptible Tour'; diff --git a/packages/blade/src/components/SpotlightPopoverTour/Tour.web.tsx b/packages/blade/src/components/SpotlightPopoverTour/Tour.web.tsx new file mode 100644 index 00000000000..90bfd794977 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/Tour.web.tsx @@ -0,0 +1,226 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable consistent-return */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import React, { useState, useCallback, useMemo } from 'react'; +import { FloatingPortal } from '@floating-ui/react'; +import { TourContext } from './TourContext'; +import { TourPopover } from './TourPopover'; +import { + smoothScroll, + useDelayedState, + useIntersectionObserver, + useIsTransitioningBetweenSteps, + useLockBodyScroll, +} from './utils'; +import type { SpotlightPopoverTourMaskRect, SpotlightPopoverTourProps } from './types'; +import { SpotlightPopoverTourMask } from './TourMask'; +import { transitionDelay } from './tourTokens'; +import { useTheme } from '~utils'; +import { useIsomorphicLayoutEffect } from '~utils/useIsomorphicLayoutEffect'; + +const SpotlightPopoverTour = ({ + steps, + activeStep, + isOpen, + onFinish, + onOpenChange, + onStepChange, + children, +}: SpotlightPopoverTourProps): React.ReactElement => { + const { theme } = useTheme(); + const [refIdMap, setRefIdMap] = useState(new Map>()); + const [size, setSize] = useState({ + x: 0, + y: 0, + height: 0, + width: 0, + }); + + // delayed state is used to let the transition finish before reacting to the state changes + const delayedActiveStep = useDelayedState(activeStep, transitionDelay); + const delayedSize = useDelayedState(size, transitionDelay); + // keep track of when we are transitioning between steps + const isTransitioning = useIsTransitioningBetweenSteps(activeStep, transitionDelay); + + const currentStepRef = refIdMap.get(steps[activeStep]?.name); + const intersection = useIntersectionObserver(currentStepRef!, { + threshold: 0.5, + }); + + // main step logic + const totalSteps = steps.length; + const currentStepData = useMemo(() => { + return steps[activeStep]; + }, [activeStep, steps]); + + const goToStep = useCallback( + (step: number) => { + if (step < 0 || step >= steps.length) return; + onStepChange?.(step); + }, + [onStepChange, steps.length], + ); + + const goToNext = useCallback(() => { + let nextState = activeStep + 1; + if (nextState >= steps.length) { + nextState = steps.length - 1; + } + onStepChange?.(nextState); + }, [activeStep, onStepChange, steps.length]); + + const goToPrevious = useCallback(() => { + let nextState = activeStep - 1; + if (nextState < 0) { + nextState = 0; + } + onStepChange?.(nextState); + }, [activeStep, onStepChange]); + + const stopTour = useCallback(() => { + onFinish?.(); + }, [onFinish]); + + const attachStep = useCallback((id: string, ref: React.RefObject) => { + if (!ref) return; + setRefIdMap((prev) => { + return new Map(prev).set(id, ref); + }); + }, []); + + const removeStep = useCallback((id: string) => { + setRefIdMap((prev) => { + const newMap = new Map(prev); + newMap.delete(id); + return newMap; + }); + }, []); + + const updateMaskSize = useCallback(() => { + const ref = refIdMap.get(steps[activeStep]?.name); + if (!ref?.current) return; + + const rect = ref.current.getBoundingClientRect(); + setSize({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }); + }, [activeStep, refIdMap, steps]); + + const scrollToStep = useCallback(() => { + const ref = refIdMap.get(steps[delayedActiveStep]?.name); + if (!ref?.current) return; + + // If the element is already in view, don't scroll + if (intersection?.isIntersecting) return; + + smoothScroll(ref.current, { + behavior: 'smooth', + block: 'center', + inline: 'center', + }) + .then(() => { + updateMaskSize(); + }) + .finally(() => { + // do nothing + }); + }, [delayedActiveStep, refIdMap, steps, updateMaskSize, intersection?.isIntersecting]); + + // Update the size of the mask when the active step changes + useIsomorphicLayoutEffect(() => { + updateMaskSize(); + }, [isOpen, activeStep, refIdMap, steps, updateMaskSize]); + + // Scroll into view when the active step changes + useIsomorphicLayoutEffect(() => { + setTimeout(() => { + if (!isOpen) return; + if (isTransitioning) return; + scrollToStep(); + }, transitionDelay); + }, [isOpen, scrollToStep, isTransitioning]); + + useLockBodyScroll(isOpen); + + // reset the mask size when the tour is closed + React.useEffect(() => { + if (isOpen) { + updateMaskSize(); + onOpenChange?.({ isOpen }); + } + if (!isOpen) { + setSize({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + const contextValue = useMemo(() => { + return { attachStep, removeStep }; + }, [attachStep, removeStep]); + + return ( + + + {isOpen ? ( + + ) : null} + + + {steps.map((step) => { + const isStepActive = currentStepData.name === step.name; + const attachTo = isStepActive ? currentStepRef : undefined; + // 1. only show popover if the tour is opened + // 2. only show the popover if the step is active + // 3. do not show the popover if we are transitioning between steps + // this ensures popover suddenly doesn't jump to the next step, + // instead it waits for the transition to finish + const isPopoverVisible = isOpen && isStepActive && !isTransitioning; + + return ( + + ); + })} + + {children} + + ); +}; + +export { SpotlightPopoverTour }; diff --git a/packages/blade/src/components/SpotlightPopoverTour/TourContext.tsx b/packages/blade/src/components/SpotlightPopoverTour/TourContext.tsx new file mode 100644 index 00000000000..a10279cf122 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/TourContext.tsx @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import React from 'react'; +import { throwBladeError } from '~utils/logger'; + +type TourContextProps = { + attachStep: (id: string, ref: React.RefObject) => void; + removeStep: (id: string) => void; +} | null; + +const TourContext = React.createContext(null); + +const useTourContext = (): NonNullable => { + const context = React.useContext(TourContext); + + if (!context) { + throwBladeError({ + moduleName: 'Tour', + message: 'useTourContext must be used within Tour', + }); + } + + return context!; +}; + +export { useTourContext, TourContext }; diff --git a/packages/blade/src/components/SpotlightPopoverTour/TourFooter.native.tsx b/packages/blade/src/components/SpotlightPopoverTour/TourFooter.native.tsx new file mode 100644 index 00000000000..3eda320ec19 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/TourFooter.native.tsx @@ -0,0 +1,14 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ +import { throwBladeError } from '~utils/logger'; + +const SpotlightPopoverTourFooter = (): React.ReactElement => { + throwBladeError({ + message: 'Tour is not yet implemented for native', + moduleName: 'Tour', + }); + + return <>; +}; + +export { SpotlightPopoverTourFooter }; diff --git a/packages/blade/src/components/SpotlightPopoverTour/TourFooter.web.tsx b/packages/blade/src/components/SpotlightPopoverTour/TourFooter.web.tsx new file mode 100644 index 00000000000..20a4ab4a1d0 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/TourFooter.web.tsx @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ +import type { SpotlightPopoverStepRenderProps } from './types'; +import type { ButtonProps } from '~components/Button'; +import { Button } from '~components/Button'; +import { Box } from '~components/Box'; +import { Text } from '~components/Typography'; + +type SpotlightPopoverFooterAction = { + text?: string; +} & Pick; + +type SpotlightPopoverTourFooterProps = { + actions: { + primary?: SpotlightPopoverFooterAction; + secondary?: SpotlightPopoverFooterAction; + }; +}; + +const SpotlightPopoverTourFooter = ({ + activeStep, + totalSteps, + actions, +}: SpotlightPopoverTourFooterProps & + Pick): React.ReactElement => { + const hasPrimaryAction = Boolean(actions?.primary); + const hasSecondaryAction = Boolean(actions?.secondary); + + let isBothIcon = false; + if (hasPrimaryAction && hasSecondaryAction) { + const primaryHasIcon = Boolean(actions?.primary?.icon); + const secondaryHasIcon = Boolean(actions?.secondary?.icon); + isBothIcon = primaryHasIcon && secondaryHasIcon; + } + + return ( + + + {activeStep + 1} / {totalSteps} + + + {hasSecondaryAction ? ( + + ) : null} + {hasPrimaryAction ? ( + + ) : null} + + + ); +}; + +export { SpotlightPopoverTourFooter }; diff --git a/packages/blade/src/components/SpotlightPopoverTour/TourMask.native.tsx b/packages/blade/src/components/SpotlightPopoverTour/TourMask.native.tsx new file mode 100644 index 00000000000..15f5aa2c0bd --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/TourMask.native.tsx @@ -0,0 +1,14 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { throwBladeError } from '~utils/logger'; + +const SpotlightPopoverTourMask = (_: any): React.ReactElement => { + throwBladeError({ + message: 'Tour is not yet implemented for native', + moduleName: 'Tour', + }); + + return <>; +}; + +export { SpotlightPopoverTourMask }; diff --git a/packages/blade/src/components/SpotlightPopoverTour/TourMask.web.tsx b/packages/blade/src/components/SpotlightPopoverTour/TourMask.web.tsx new file mode 100644 index 00000000000..a69dedd6661 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/TourMask.web.tsx @@ -0,0 +1,190 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import React from 'react'; +import type { FlattenSimpleInterpolation } from 'styled-components'; +import styled, { css, keyframes } from 'styled-components'; +import usePresence from 'use-presence'; +import type { SpotlightPopoverTourMaskRect } from './types'; +import { tourMaskZIndex } from './tourTokens'; +import { useWindowSize } from '~utils/useWindowSize'; +import { makeSpace, useTheme } from '~utils'; +import { makeMotionTime } from '~utils/makeMotionTime'; +import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; +import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; + +const scaleIn = keyframes` + from { + fill-opacity: 0; + } + to { + fill-opacity: 1; + } +`; +const fadeOut = keyframes` + from { + fill-opacity: 1; + } + to { + fill-opacity: 0; + } +`; + +const pulsingAnimation = keyframes` + 0% { + opacity: 0.5; + } + 100% { + opacity: 1; + } +`; + +const AnimatedFade = styled.rect<{ animationType: FlattenSimpleInterpolation | null }>( + ({ animationType }) => + animationType === null + ? '' + : css` + ${animationType} + `, +); + +const StyledPlusing = styled.rect<{ animationType: FlattenSimpleInterpolation | null }>( + ({ animationType }) => { + return animationType === null + ? '' + : css` + ${animationType} + `; + }, +); + +type FadeRectProps = React.ComponentProps<'rect'> & { + show: boolean; +}; +const FadeRect = React.memo( + ({ show, children, ...rest }: FadeRectProps): React.ReactElement => { + const { theme } = useTheme(); + + const duration = theme.motion.duration.gentle; + const enter = css` + animation: ${scaleIn} ${makeMotionTime(duration)} + ${(theme.motion.easing.entrance.effective as unknown) as string}; + animation-fill-mode: forwards; + `; + + const exit = css` + animation: ${fadeOut} ${makeMotionTime(duration)} + ${(theme.motion.easing.exit.effective as unknown) as string}; + animation-fill-mode: forwards; + `; + + const { isVisible } = usePresence(Boolean(show), { + transitionDuration: duration, + initialEnter: false, + }); + + return ( + // @ts-expect-error styled compoennt types are different from react types + + {children} + + ); + }, +); + +const PulsingRect = React.memo( + (props: React.ComponentProps<'rect'>): React.ReactElement => { + const pulsing = css` + animation: ${pulsingAnimation} 2s; + animation-iteration-count: infinite; + animation-direction: alternate; + `; + + return ( + // @ts-expect-error styled compoennt types are different from react types + + ); + }, +); + +type SpotlightPopoverTourMaskProps = { + padding: number; + size: SpotlightPopoverTourMaskRect; + isTransitioning: boolean; +}; + +const absoluteFill = { + position: 'fixed', + top: 0, + left: 0, + bottom: 0, + right: 0, + zIndex: tourMaskZIndex, +} as const; + +const _SpotlightPopoverTourMask = ({ + padding, + size, + isTransitioning, +}: SpotlightPopoverTourMaskProps): React.ReactElement => { + const { theme } = useTheme(); + const { width: windowWidth, height: windowHeight } = useWindowSize(); + + const width = size.width + padding; + const height = size.height + padding; + const x = size.x - (width - size.width) / 2; + const y = size.y - (height - size.height) / 2; + + const borderWidth = theme.spacing[1]; + const borderRadius = theme.spacing[2]; + + const isSizeZero = size.width === 0 || size.height === 0; + + return ( + + + + + + {!isSizeZero && ( + + )} + + + + ); +}; + +const SpotlightPopoverTourMask = assignWithoutSideEffects(React.memo(_SpotlightPopoverTourMask), { + displayName: 'TourMask', +}); +export { SpotlightPopoverTourMask }; diff --git a/packages/blade/src/components/SpotlightPopoverTour/TourPopover.native.tsx b/packages/blade/src/components/SpotlightPopoverTour/TourPopover.native.tsx new file mode 100644 index 00000000000..0a4efa88d86 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/TourPopover.native.tsx @@ -0,0 +1,14 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { throwBladeError } from '~utils/logger'; + +const TourPopover = (_: any): React.ReactElement => { + throwBladeError({ + message: 'Tour is not yet implemented for native', + moduleName: 'Tour', + }); + + return <>; +}; + +export { TourPopover }; diff --git a/packages/blade/src/components/SpotlightPopoverTour/TourPopover.web.tsx b/packages/blade/src/components/SpotlightPopoverTour/TourPopover.web.tsx new file mode 100644 index 00000000000..8059261eef3 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/TourPopover.web.tsx @@ -0,0 +1,182 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +/* eslint-disable @typescript-eslint/restrict-plus-operands */ +import { + shift, + FloatingPortal, + arrow, + flip, + offset, + useFloating, + useInteractions, + useRole, + useTransitionStyles, + autoUpdate, + useClick, + useDismiss, + FloatingFocusManager, +} from '@floating-ui/react'; +import React from 'react'; +import { PopoverContent } from '../Popover/PopoverContent'; +import { ARROW_HEIGHT, ARROW_WIDTH, popoverZIndex } from '../Popover/constants'; +import { PopoverContext } from '../Popover/PopoverContext'; +import { transitionDelay } from './tourTokens'; +import { useTheme } from '~components/BladeProvider'; +import BaseBox from '~components/Box/BaseBox'; +import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; +import { size } from '~tokens/global'; +import { useControllableState } from '~utils/useControllable'; +import { PopupArrow } from '~components/PopupArrow'; +import { makeAccessible } from '~utils/makeAccessible'; +import { useId } from '~utils/useId'; +import { getFloatingPlacementParts } from '~utils/getFloatingPlacementParts'; +import type { PopoverProps } from '~components/Popover'; + +type TourPopoverProps = Omit & { + attachTo: React.RefObject | undefined; + isTransitioning: boolean; +}; + +// TODO: Refactor out Popover/FloatingUI logic to a reusable hook/component later on +const TourPopover = ({ + attachTo, + content, + title, + titleLeading, + footer, + placement = 'top', + onOpenChange, + zIndex = popoverZIndex, + isOpen, + defaultIsOpen, + isTransitioning, +}: TourPopoverProps): React.ReactElement => { + const { theme } = useTheme(); + const defaultInitialFocusRef = React.useRef(null); + const arrowRef = React.useRef(null); + const titleId = useId('popover-title'); + + const GAP = theme.spacing[4]; + const [side] = getFloatingPlacementParts(placement); + const isHorizontal = side === 'left' || side === 'right'; + const isOppositeAxis = side === 'right' || side === 'bottom'; + + const [controllableIsOpen, controllableSetIsOpen] = useControllableState({ + value: isOpen, + defaultValue: defaultIsOpen, + onChange: (isOpen) => onOpenChange?.({ isOpen }), + }); + + const { refs, floatingStyles, context, placement: computedPlacement } = useFloating({ + open: controllableIsOpen, + onOpenChange: (isOpen) => controllableSetIsOpen(() => isOpen), + placement, + strategy: 'fixed', + middleware: [ + shift({ crossAxis: false, padding: GAP }), + flip({ padding: GAP, fallbackAxisSideDirection: 'end' }), + offset(GAP + ARROW_HEIGHT), + arrow({ + element: arrowRef, + padding: isHorizontal ? GAP + ARROW_HEIGHT : ARROW_WIDTH, + }), + ], + transform: true, + whileElementsMounted: autoUpdate, + }); + + const close = React.useCallback(() => { + controllableSetIsOpen(() => false); + }, [controllableSetIsOpen]); + + // we need to animate from the offset of the computed placement + // because placement can change dynamically based on available space + const [computedSide] = getFloatingPlacementParts(computedPlacement); + const computedIsHorizontal = computedSide === 'left' || computedSide === 'right'; + const animationOffset = isOppositeAxis ? -size[4] : size[4]; + + const { isMounted, styles } = useTransitionStyles(context, { + duration: { + open: transitionDelay, + close: theme.motion.duration.xquick, + }, + initial: { + opacity: 0, + transform: `translate${computedIsHorizontal ? 'X' : 'Y'}(${animationOffset}px)`, + }, + }); + + // remove click handler if popover is controlled + const isControlled = isOpen !== undefined; + const click = useClick(context, { enabled: !isControlled }); + const dismiss = useDismiss(context); + const role = useRole(context); + + const { getFloatingProps } = useInteractions([click, dismiss, role]); + + const contextValue = React.useMemo(() => { + return { + close, + defaultInitialFocusRef, + titleId, + }; + }, [close, titleId]); + + // https://github.com/floating-ui/floating-ui/discussions/2352#discussioncomment-6044834 + React.useLayoutEffect(() => { + window.setTimeout(() => { + if (!attachTo) return; + refs.setReference(attachTo.current); + refs.setPositionReference(attachTo.current); + }); + }, [attachTo, refs, isOpen]); + + return ( + + + + {isMounted ? ( + + + } + > + {content} + + + ) : ( + <> + )} + + + + ); +}; + +export { TourPopover }; diff --git a/packages/blade/src/components/SpotlightPopoverTour/TourStep.native.tsx b/packages/blade/src/components/SpotlightPopoverTour/TourStep.native.tsx new file mode 100644 index 00000000000..8c775b3cc9e --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/TourStep.native.tsx @@ -0,0 +1,14 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import type { SpotlightPopoverTourStepProps } from './types'; +import { throwBladeError } from '~utils/logger'; + +const SpotlightPopoverTourStep = (_props: SpotlightPopoverTourStepProps): React.ReactElement => { + throwBladeError({ + message: 'TourStep is not yet implemented for native', + moduleName: 'TourStep', + }); + + return <>; +}; + +export { SpotlightPopoverTourStep }; diff --git a/packages/blade/src/components/SpotlightPopoverTour/TourStep.web.tsx b/packages/blade/src/components/SpotlightPopoverTour/TourStep.web.tsx new file mode 100644 index 00000000000..d270525bf18 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/TourStep.web.tsx @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable consistent-return */ +import React from 'react'; +import { useTourContext } from './TourContext'; +import type { SpotlightPopoverTourStepProps } from './types'; +import { mergeRefs } from '~utils/useMergeRefs'; +import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; + +const _SpotlightPopoverTourStep = ({ + name, + children, +}: SpotlightPopoverTourStepProps): React.ReactElement => { + const ref = React.useRef(null); + const { attachStep, removeStep } = useTourContext(); + + React.useLayoutEffect(() => { + if (!ref) return; + attachStep(name, ref); + + return () => { + removeStep(name); + }; + }, [ref, attachStep, name, removeStep]); + + const child = children as React.ReactElement; + return React.cloneElement(child, { + ...child.props, + ref: mergeRefs(ref, (child as any)?.ref), + }); +}; + +const SpotlightPopoverTourStep = assignWithoutSideEffects(React.memo(_SpotlightPopoverTourStep), { + displayName: 'TourStep', +}); + +export { SpotlightPopoverTourStep }; diff --git a/packages/blade/src/components/SpotlightPopoverTour/_KitchenSink.Tour.stories.tsx b/packages/blade/src/components/SpotlightPopoverTour/_KitchenSink.Tour.stories.tsx new file mode 100644 index 00000000000..dba644d354a --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/_KitchenSink.Tour.stories.tsx @@ -0,0 +1,31 @@ +import { composeStories } from '@storybook/react'; +import * as tourStories from './Tour.stories'; +import { Box } from '~components/Box'; +import { Heading } from '~components/Typography'; + +const allStories = Object.values(composeStories(tourStories)); + +export const Tour = (): JSX.Element => { + return ( + + {allStories.map((Story) => { + return ( + <> + {Story.storyName} + + + ); + })} + + ); +}; + +export default { + title: 'Components/KitchenSink/Tour', + component: Tour, + parameters: { + // enable Chromatic's snapshotting only for kitchensink + chromatic: { disableSnapshot: false }, + options: { showPanel: false }, + }, +}; diff --git a/packages/blade/src/components/SpotlightPopoverTour/__tests__/Tour.ssr.test.tsx b/packages/blade/src/components/SpotlightPopoverTour/__tests__/Tour.ssr.test.tsx new file mode 100644 index 00000000000..5f2147f04ad --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/__tests__/Tour.ssr.test.tsx @@ -0,0 +1,117 @@ +/* eslint-disable blade/no-cross-platform-imports */ +/* eslint-disable @typescript-eslint/require-await */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import React from 'react'; +import { SpotlightPopoverTour } from '../Tour.web'; +import { SpotlightPopoverTourStep } from '../TourStep.web'; +import type { SpotlightPopoverStepRenderProps, SpotlightPopoverTourSteps } from '../types'; +import { SpotlightPopoverTourFooter } from '../TourFooter.web'; +import { Button } from '~components/Button'; +import renderWithSSR from '~utils/testing/renderWithSSR.web'; +import { Box } from '~components/Box'; +import { Text } from '~components/Typography'; + +const nextButtonText = 'Next'; +const prevButtonText = 'Prev'; +const doneButtonText = 'Done'; + +const CustomFooter = ({ + activeStep, + totalSteps, + goToNext, + goToPrevious, + stopTour, +}: SpotlightPopoverStepRenderProps) => { + const isLast = activeStep === totalSteps - 1; + const isFirst = activeStep === 0; + return ( + + ); +}; + +const openTourButtonText = 'Open Tour'; +const steps: SpotlightPopoverTourSteps = [ + { + name: 'step-1', + content: () => Step 1, + placement: 'bottom', + footer: CustomFooter, + }, + { + name: 'step-2', + content: () => Step 2, + placement: 'bottom', + footer: CustomFooter, + }, +]; +const BasicTourExample = () => { + const [activeStep, setActiveStep] = React.useState(0); + const [isOpen, setIsOpen] = React.useState(false); + + return ( + + + { + setActiveStep(0); + setIsOpen(false); + }} + onOpenChange={({ isOpen }) => { + setIsOpen(isOpen); + }} + onStepChange={(step) => { + setActiveStep(step); + }} + > + Some other content inside + + + Trigger 1 + + + + + Trigger 2 + + + + + ); +}; + +describe('', () => { + it('should render Tour ssr', () => { + const { baseElement } = renderWithSSR(); + expect(baseElement).toMatchSnapshot(); + }); +}); diff --git a/packages/blade/src/components/SpotlightPopoverTour/__tests__/Tour.web.test.tsx b/packages/blade/src/components/SpotlightPopoverTour/__tests__/Tour.web.test.tsx new file mode 100644 index 00000000000..b142dd2aba7 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/__tests__/Tour.web.test.tsx @@ -0,0 +1,272 @@ +/* eslint-disable @typescript-eslint/require-await */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { act, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { SpotlightPopoverTour } from '../Tour.web'; +import { SpotlightPopoverTourStep } from '../TourStep.web'; +import type { SpotlightPopoverStepRenderProps, SpotlightPopoverTourSteps } from '../types'; +import { SpotlightPopoverTourFooter } from '../TourFooter.web'; +import { Button } from '~components/Button'; +import { paymentTheme } from '~tokens/theme'; +import renderWithTheme from '~utils/testing/renderWithTheme.web'; +import { Box } from '~components/Box'; +import { Text } from '~components/Typography'; +import assertAccessible from '~utils/testing/assertAccessible.web'; + +const animationDuration = paymentTheme.motion.duration.gentle; +const nextButtonText = 'Next'; +const prevButtonText = 'Prev'; +const doneButtonText = 'Done'; +const onStepChangeFn = jest.fn(); +const onOpenChangeFn = jest.fn(); +const onFinishFn = jest.fn(); + +beforeAll(() => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); +}); +beforeEach(() => { + onStepChangeFn.mockReset(); + onOpenChangeFn.mockReset(); + onFinishFn.mockReset(); +}); + +const CustomFooter = ({ + activeStep, + totalSteps, + goToNext, + goToPrevious, + stopTour, +}: SpotlightPopoverStepRenderProps) => { + const isLast = activeStep === totalSteps - 1; + const isFirst = activeStep === 0; + return ( + + ); +}; + +const openTourButtonText = 'Open Tour'; +const steps: SpotlightPopoverTourSteps = [ + { + name: 'step-1', + content: () => Step 1, + placement: 'bottom', + footer: CustomFooter, + }, + { + name: 'step-2', + content: () => Step 2, + placement: 'bottom', + footer: CustomFooter, + }, +]; +const BasicTourExample = () => { + const [activeStep, setActiveStep] = React.useState(0); + const [isOpen, setIsOpen] = React.useState(false); + + return ( + + + { + setActiveStep(0); + setIsOpen(false); + onFinishFn(); + }} + onOpenChange={({ isOpen }) => { + setIsOpen(isOpen); + onOpenChangeFn(isOpen); + }} + onStepChange={(step) => { + setActiveStep(step); + onStepChangeFn(step); + }} + > + + + Trigger 1 + + + + + Trigger 2 + + + + + ); +}; + +describe('', () => { + jest.useFakeTimers(); + + it('should render', async () => { + const { baseElement, getByRole, queryByRole, queryByText } = renderWithTheme( + , + ); + + expect(queryByText('Step 1')).not.toBeInTheDocument(); + + // snapshot while on opened + fireEvent.click(getByRole('button', { name: openTourButtonText })); + await act(async () => { + jest.advanceTimersByTime(animationDuration); + }); + + expect(queryByRole('dialog')).toBeInTheDocument(); + expect(queryByText('Step 1')).toBeInTheDocument(); + expect(baseElement).toMatchSnapshot(); + }); + + it('should go to next/prev step', async () => { + const { getByRole, queryByRole, queryByText } = renderWithTheme(); + + expect(queryByRole('dialog')).not.toBeInTheDocument(); + expect(queryByText('Step 1')).not.toBeInTheDocument(); + + // snapshot while on opened + fireEvent.click(getByRole('button', { name: openTourButtonText })); + await act(async () => { + jest.advanceTimersByTime(animationDuration); + }); + + expect(queryByRole('dialog')).toBeInTheDocument(); + expect(queryByText('Step 1')).toBeInTheDocument(); + expect(onStepChangeFn).not.toHaveBeenCalled(); + + // go to next step + fireEvent.click(getByRole('button', { name: nextButtonText })); + + await act(async () => { + jest.advanceTimersByTime(animationDuration); + }); + + expect(queryByText('Step 1')).not.toBeInTheDocument(); + expect(queryByText('Step 2')).toBeInTheDocument(); + expect(onStepChangeFn).toHaveBeenCalledWith(1); + + // We are at the end of the tour step, expect done button to be visible + expect(getByRole('button', { name: doneButtonText })).toBeInTheDocument(); + + // got to previous step + fireEvent.click(getByRole('button', { name: prevButtonText })); + + await act(async () => { + jest.advanceTimersByTime(animationDuration); + }); + + expect(queryByText('Step 1')).toBeInTheDocument(); + expect(queryByText('Step 2')).not.toBeInTheDocument(); + expect(onStepChangeFn).toHaveBeenCalledWith(0); + }); + + it('should close on clicking the close button', async () => { + const { getByRole, queryByRole, queryByText } = renderWithTheme(); + + expect(queryByText('Step 1')).not.toBeInTheDocument(); + expect(onOpenChangeFn).not.toHaveBeenCalled(); + + // click on open tour button + fireEvent.click(getByRole('button', { name: openTourButtonText })); + await act(async () => { + jest.advanceTimersByTime(animationDuration); + }); + + expect(queryByRole('dialog')).toBeInTheDocument(); + expect(queryByText('Step 1')).toBeInTheDocument(); + expect(onOpenChangeFn).toHaveBeenCalledWith(true); + + // close the tour + fireEvent.click(getByRole('button', { name: 'Close' })); + + await act(async () => { + jest.advanceTimersByTime(animationDuration); + }); + + expect(queryByRole('dialog')).not.toBeInTheDocument(); + expect(queryByText('Step 1')).not.toBeInTheDocument(); + expect(onOpenChangeFn).toHaveBeenCalledWith(false); + }); + + it('should call onFinish when calling stopTour method', async () => { + const { getByRole, queryByRole, queryByText } = renderWithTheme(); + + expect(queryByText('Step 1')).not.toBeInTheDocument(); + expect(onOpenChangeFn).not.toHaveBeenCalled(); + expect(onFinishFn).not.toHaveBeenCalled(); + + // click on open tour button + fireEvent.click(getByRole('button', { name: openTourButtonText })); + await act(async () => { + jest.advanceTimersByTime(animationDuration); + }); + + expect(queryByRole('dialog')).toBeInTheDocument(); + expect(queryByText('Step 1')).toBeInTheDocument(); + expect(onOpenChangeFn).toHaveBeenCalledWith(true); + expect(onFinishFn).not.toHaveBeenCalled(); + + // Go to last step + fireEvent.click(getByRole('button', { name: nextButtonText })); + await act(async () => { + jest.advanceTimersByTime(animationDuration); + }); + + expect(queryByText('Step 2')).toBeInTheDocument(); + expect(getByRole('button', { name: doneButtonText })).toBeInTheDocument(); + expect(onFinishFn).not.toHaveBeenCalled(); + + // stop tour + fireEvent.click(getByRole('button', { name: doneButtonText })); + + await act(async () => { + jest.advanceTimersByTime(animationDuration); + }); + + expect(queryByText('Step 2')).not.toBeInTheDocument(); + expect(queryByRole('dialog')).not.toBeInTheDocument(); + expect(onFinishFn).toHaveBeenCalled(); + }); + + it('should not have a11y violations', async () => { + const { baseElement, getByRole } = renderWithTheme(); + + // snapshot while on opened + fireEvent.click(getByRole('button', { name: openTourButtonText })); + await act(async () => { + jest.advanceTimersByTime(animationDuration); + }); + + // check for a11y violations + assertAccessible(baseElement); + }); +}); diff --git a/packages/blade/src/components/SpotlightPopoverTour/__tests__/__snapshots__/Tour.ssr.test.tsx.snap b/packages/blade/src/components/SpotlightPopoverTour/__tests__/__snapshots__/Tour.ssr.test.tsx.snap new file mode 100644 index 00000000000..851a71c8054 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/__tests__/__snapshots__/Tour.ssr.test.tsx.snap @@ -0,0 +1,176 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render Tour ssr 1`] = `"
Some other content inside

Trigger 1

Trigger 2

"`; + +exports[` should render Tour ssr 2`] = ` +.c1 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; +} + +.c3 { + color: hsla(0,0%,100%,1); + font-family: Lato,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 0.875rem; + font-weight: 700; + font-style: normal; + -webkit-text-decoration-line: none; + text-decoration-line: none; + line-height: 1.25rem; + text-align: center; + margin: 0; + padding: 0; +} + +.c4 { + color: hsla(217,56%,17%,1); + font-family: Lato,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 0.875rem; + font-weight: 400; + font-style: normal; + -webkit-text-decoration-line: none; + text-decoration-line: none; + line-height: 1.25rem; + margin: 0; + padding: 0; +} + +.c0 { + min-height: 36px; + width: auto; + cursor: pointer; + background-color: hsla(218,89%,51%,1); + border-color: hsla(218,89%,51%,1); + border-width: 1px; + border-radius: 2px; + border-style: solid; + padding-top: 0px; + padding-bottom: 0px; + padding-left: 20px; + padding-right: 20px; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-text-decoration: none; + text-decoration: none; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-transition-property: background-color,border-color,box-shadow; + transition-property: background-color,border-color,box-shadow; + -webkit-transition-timing-function: cubic-bezier(0.3,0,0.2,1); + transition-timing-function: cubic-bezier(0.3,0,0.2,1); + -webkit-transition-duration: 150ms; + transition-duration: 150ms; + position: relative; + margin-bottom: 40px; +} + +.c0:hover { + background-color: hsla(223,95%,48%,1); + border-color: hsla(223,95%,48%,1); +} + +.c0:active { + background-color: hsla(230,100%,42%,1); + border-color: hsla(230,100%,42%,1); +} + +.c0:focus-visible { + background-color: hsla(227,100%,45%,1); + border-color: hsla(227,100%,45%,1); + outline: 1px solid hsla(220,30%,96%,1); + box-shadow: 0px 0px 0px 4px hsla(218,89%,51%,0.18); +} + +.c0 * { + -webkit-transition-property: color,fill; + transition-property: color,fill; + -webkit-transition-duration: 150ms; + transition-duration: 150ms; + -webkit-transition-timing-function: cubic-bezier(0.3,0,0.2,1); + transition-timing-function: cubic-bezier(0.3,0,0.2,1); +} + +.c2 { + opacity: 1; +} + +
+
+ +
+ Some other content inside +
+
+

+ Trigger 1 +

+
+
+

+ Trigger 2 +

+
+
+
+`; diff --git a/packages/blade/src/components/SpotlightPopoverTour/__tests__/__snapshots__/Tour.web.test.tsx.snap b/packages/blade/src/components/SpotlightPopoverTour/__tests__/__snapshots__/Tour.web.test.tsx.snap new file mode 100644 index 00000000000..262e5251851 --- /dev/null +++ b/packages/blade/src/components/SpotlightPopoverTour/__tests__/__snapshots__/Tour.web.test.tsx.snap @@ -0,0 +1,583 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render 1`] = ` +.c1 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; +} + +.c6 { + z-index: 1100; +} + +.c8 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + padding: 12px; + gap: 12px; +} + +.c9 { + position: absolute; + z-index: 1; + padding: 4px; + top: 4px; + right: 4px; + border-radius: 9999px; +} + +.c11 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; + gap: 24px; +} + +.c13 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + gap: 12px; +} + +.c7 { + width: 100%; + max-width: 328px; + position: relative; + background-color: hsla(0,0%,100%,1); + border-width: 1px; + border-radius: 8px; + border-color: hsla(216,19%,89%,1); + border-style: solid; + box-shadow: 0 12px 16px -4px hsla(217,56%,17%,0.08),0 4px 6px -2px hsla(217,56%,17%,0.03); + opacity: 0; + -webkit-transform: translateY(-4px); + -ms-transform: translateY(-4px); + transform: translateY(-4px); +} + +.c10 { + border: none; + cursor: pointer; + padding: 0; + border-radius: 2px; + background: transparent; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + color: hsla(214,18%,69%,1); + -webkit-transition-property: color,box-shadow; + transition-property: color,box-shadow; + -webkit-transition-duration: 150ms; + transition-duration: 150ms; + -webkit-transition-timing-function: cubic-bezier(0.3,0,0.2,1); + transition-timing-function: cubic-bezier(0.3,0,0.2,1); +} + +.c10:hover { + color: hsla(217,18%,45%,1); +} + +.c10:focus-visible { + outline: none; + box-shadow: 0px 0px 0px 4px hsla(218,89%,51%,0.18); + color: hsla(217,18%,45%,1); +} + +.c10:active { + color: hsla(217,18%,45%,1); +} + +.c3 { + color: hsla(0,0%,100%,1); + font-family: Lato,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 0.875rem; + font-weight: 700; + font-style: normal; + -webkit-text-decoration-line: none; + text-decoration-line: none; + line-height: 1.25rem; + text-align: center; + margin: 0; + padding: 0; +} + +.c4 { + color: hsla(217,56%,17%,1); + font-family: Lato,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 0.875rem; + font-weight: 400; + font-style: normal; + -webkit-text-decoration-line: none; + text-decoration-line: none; + line-height: 1.25rem; + margin: 0; + padding: 0; +} + +.c12 { + color: hsla(217,56%,17%,1); + font-family: Lato,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 0.75rem; + font-weight: 700; + font-style: normal; + -webkit-text-decoration-line: none; + text-decoration-line: none; + line-height: 1rem; + margin: 0; + padding: 0; +} + +.c15 { + color: hsla(0,0%,100%,1); + font-family: Lato,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 0.75rem; + font-weight: 700; + font-style: normal; + -webkit-text-decoration-line: none; + text-decoration-line: none; + line-height: 1rem; + text-align: center; + margin: 0; + padding: 0; +} + +.c5 { + -webkit-animation: iNqBts 2s; + animation: iNqBts 2s; + -webkit-animation-iteration-count: infinite; + animation-iteration-count: infinite; + -webkit-animation-direction: alternate; + animation-direction: alternate; +} + +.c0 { + min-height: 36px; + width: auto; + cursor: pointer; + background-color: hsla(218,89%,51%,1); + border-color: hsla(218,89%,51%,1); + border-width: 1px; + border-radius: 2px; + border-style: solid; + padding-top: 0px; + padding-bottom: 0px; + padding-left: 20px; + padding-right: 20px; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-text-decoration: none; + text-decoration: none; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-transition-property: background-color,border-color,box-shadow; + transition-property: background-color,border-color,box-shadow; + -webkit-transition-timing-function: cubic-bezier(0.3,0,0.2,1); + transition-timing-function: cubic-bezier(0.3,0,0.2,1); + -webkit-transition-duration: 150ms; + transition-duration: 150ms; + position: relative; + margin-bottom: 40px; +} + +.c0:hover { + background-color: hsla(223,95%,48%,1); + border-color: hsla(223,95%,48%,1); +} + +.c0:active { + background-color: hsla(230,100%,42%,1); + border-color: hsla(230,100%,42%,1); +} + +.c0:focus-visible { + background-color: hsla(227,100%,45%,1); + border-color: hsla(227,100%,45%,1); + outline: 1px solid hsla(220,30%,96%,1); + box-shadow: 0px 0px 0px 4px hsla(218,89%,51%,0.18); +} + +.c0 * { + -webkit-transition-property: color,fill; + transition-property: color,fill; + -webkit-transition-duration: 150ms; + transition-duration: 150ms; + -webkit-transition-timing-function: cubic-bezier(0.3,0,0.2,1); + transition-timing-function: cubic-bezier(0.3,0,0.2,1); +} + +.c14 { + min-height: 32px; + width: auto; + cursor: pointer; + background-color: hsla(218,89%,51%,1); + border-color: hsla(218,89%,51%,1); + border-width: 1px; + border-radius: 2px; + border-style: solid; + padding-top: 0px; + padding-bottom: 0px; + padding-left: 12px; + padding-right: 12px; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-text-decoration: none; + text-decoration: none; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-transition-property: background-color,border-color,box-shadow; + transition-property: background-color,border-color,box-shadow; + -webkit-transition-timing-function: cubic-bezier(0.3,0,0.2,1); + transition-timing-function: cubic-bezier(0.3,0,0.2,1); + -webkit-transition-duration: 150ms; + transition-duration: 150ms; + position: relative; +} + +.c14:hover { + background-color: hsla(223,95%,48%,1); + border-color: hsla(223,95%,48%,1); +} + +.c14:active { + background-color: hsla(230,100%,42%,1); + border-color: hsla(230,100%,42%,1); +} + +.c14:focus-visible { + background-color: hsla(227,100%,45%,1); + border-color: hsla(227,100%,45%,1); + outline: 1px solid hsla(220,30%,96%,1); + box-shadow: 0px 0px 0px 4px hsla(218,89%,51%,0.18); +} + +.c14 * { + -webkit-transition-property: color,fill; + transition-property: color,fill; + -webkit-transition-duration: 150ms; + transition-duration: 150ms; + -webkit-transition-timing-function: cubic-bezier(0.3,0,0.2,1); + transition-timing-function: cubic-bezier(0.3,0,0.2,1); +} + +.c2 { + opacity: 1; +} + + + + +
+
+