aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/design-system/src
diff options
context:
space:
mode:
authorWouter Admiraal <wouter.admiraal@sonarsource.com>2023-08-07 14:33:14 +0200
committersonartech <sonartech@sonarsource.com>2023-08-18 20:02:48 +0000
commit88bb95d78ab8e02a11344a4e3d482ae2e48492a6 (patch)
treeaf443c62fc1f8b3e5866b590d15b93dc220fccc5 /server/sonar-web/design-system/src
parent76650fecfb023f0b38da076b008f8f9edefa03bd (diff)
downloadsonarqube-88bb95d78ab8e02a11344a4e3d482ae2e48492a6.tar.gz
sonarqube-88bb95d78ab8e02a11344a4e3d482ae2e48492a6.zip
SONAR-20023 Update guide styling to comply with Spotlight from design system
Diffstat (limited to 'server/sonar-web/design-system/src')
-rw-r--r--server/sonar-web/design-system/src/components/InteractiveIcon.tsx16
-rw-r--r--server/sonar-web/design-system/src/components/SpotlightTour.tsx275
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/SpotlightTour-test.tsx153
-rw-r--r--server/sonar-web/design-system/src/components/icons/Icon.tsx1
-rw-r--r--server/sonar-web/design-system/src/components/index.ts1
-rw-r--r--server/sonar-web/design-system/src/helpers/constants.ts2
-rw-r--r--server/sonar-web/design-system/src/theme/light.ts4
7 files changed, 442 insertions, 10 deletions
diff --git a/server/sonar-web/design-system/src/components/InteractiveIcon.tsx b/server/sonar-web/design-system/src/components/InteractiveIcon.tsx
index 8274c3c93f0..00fee474192 100644
--- a/server/sonar-web/design-system/src/components/InteractiveIcon.tsx
+++ b/server/sonar-web/design-system/src/components/InteractiveIcon.tsx
@@ -27,8 +27,8 @@ import { OPACITY_20_PERCENT } from '../helpers/constants';
import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
import { isDefined } from '../helpers/types';
import { ThemedProps } from '../types/theme';
-import { IconProps } from './icons/Icon';
import { BaseLink, LinkProps } from './Link';
+import { IconProps } from './icons/Icon';
export type InteractiveIconSize = 'small' | 'medium';
@@ -39,6 +39,7 @@ export interface InteractiveIconProps {
className?: string;
currentColor?: boolean;
disabled?: boolean;
+ iconProps?: IconProps;
id?: string;
innerRef?: React.Ref<HTMLButtonElement>;
onClick?: VoidFunction;
@@ -69,6 +70,7 @@ export class InteractiveIconBase extends React.PureComponent<InteractiveIconProp
onClick,
size = 'medium',
to,
+ iconProps = {},
...htmlProps
} = this.props;
@@ -82,14 +84,8 @@ export class InteractiveIconBase extends React.PureComponent<InteractiveIconProp
if (to) {
return (
- <IconLink
- {...props}
- onClick={onClick}
- showExternalIcon={false}
- stopPropagation
- to={to}
- >
- <Icon className={classNames({ 'sw-mr-1': isDefined(children) })} />
+ <IconLink {...props} onClick={onClick} showExternalIcon={false} stopPropagation to={to}>
+ <Icon className={classNames({ 'sw-mr-1': isDefined(children) })} {...iconProps} />
{children}
</IconLink>
);
@@ -97,7 +93,7 @@ export class InteractiveIconBase extends React.PureComponent<InteractiveIconProp
return (
<IconButton {...props} onClick={this.handleClick} ref={innerRef}>
- <Icon className={classNames({ 'sw-mr-1': isDefined(children) })} />
+ <Icon className={classNames({ 'sw-mr-1': isDefined(children) })} {...iconProps} />
{children}
</IconButton>
);
diff --git a/server/sonar-web/design-system/src/components/SpotlightTour.tsx b/server/sonar-web/design-system/src/components/SpotlightTour.tsx
new file mode 100644
index 00000000000..562389f56c6
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/SpotlightTour.tsx
@@ -0,0 +1,275 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 React from 'react';
+import ReactJoyride, {
+ Props as JoyrideProps,
+ Step as JoyrideStep,
+ TooltipRenderProps,
+} from 'react-joyride';
+import tw from 'twin.macro';
+import { GLOBAL_POPUP_Z_INDEX, PopupZLevel, themeColor } from '../helpers';
+import { translate, translateWithParameters } from '../helpers/l10n';
+import { ButtonLink, ButtonPrimary, WrapperButton } from './buttons';
+import { CloseIcon } from './icons';
+import { PopupWrapper } from './popups';
+
+type Placement = 'left' | 'right' | 'top' | 'bottom' | 'center';
+
+export interface SpotlightTourProps extends Omit<JoyrideProps, 'steps'> {
+ backLabel?: string;
+ closeLabel?: string;
+ nextLabel?: string;
+ skipLabel?: string;
+ stepXofYLabel?: (x: number, y: number) => string;
+ steps: SpotlightTourStep[];
+}
+
+export type SpotlightTourStep = Pick<JoyrideStep, 'target' | 'content' | 'title'> & {
+ 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 ARROW_LENGTH = 40;
+const DEFAULT_PLACEMENT = 'bottom';
+
+function TooltipComponent({
+ continuous,
+ index,
+ step,
+ size,
+ isLastStep,
+ backProps,
+ skipProps,
+ closeProps,
+ primaryProps,
+ stepXofYLabel,
+ tooltipProps,
+}: TooltipRenderProps & {
+ step: SpotlightTourStep;
+ stepXofYLabel: SpotlightTourProps['stepXofYLabel'];
+}) {
+ const [arrowPosition, setArrowPosition] = React.useState({ left: 0, top: 0, rotate: '0deg' });
+ const ref = React.useRef<HTMLDivElement | null>(null);
+ const setRef = React.useCallback((node: HTMLDivElement) => {
+ ref.current = node;
+ }, []);
+ const placement = step.placement ?? DEFAULT_PLACEMENT;
+
+ React.useEffect(() => {
+ // We don't compute for "center"; "center" will simply not show any arrow.
+ if (placement !== 'center' && ref.current?.parentNode) {
+ let left = 0;
+ let top = 0;
+ let rotate = '0deg';
+ const rect = (ref.current.parentNode as HTMLDivElement).getBoundingClientRect();
+ const targetRect =
+ typeof step.target === 'string'
+ ? (document.querySelector(step.target) as HTMLElement).getBoundingClientRect()
+ : step.target.getBoundingClientRect();
+
+ if (placement === 'right') {
+ left = -ARROW_LENGTH - PULSE_SIZE;
+ top = Math.abs(targetRect.y - rect.y) + targetRect.height / 2 - PULSE_SIZE / 2;
+ rotate = '0deg';
+ } else if (placement === 'left') {
+ left = rect.width + ARROW_LENGTH + PULSE_SIZE;
+ top = Math.abs(targetRect.y - rect.y) + targetRect.height / 2 - PULSE_SIZE / 2;
+ rotate = '180deg';
+ } else if (placement === 'bottom') {
+ left = Math.abs(targetRect.x - rect.x) + targetRect.width / 2 - PULSE_SIZE / 2;
+ top = -ARROW_LENGTH - PULSE_SIZE;
+ rotate = '90deg';
+ } else if (placement === 'top') {
+ left = Math.abs(targetRect.x - rect.x) + targetRect.width / 2 - PULSE_SIZE / 2;
+ top = rect.height + ARROW_LENGTH + PULSE_SIZE;
+ rotate = '-90deg';
+ }
+
+ setArrowPosition({ left, top, rotate });
+ }
+ }, [step, ref, setArrowPosition, placement]);
+
+ return (
+ <StyledPopupWrapper
+ className="sw-p-3 sw-body-sm sw-w-[300px] sw-relative sw-border-0"
+ placement={(step.placement as Placement | undefined) ?? DEFAULT_PLACEMENT}
+ zLevel={PopupZLevel.Absolute}
+ {...tooltipProps}
+ >
+ {placement !== 'center' && (
+ <SpotlightArrowWrapper left={arrowPosition.left} top={arrowPosition.top}>
+ <SpotlightArrow rotate={arrowPosition.rotate} />
+ </SpotlightArrowWrapper>
+ )}
+
+ <div className="sw-flex sw-justify-between" ref={setRef}>
+ <strong className="sw-mb-2">{step.title}</strong>
+ <WrapperButton
+ className="sw-w-[30px] sw-h-[30px] sw--mt-2 sw--mr-2 sw-flex sw-justify-center"
+ {...skipProps}
+ >
+ <CloseIcon className="sw-mr-0" />
+ </WrapperButton>
+ </div>
+ <div>{step.content}</div>
+ <div className="sw-flex sw-justify-between sw-items-center sw-mt-3">
+ <strong>
+ {stepXofYLabel
+ ? stepXofYLabel(index + 1, size)
+ : translateWithParameters('guiding.step_x_of_y', index + 1, size)}
+ </strong>
+ <div>
+ {index > 0 && (
+ <ButtonLink className="sw-mr-4" {...backProps}>
+ {backProps.title}
+ </ButtonLink>
+ )}
+ {continuous && !isLastStep && (
+ <ButtonPrimary {...primaryProps}>{primaryProps.title}</ButtonPrimary>
+ )}
+ {(!continuous || isLastStep) && (
+ <ButtonPrimary {...closeProps}>{closeProps.title}</ButtonPrimary>
+ )}
+ </div>
+ </div>
+ </StyledPopupWrapper>
+ );
+}
+
+export function SpotlightTour(props: SpotlightTourProps) {
+ const { steps, skipLabel, backLabel, closeLabel, nextLabel, stepXofYLabel, ...otherProps } =
+ props;
+
+ return (
+ <ReactJoyride
+ disableOverlay
+ floaterProps={{
+ styles: {
+ floater: {
+ zIndex: GLOBAL_POPUP_Z_INDEX,
+ },
+ },
+ hideArrow: true,
+ offset: 0,
+ }}
+ locale={{
+ skip: skipLabel ?? translate('skip'),
+ back: backLabel ?? translate('go_back'),
+ close: closeLabel ?? translate('close'),
+ next: nextLabel ?? translate('next'),
+ }}
+ scrollDuration={0}
+ scrollOffset={250}
+ steps={steps.map((s) => ({
+ ...s,
+ disableScrolling: true,
+ disableBeacon: true,
+ floaterProps: {
+ disableAnimation: true,
+ offset: 0,
+ },
+ }))}
+ tooltipComponent={(
+ tooltipProps: React.PropsWithChildren<TooltipRenderProps & { step: SpotlightTourStep }>
+ ) => <TooltipComponent stepXofYLabel={stepXofYLabel} {...tooltipProps} />}
+ {...otherProps}
+ />
+ );
+}
+
+const StyledPopupWrapper = styled(PopupWrapper)<{ placement: Placement }>`
+ background-color: ${themeColor('spotlightBackgroundColor')};
+ ${tw`sw-overflow-visible`};
+ ${({ placement }) => getStyledPopupWrapperMargin(placement)};
+`;
+
+function getStyledPopupWrapperMargin(placement: Placement) {
+ switch (placement) {
+ case 'left':
+ return `margin-right: 2rem`;
+
+ case 'right':
+ return `margin-left: 2rem`;
+
+ case 'bottom':
+ return `margin-top: 2rem`;
+
+ case 'top':
+ return `margin-bottom: 2rem`;
+
+ default:
+ return null;
+ }
+}
+
+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 }>`
+ ${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: ${ARROW_LENGTH}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: '';
+ }
+`;
diff --git a/server/sonar-web/design-system/src/components/__tests__/SpotlightTour-test.tsx b/server/sonar-web/design-system/src/components/__tests__/SpotlightTour-test.tsx
new file mode 100644
index 00000000000..4e606267eb7
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/SpotlightTour-test.tsx
@@ -0,0 +1,153 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SpotlightTour, SpotlightTourProps } from '../SpotlightTour';
+
+it('should display the spotlight tour', async () => {
+ const user = userEvent.setup();
+ const callback = jest.fn();
+ renderSpotlightTour({ callback });
+
+ expect(await screen.findByRole('alertdialog')).toBeInTheDocument();
+ expect(screen.getByRole('alertdialog')).toHaveTextContent(
+ 'Trust The FooFoo bar is bazstep 1 of 5next'
+ );
+
+ await user.click(screen.getByRole('button', { name: 'next' }));
+
+ expect(screen.getByRole('alertdialog')).toHaveTextContent(
+ 'Trust The BazBaz foo is barstep 2 of 5go_backnext'
+ );
+ expect(callback).toHaveBeenCalled();
+
+ await user.click(screen.getByRole('button', { name: 'next' }));
+
+ expect(screen.getByRole('alertdialog')).toHaveTextContent(
+ 'Trust The BarBar baz is foostep 3 of 5go_backnext'
+ );
+
+ await user.click(screen.getByRole('button', { name: 'next' }));
+
+ expect(screen.getByRole('alertdialog')).toHaveTextContent(
+ 'Trust The Foo 2Foo baz is barstep 4 of 5go_backnext'
+ );
+
+ await user.click(screen.getByRole('button', { name: 'go_back' }));
+
+ expect(screen.getByRole('alertdialog')).toHaveTextContent(
+ 'Trust The BarBar baz is foostep 3 of 5go_backnext'
+ );
+
+ await user.click(screen.getByRole('button', { name: 'next' }));
+ await user.click(screen.getByRole('button', { name: 'next' }));
+
+ expect(screen.getByRole('alertdialog')).toHaveTextContent(
+ 'Trust The Baz 2Baz bar is foostep 5 of 5go_backclose'
+ );
+
+ expect(screen.queryByRole('button', { name: 'next' })).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'close' }));
+
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
+});
+
+it('should not show the spotlight tour if run is false', () => {
+ renderSpotlightTour({ run: false });
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
+});
+
+it('should allow the customization of button labels', async () => {
+ const user = userEvent.setup();
+ renderSpotlightTour({
+ nextLabel: 'forward',
+ backLabel: 'backward',
+ closeLabel: 'close_me',
+ skipLabel: "just don't",
+ showSkipButton: true,
+ });
+
+ expect(await screen.findByRole('alertdialog')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'forward' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: "just don't" })).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'forward' }));
+
+ expect(screen.getByRole('button', { name: 'backward' })).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'forward' }));
+ await user.click(screen.getByRole('button', { name: 'forward' }));
+ await user.click(screen.getByRole('button', { name: 'forward' }));
+
+ expect(screen.getByRole('button', { name: 'close_me' })).toBeInTheDocument();
+});
+
+function renderSpotlightTour(props: Partial<SpotlightTourProps> = {}) {
+ return render(
+ <div>
+ <div id="step1">This is step 1</div>
+ <div id="step2">This is step 2</div>
+ <div id="step3">This is step 3</div>
+ <div id="step4">This is step 4</div>
+ <div id="step5">This is step 5</div>
+
+ <SpotlightTour
+ continuous
+ run
+ stepXofYLabel={(x: number, y: number) => `step ${x} of ${y}`}
+ steps={[
+ {
+ target: '#step1',
+ content: 'Foo bar is baz',
+ title: 'Trust The Foo',
+ placement: 'top',
+ },
+ {
+ target: '#step2',
+ content: 'Baz foo is bar',
+ title: 'Trust The Baz',
+ placement: 'right',
+ },
+ {
+ target: '#step3',
+ content: 'Bar baz is foo',
+ title: 'Trust The Bar',
+ placement: 'bottom',
+ },
+ {
+ target: '#step4',
+ content: 'Foo baz is bar',
+ title: 'Trust The Foo 2',
+ placement: 'left',
+ },
+ {
+ target: '#step5',
+ content: 'Baz bar is foo',
+ title: 'Trust The Baz 2',
+ placement: 'center',
+ },
+ ]}
+ {...props}
+ />
+ </div>
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/Icon.tsx b/server/sonar-web/design-system/src/components/icons/Icon.tsx
index a1ebbc84e7a..f8cb3f39ca6 100644
--- a/server/sonar-web/design-system/src/components/icons/Icon.tsx
+++ b/server/sonar-web/design-system/src/components/icons/Icon.tsx
@@ -34,6 +34,7 @@ interface Props {
}
export interface IconProps extends Omit<Props, 'children'> {
+ ['data-guiding-id']?: string;
fill?: ThemeColors | CSSColor;
height?: number;
transform?: string;
diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts
index 92aa59aca20..b284e595baa 100644
--- a/server/sonar-web/design-system/src/components/index.ts
+++ b/server/sonar-web/design-system/src/components/index.ts
@@ -70,6 +70,7 @@ export * from './SizeIndicator';
export * from './SonarCodeColorizer';
export * from './SonarQubeLogo';
export { Spinner } from './Spinner';
+export * from './SpotlightTour';
export * from './Table';
export * from './Tags';
export * from './TagsSelector';
diff --git a/server/sonar-web/design-system/src/helpers/constants.ts b/server/sonar-web/design-system/src/helpers/constants.ts
index 9b40d9577f9..368b81e9986 100644
--- a/server/sonar-web/design-system/src/helpers/constants.ts
+++ b/server/sonar-web/design-system/src/helpers/constants.ts
@@ -74,3 +74,5 @@ export const DARK_THEME_ID = 'dark-theme';
export const OPACITY_20_PERCENT = 0.2;
export const OPACITY_75_PERCENT = 0.75;
+
+export const GLOBAL_POPUP_Z_INDEX = 5000;
diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts
index 9eb9da4fc5f..8a97f98d03a 100644
--- a/server/sonar-web/design-system/src/theme/light.ts
+++ b/server/sonar-web/design-system/src/theme/light.ts
@@ -106,6 +106,10 @@ export const lightTheme = {
popup: COLORS.white,
popupBorder: secondary.default,
+ // spotlight
+ spotlightPulseBackground: primary.default,
+ spotlightBackgroundColor: COLORS.blueGrey[50],
+
// modal
modalContents: COLORS.white,
modalOverlay: [...COLORS.blueGrey[900], OPACITY_75_PERCENT],