You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

SpotlightTour.tsx 8.4KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2024 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import { keyframes } from '@emotion/react';
  21. import styled from '@emotion/styled';
  22. import React from 'react';
  23. import { useIntl } from 'react-intl';
  24. import ReactJoyride, {
  25. Props as JoyrideProps,
  26. Step as JoyrideStep,
  27. TooltipRenderProps,
  28. } from 'react-joyride';
  29. import tw from 'twin.macro';
  30. import { GLOBAL_POPUP_Z_INDEX, PopupZLevel, themeColor } from '../helpers';
  31. import { findAnchor } from '../helpers/dom';
  32. import { ButtonPrimary } from '../sonar-aligned/components/buttons';
  33. import { ButtonLink, WrapperButton } from './buttons';
  34. import { CloseIcon } from './icons';
  35. import { PopupWrapper } from './popups';
  36. type Placement = 'left' | 'right' | 'top' | 'bottom' | 'center';
  37. export interface SpotlightTourProps extends Omit<JoyrideProps, 'steps'> {
  38. backLabel?: string;
  39. closeLabel?: string;
  40. nextLabel?: string;
  41. skipLabel?: string;
  42. stepXofYLabel?: (x: number, y: number) => string;
  43. steps: SpotlightTourStep[];
  44. width?: number;
  45. }
  46. export type SpotlightTourStep = JoyrideStep & {
  47. placement?: Placement;
  48. };
  49. // React Joyride needs a "global" property to be defined on window. It will throw an error if it cannot find it.
  50. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
  51. (window as any).global = (window as any).global ?? {};
  52. const PULSE_SIZE = 8;
  53. const DEFAULT_PLACEMENT = 'bottom';
  54. const DEFAULT_WIDTH = 315;
  55. const defultRect = new DOMRect(0, 0, 0, 0);
  56. function TooltipComponent({
  57. continuous,
  58. index,
  59. step,
  60. size,
  61. isLastStep,
  62. backProps,
  63. skipProps,
  64. closeProps,
  65. primaryProps,
  66. stepXofYLabel,
  67. tooltipProps,
  68. width = DEFAULT_WIDTH,
  69. }: TooltipRenderProps & {
  70. step: SpotlightTourStep;
  71. stepXofYLabel: SpotlightTourProps['stepXofYLabel'];
  72. width?: number;
  73. }) {
  74. const [timeStamp, setTimeStamp] = React.useState(0);
  75. const [ref, setRef] = React.useState<HTMLDivElement | null>(null);
  76. const placement = step.placement ?? DEFAULT_PLACEMENT;
  77. const intl = useIntl();
  78. React.useEffect(() => {
  79. const target =
  80. typeof step.target === 'string' ? document.querySelector(step.target) : step.target;
  81. // To show the highlight, target has to be HighlightRing from design system
  82. target?.classList.add('active');
  83. return () => {
  84. target?.classList.remove('active');
  85. };
  86. }, [step]);
  87. React.useEffect(() => {
  88. const updateScroll = (event: Event) => {
  89. // The spotlight is doint transition that would look strange when we
  90. // re-render arrow right away.
  91. setTimeout(() => {
  92. setTimeStamp(event.timeStamp);
  93. }, 0);
  94. };
  95. document.addEventListener('scroll', updateScroll, { capture: true });
  96. return () => {
  97. document.removeEventListener('scroll', updateScroll, { capture: true });
  98. };
  99. }, []);
  100. const rect = ref?.parentElement?.getBoundingClientRect();
  101. const targetElement =
  102. typeof step.target === 'string'
  103. ? document.querySelector<HTMLElement>(step.target)
  104. : step.target;
  105. const targetRect = targetElement?.getBoundingClientRect();
  106. /**
  107. * Preventing click events from bubbling to avoid closing other popups, in cases when the guide
  108. * is shown simultaneously with other popups.
  109. */
  110. function handleClick(e: React.MouseEvent) {
  111. e.stopPropagation();
  112. }
  113. const arrowPosition = React.useMemo(
  114. () => findAnchor(rect ?? defultRect, targetRect ?? defultRect, PULSE_SIZE),
  115. [rect, targetRect, timeStamp],
  116. );
  117. return (
  118. <StyledPopupWrapper
  119. className="sw-p-3 sw-body-sm sw-relative sw-border-0"
  120. onClick={handleClick}
  121. style={{ width }}
  122. zLevel={PopupZLevel.Absolute}
  123. {...tooltipProps}
  124. >
  125. {placement !== 'center' && (
  126. <SpotlightArrowWrapper left={arrowPosition.left} top={arrowPosition.top}>
  127. <SpotlightArrow rotate={arrowPosition.rotate} width={arrowPosition.width} />
  128. </SpotlightArrowWrapper>
  129. )}
  130. <div className="sw-flex sw-justify-between" ref={setRef}>
  131. <strong className="sw-body-md-highlight sw-mb-2">{step.title}</strong>
  132. <WrapperButton
  133. className="sw-w-[30px] sw-h-[30px] sw--mt-2 sw--mr-2 sw-flex sw-justify-center"
  134. {...skipProps}
  135. >
  136. <CloseIcon className="sw-mr-0" />
  137. </WrapperButton>
  138. </div>
  139. <div>{step.content}</div>
  140. <div className="sw-flex sw-justify-between sw-items-center sw-mt-4">
  141. {(stepXofYLabel || size > 1) && (
  142. <strong>
  143. {stepXofYLabel
  144. ? stepXofYLabel(index + 1, size)
  145. : intl.formatMessage({ id: 'guiding.step_x_of_y' }, { '0': index + 1, '1': size })}
  146. </strong>
  147. )}
  148. <span />
  149. <div>
  150. {index > 0 && (
  151. <ButtonLink className="sw-mr-4" {...backProps}>
  152. {backProps.title}
  153. </ButtonLink>
  154. )}
  155. {continuous && !isLastStep && (
  156. <ButtonPrimary {...primaryProps}>{primaryProps.title}</ButtonPrimary>
  157. )}
  158. {(!continuous || isLastStep) && (
  159. <ButtonPrimary {...closeProps}>{closeProps.title}</ButtonPrimary>
  160. )}
  161. </div>
  162. </div>
  163. </StyledPopupWrapper>
  164. );
  165. }
  166. export function SpotlightTour(props: SpotlightTourProps) {
  167. const {
  168. steps,
  169. skipLabel,
  170. backLabel,
  171. closeLabel,
  172. nextLabel,
  173. stepXofYLabel,
  174. disableOverlay = true,
  175. width,
  176. ...otherProps
  177. } = props;
  178. const intl = useIntl();
  179. return (
  180. <ReactJoyride
  181. disableOverlay={disableOverlay}
  182. floaterProps={{
  183. styles: {
  184. floater: {
  185. zIndex: GLOBAL_POPUP_Z_INDEX,
  186. },
  187. },
  188. hideArrow: true,
  189. offset: 0,
  190. }}
  191. locale={{
  192. skip: skipLabel ?? intl.formatMessage({ id: 'skip' }),
  193. back: backLabel ?? intl.formatMessage({ id: 'go_back' }),
  194. close: closeLabel ?? intl.formatMessage({ id: 'close' }),
  195. next: nextLabel ?? intl.formatMessage({ id: 'next' }),
  196. }}
  197. scrollDuration={0}
  198. scrollOffset={250}
  199. steps={steps.map((s) => ({
  200. ...s,
  201. disableScrolling: true,
  202. disableBeacon: true,
  203. floaterProps: {
  204. disableAnimation: true,
  205. offset: 0,
  206. },
  207. }))}
  208. tooltipComponent={(
  209. tooltipProps: React.PropsWithChildren<TooltipRenderProps & { step: SpotlightTourStep }>,
  210. ) => <TooltipComponent stepXofYLabel={stepXofYLabel} width={width} {...tooltipProps} />}
  211. {...otherProps}
  212. />
  213. );
  214. }
  215. const StyledPopupWrapper = styled(PopupWrapper)`
  216. background-color: ${themeColor('spotlightBackgroundColor')};
  217. ${tw`sw-overflow-visible`};
  218. ${tw`sw-rounded-1`};
  219. `;
  220. const SpotlightArrowWrapper = styled.div<{ left: number; top: number }>`
  221. ${tw`sw-absolute`}
  222. ${tw`sw-z-popup`}
  223. width: ${PULSE_SIZE}px;
  224. height: ${PULSE_SIZE}px;
  225. left: ${({ left }) => left}px;
  226. top: ${({ top }) => top}px;
  227. `;
  228. const pulseKeyFrame = keyframes`
  229. 0% { transform: scale(.50) }
  230. 80%, 100% { opacity: 0 }
  231. `;
  232. const SpotlightArrow = styled.div<{ rotate: string; width: number }>`
  233. ${tw`sw-w-full sw-h-full`}
  234. ${tw`sw-rounded-pill`}
  235. background: ${themeColor('spotlightPulseBackground')};
  236. opacity: 1;
  237. transform: rotate(${({ rotate }) => rotate});
  238. &::after {
  239. ${tw`sw-block sw-absolute`}
  240. ${tw`sw-rounded-pill`}
  241. top: -100%;
  242. left: -100%;
  243. width: 300%;
  244. height: 300%;
  245. background-color: ${themeColor('spotlightPulseBackground')};
  246. animation: ${pulseKeyFrame} 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
  247. content: '';
  248. }
  249. &::before {
  250. ${tw`sw-block sw-absolute`}
  251. width: ${({ width }) => width}px;
  252. height: 0.125rem;
  253. background-color: ${themeColor('spotlightPulseBackground')};
  254. left: 100%;
  255. top: calc(50% - calc(0.125rem / 2));
  256. transition:
  257. margin 0.3s,
  258. left 0.3s;
  259. content: '';
  260. }
  261. `;