/* * SonarQube * Copyright (C) 2009-2024 SonarSource SA * mailto:info AT sonarsource DOT com * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3 of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { keyframes } from '@emotion/react'; import styled from '@emotion/styled'; import { LinkStandalone } from '@sonarsource/echoes-react'; import React from 'react'; import { useIntl } from 'react-intl'; import ReactJoyride, { Props as JoyrideProps, Step as JoyrideStep, TooltipRenderProps, } from 'react-joyride'; import { LinkProps } from 'react-router-dom'; import tw from 'twin.macro'; import { GLOBAL_POPUP_Z_INDEX, PopupZLevel, themeColor } from '../helpers'; import { findAnchor } from '../helpers/dom'; import { ButtonPrimary } from '../sonar-aligned/components/buttons'; import { ButtonLink, WrapperButton } from './buttons'; import { CloseIcon } from './icons'; import { PopupWrapper } from './popups'; type Placement = 'left' | 'right' | 'top' | 'bottom' | 'center'; export interface SpotlightTourProps extends Omit { actionLabel?: string; actionPath?: LinkProps['to']; backLabel?: string; closeLabel?: string; nextLabel?: string; skipLabel?: string; stepXofYLabel?: (x: number, y: number) => string; steps: SpotlightTourStep[]; width?: number; } export type SpotlightTourStep = JoyrideStep & { placement?: Placement; }; // React Joyride needs a "global" property to be defined on window. It will throw an error if it cannot find it. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access (window as any).global = (window as any).global ?? {}; const PULSE_SIZE = 8; const DEFAULT_PLACEMENT = 'bottom'; const DEFAULT_WIDTH = 315; const defultRect = new DOMRect(0, 0, 0, 0); function TooltipComponent({ actionLabel, actionPath, continuous, index, step, size, isLastStep, backProps, skipProps, closeProps, primaryProps, stepXofYLabel, tooltipProps, width = DEFAULT_WIDTH, }: TooltipRenderProps & { actionLabel?: string; actionPath?: LinkProps['to']; step: SpotlightTourStep; stepXofYLabel: SpotlightTourProps['stepXofYLabel']; width?: number; }) { const [timeStamp, setTimeStamp] = React.useState(0); const [ref, setRef] = React.useState(null); const placement = step.placement ?? DEFAULT_PLACEMENT; const intl = useIntl(); React.useEffect(() => { const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target; // To show the highlight, target has to be HighlightRing from design system target?.classList.add('active'); return () => { target?.classList.remove('active'); }; }, [step]); React.useEffect(() => { const updateScroll = (event: Event) => { // The spotlight is doint transition that would look strange when we // re-render arrow right away. setTimeout(() => { setTimeStamp(event.timeStamp); }, 0); }; document.addEventListener('scroll', updateScroll, { capture: true }); return () => { document.removeEventListener('scroll', updateScroll, { capture: true }); }; }, []); const rect = ref?.parentElement?.getBoundingClientRect(); const targetElement = typeof step.target === 'string' ? document.querySelector(step.target) : step.target; const targetRect = targetElement?.getBoundingClientRect(); /** * Preventing click events from bubbling to avoid closing other popups, in cases when the guide * is shown simultaneously with other popups. */ function handleClick(e: React.MouseEvent) { e.stopPropagation(); } const arrowPosition = React.useMemo( () => findAnchor(rect ?? defultRect, targetRect ?? defultRect, PULSE_SIZE), [rect, targetRect, timeStamp], ); return ( {placement !== 'center' && ( )}
{ setRef(r); setTimeout(() => { setTimeStamp(1); }, 100); }} > {step.title}
{step.content}
{actionLabel && actionPath && (
{actionLabel}
)}
{(stepXofYLabel || size > 1) && ( {stepXofYLabel ? stepXofYLabel(index + 1, size) : intl.formatMessage({ id: 'guiding.step_x_of_y' }, { '0': index + 1, '1': size })} )}
{index > 0 && ( {backProps.title} )} {continuous && !isLastStep && ( {primaryProps.title} )} {(!continuous || isLastStep) && ( {closeProps.title} )}
); } export function SpotlightTour(props: SpotlightTourProps) { const { actionLabel, actionPath, steps, skipLabel, backLabel, closeLabel, nextLabel, stepXofYLabel, disableOverlay = true, width, ...otherProps } = props; const intl = useIntl(); return ( ({ ...s, disableScrolling: true, disableBeacon: true, floaterProps: { disableAnimation: true, offset: 0, }, }))} tooltipComponent={( tooltipProps: React.PropsWithChildren, ) => ( )} {...otherProps} /> ); } const StyledPopupWrapper = styled(PopupWrapper)` background-color: ${themeColor('spotlightBackgroundColor')}; ${tw`sw-overflow-visible`}; ${tw`sw-rounded-1`}; `; const SpotlightArrowWrapper = styled.div<{ left: number; top: number }>` ${tw`sw-absolute`} ${tw`sw-z-popup`} width: ${PULSE_SIZE}px; height: ${PULSE_SIZE}px; left: ${({ left }) => left}px; top: ${({ top }) => top}px; `; const pulseKeyFrame = keyframes` 0% { transform: scale(.50) } 80%, 100% { opacity: 0 } `; const SpotlightArrow = styled.div<{ rotate: string; width: number }>` ${tw`sw-w-full sw-h-full`} ${tw`sw-rounded-pill`} background: ${themeColor('spotlightPulseBackground')}; opacity: 1; transform: rotate(${({ rotate }) => rotate}); &::after { ${tw`sw-block sw-absolute`} ${tw`sw-rounded-pill`} top: -100%; left: -100%; width: 300%; height: 300%; background-color: ${themeColor('spotlightPulseBackground')}; animation: ${pulseKeyFrame} 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite; content: ''; } &::before { ${tw`sw-block sw-absolute`} width: ${({ width }) => width}px; height: 0.125rem; background-color: ${themeColor('spotlightPulseBackground')}; left: 100%; top: calc(50% - calc(0.125rem / 2)); transition: margin 0.3s, left 0.3s; content: ''; } `;