aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/design-system/src
diff options
context:
space:
mode:
Diffstat (limited to 'server/sonar-web/design-system/src')
-rw-r--r--server/sonar-web/design-system/src/@types/css.d.ts27
-rw-r--r--server/sonar-web/design-system/src/@types/emotion.d.ts25
-rw-r--r--server/sonar-web/design-system/src/components/Avatar.tsx117
-rw-r--r--server/sonar-web/design-system/src/components/Checkbox.tsx174
-rw-r--r--server/sonar-web/design-system/src/components/ClickEventBoundary.tsx35
-rw-r--r--server/sonar-web/design-system/src/components/DeferredSpinner.tsx138
-rw-r--r--server/sonar-web/design-system/src/components/Dropdown.tsx140
-rw-r--r--server/sonar-web/design-system/src/components/DropdownMenu.tsx370
-rw-r--r--server/sonar-web/design-system/src/components/DropdownToggler.tsx48
-rw-r--r--server/sonar-web/design-system/src/components/EscKeydownHandler.tsx48
-rw-r--r--server/sonar-web/design-system/src/components/GenericAvatar.tsx60
-rw-r--r--server/sonar-web/design-system/src/components/InputSearch.tsx243
-rw-r--r--server/sonar-web/design-system/src/components/InteractiveIcon.tsx182
-rw-r--r--server/sonar-web/design-system/src/components/Link.tsx173
-rw-r--r--server/sonar-web/design-system/src/components/MainAppBar.tsx89
-rw-r--r--server/sonar-web/design-system/src/components/MainMenu.tsx30
-rw-r--r--server/sonar-web/design-system/src/components/MainMenuItem.tsx59
-rw-r--r--server/sonar-web/design-system/src/components/NavLink.tsx74
-rw-r--r--server/sonar-web/design-system/src/components/OutsideClickHandler.tsx68
-rw-r--r--server/sonar-web/design-system/src/components/RadioButton.tsx125
-rw-r--r--server/sonar-web/design-system/src/components/SonarQubeLogo.tsx50
-rw-r--r--server/sonar-web/design-system/src/components/Text.tsx62
-rw-r--r--server/sonar-web/design-system/src/components/Tooltip.tsx504
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx69
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx73
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx65
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx100
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx51
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx90
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/Link-test.tsx129
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx54
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx64
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx112
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/Text-test.tsx41
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx126
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx69
-rw-r--r--server/sonar-web/design-system/src/components/buttons.tsx219
-rw-r--r--server/sonar-web/design-system/src/components/clipboard.tsx170
-rw-r--r--server/sonar-web/design-system/src/components/icons/CheckIcon.tsx36
-rw-r--r--server/sonar-web/design-system/src/components/icons/ClockIcon.tsx23
-rw-r--r--server/sonar-web/design-system/src/components/icons/CloseIcon.tsx23
-rw-r--r--server/sonar-web/design-system/src/components/icons/CopyIcon.tsx23
-rw-r--r--server/sonar-web/design-system/src/components/icons/Icon.tsx86
-rw-r--r--server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx36
-rw-r--r--server/sonar-web/design-system/src/components/icons/MenuIcon.tsx29
-rw-r--r--server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx37
-rw-r--r--server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx23
-rw-r--r--server/sonar-web/design-system/src/components/icons/SearchIcon.tsx23
-rw-r--r--server/sonar-web/design-system/src/components/icons/StarIcon.tsx23
-rw-r--r--server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx54
-rw-r--r--server/sonar-web/design-system/src/components/icons/index.ts24
-rw-r--r--server/sonar-web/design-system/src/components/index.ts19
-rw-r--r--server/sonar-web/design-system/src/components/popups.tsx256
-rw-r--r--server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts61
-rw-r--r--server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts167
-rw-r--r--server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts148
-rw-r--r--server/sonar-web/design-system/src/helpers/colors.ts56
-rw-r--r--server/sonar-web/design-system/src/helpers/constants.ts68
-rw-r--r--server/sonar-web/design-system/src/helpers/index.ts (renamed from server/sonar-web/design-system/src/components/DummyComponent.tsx)6
-rw-r--r--server/sonar-web/design-system/src/helpers/keyboard.ts52
-rw-r--r--server/sonar-web/design-system/src/helpers/l10n.ts30
-rw-r--r--server/sonar-web/design-system/src/helpers/positioning.ts185
-rw-r--r--server/sonar-web/design-system/src/helpers/testUtils.tsx117
-rw-r--r--server/sonar-web/design-system/src/helpers/theme.ts130
-rw-r--r--server/sonar-web/design-system/src/helpers/types.ts22
-rw-r--r--server/sonar-web/design-system/src/index.ts23
-rw-r--r--server/sonar-web/design-system/src/theme/colors.ts136
-rw-r--r--server/sonar-web/design-system/src/theme/index.ts20
-rw-r--r--server/sonar-web/design-system/src/theme/light.ts743
-rw-r--r--server/sonar-web/design-system/src/types/misc.ts21
-rw-r--r--server/sonar-web/design-system/src/types/theme.ts45
71 files changed, 7013 insertions, 5 deletions
diff --git a/server/sonar-web/design-system/src/@types/css.d.ts b/server/sonar-web/design-system/src/@types/css.d.ts
new file mode 100644
index 00000000000..446d5d09539
--- /dev/null
+++ b/server/sonar-web/design-system/src/@types/css.d.ts
@@ -0,0 +1,27 @@
+/*
+ * 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 * as CSS from 'csstype';
+
+declare module 'csstype' {
+ interface Properties extends CSS.Properties {
+ // Support any CSS Custom Property in style prop of components
+ [index: `--${string}`]: string | number;
+ }
+}
diff --git a/server/sonar-web/design-system/src/@types/emotion.d.ts b/server/sonar-web/design-system/src/@types/emotion.d.ts
new file mode 100644
index 00000000000..6ab3a1a59bb
--- /dev/null
+++ b/server/sonar-web/design-system/src/@types/emotion.d.ts
@@ -0,0 +1,25 @@
+/*
+ * 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 '@emotion/react';
+import { Theme as SQTheme } from '../types/theme';
+
+declare module '@emotion/react' {
+ export interface Theme extends SQTheme {}
+}
diff --git a/server/sonar-web/design-system/src/components/Avatar.tsx b/server/sonar-web/design-system/src/components/Avatar.tsx
new file mode 100644
index 00000000000..8b454295681
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/Avatar.tsx
@@ -0,0 +1,117 @@
+/*
+ * 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 styled from '@emotion/styled';
+import { ReactEventHandler, useState } from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor } from '../helpers/theme';
+import { GenericAvatar } from './GenericAvatar';
+
+type Size = 'xs' | 'sm' | 'md' | 'lg';
+
+const sizeMap: Record<Size, number> = {
+ xs: 16,
+ sm: 24,
+ md: 40,
+ lg: 64,
+};
+
+interface AvatarProps {
+ border?: boolean;
+ className?: string;
+ enableGravatar?: boolean;
+ gravatarServerUrl?: string;
+ hash?: string;
+ name?: string;
+ organizationAvatar?: string;
+ organizationName?: string;
+ size?: Size;
+}
+
+export function Avatar({
+ className,
+ enableGravatar,
+ gravatarServerUrl,
+ hash,
+ name,
+ organizationAvatar,
+ organizationName,
+ size = 'sm',
+ border,
+}: AvatarProps) {
+ const [imgError, setImgError] = useState(false);
+ const numberSize = sizeMap[size];
+ const resolvedName = organizationName ?? name;
+
+ const handleImgError: ReactEventHandler<HTMLImageElement> = () => {
+ setImgError(true);
+ };
+
+ if (!imgError) {
+ if (enableGravatar && gravatarServerUrl && hash) {
+ const url = gravatarServerUrl
+ .replace('{EMAIL_MD5}', hash)
+ .replace('{SIZE}', String(numberSize * 2));
+
+ return (
+ <StyledAvatar
+ alt={resolvedName}
+ border={border}
+ className={className}
+ height={numberSize}
+ onError={handleImgError}
+ role="img"
+ src={url}
+ width={numberSize}
+ />
+ );
+ }
+
+ if (resolvedName && organizationAvatar) {
+ return (
+ <StyledAvatar
+ alt={resolvedName}
+ border={border}
+ className={className}
+ height={numberSize}
+ onError={handleImgError}
+ role="img"
+ src={organizationAvatar}
+ width={numberSize}
+ />
+ );
+ }
+ }
+
+ if (!resolvedName) {
+ return <input className="sw-appearance-none" />;
+ }
+
+ return <GenericAvatar className={className} name={resolvedName} size={numberSize} />;
+}
+
+const StyledAvatar = styled.img<{ border?: boolean }>`
+ ${tw`sw-inline-flex`};
+ ${tw`sw-items-center`};
+ ${tw`sw-justify-center`};
+ ${tw`sw-align-top`};
+ ${tw`sw-rounded-1`};
+ border: ${({ border }) => (border ? themeBorder('default', 'avatarBorder') : '')};
+ background: ${themeColor('avatarBackground')};
+`;
diff --git a/server/sonar-web/design-system/src/components/Checkbox.tsx b/server/sonar-web/design-system/src/components/Checkbox.tsx
new file mode 100644
index 00000000000..7e352d04d3d
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/Checkbox.tsx
@@ -0,0 +1,174 @@
+/*
+ * 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 styled from '@emotion/styled';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import DeferredSpinner from './DeferredSpinner';
+import CheckIcon from './icons/CheckIcon';
+import { CustomIcon } from './icons/Icon';
+
+interface Props {
+ checked: boolean;
+ children?: React.ReactNode;
+ className?: string;
+ disabled?: boolean;
+ id?: string;
+ loading?: boolean;
+ onCheck: (checked: boolean, id?: string) => void;
+ onClick?: (event: React.MouseEvent<HTMLInputElement>) => void;
+ onFocus?: VoidFunction;
+ right?: boolean;
+ thirdState?: boolean;
+ title?: string;
+}
+
+export default function Checkbox({
+ checked,
+ disabled,
+ children,
+ className,
+ id,
+ loading = false,
+ onCheck,
+ onFocus,
+ onClick,
+ right,
+ thirdState = false,
+ title,
+}: Props) {
+ const handleChange = () => {
+ if (!disabled) {
+ onCheck(!checked, id);
+ }
+ };
+
+ return (
+ <CheckboxContainer className={className} disabled={disabled}>
+ {right && children}
+ <AccessibleCheckbox
+ aria-label={title}
+ checked={checked}
+ disabled={disabled || loading}
+ id={id}
+ onChange={handleChange}
+ onClick={onClick}
+ onFocus={onFocus}
+ type="checkbox"
+ />
+ <DeferredSpinner loading={loading}>
+ <StyledCheckbox aria-hidden={true} data-clickable="true" title={title}>
+ <CheckboxIcon checked={checked} thirdState={thirdState} />
+ </StyledCheckbox>
+ </DeferredSpinner>
+ {!right && children}
+ </CheckboxContainer>
+ );
+}
+
+interface CheckIconProps {
+ checked?: boolean;
+ thirdState?: boolean;
+}
+
+function CheckboxIcon({ checked, thirdState }: CheckIconProps) {
+ if (checked && thirdState) {
+ return (
+ <CustomIcon>
+ <rect fill="currentColor" height="2" rx="1" width="50%" x="4" y="7" />
+ </CustomIcon>
+ );
+ } else if (checked) {
+ return <CheckIcon fill="currentColor" />;
+ }
+ return null;
+}
+
+const CheckboxContainer = styled.label<{ disabled?: boolean }>`
+ color: ${themeContrast('backgroundSecondary')};
+ user-select: none;
+
+ ${tw`sw-inline-flex sw-items-center`};
+
+ &:hover {
+ ${tw`sw-cursor-pointer`}
+ }
+
+ &:disabled {
+ color: ${themeContrast('checkboxDisabled')};
+ ${tw`sw-cursor-not-allowed`}
+ }
+`;
+
+export const StyledCheckbox = styled.span`
+ border: ${themeBorder('default', 'primary')};
+ color: ${themeContrast('primary')};
+
+ ${tw`sw-w-4 sw-h-4`};
+ ${tw`sw-rounded-1/2`};
+ ${tw`sw-box-border`}
+ ${tw`sw-inline-flex sw-items-center sw-justify-center`};
+`;
+
+export const AccessibleCheckbox = styled.input`
+ // Following css makes the checkbox accessible and invisible
+ border: 0;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ height: 1px;
+ overflow: hidden;
+ padding: 0;
+ white-space: nowrap;
+ width: 1px;
+
+ &:focus,
+ &:active {
+ &:not(:disabled) + ${StyledCheckbox} {
+ outline: ${themeBorder('focus', 'primary')};
+ }
+ }
+
+ &:checked {
+ & + ${StyledCheckbox} {
+ background: ${themeColor('primary')};
+ }
+ &:disabled + ${StyledCheckbox} {
+ background: ${themeColor('checkboxDisabledChecked')};
+ }
+ }
+
+ &:hover {
+ &:not(:disabled) + ${StyledCheckbox} {
+ background: ${themeColor('checkboxHover')};
+ border: ${themeBorder('default', 'primary')};
+ }
+
+ &:checked:not(:disabled) + ${StyledCheckbox} {
+ background: ${themeColor('checkboxCheckedHover')};
+ border: ${themeBorder('default', 'checkboxCheckedHover')};
+ }
+ }
+
+ &:disabled + ${StyledCheckbox} {
+ background: ${themeColor('checkboxDisabled')};
+ color: ${themeColor('checkboxDisabled')};
+ border: ${themeBorder('default', 'checkboxDisabledChecked')};
+ }
+`;
diff --git a/server/sonar-web/design-system/src/components/ClickEventBoundary.tsx b/server/sonar-web/design-system/src/components/ClickEventBoundary.tsx
new file mode 100644
index 00000000000..d2c5b85f0e7
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/ClickEventBoundary.tsx
@@ -0,0 +1,35 @@
+/*
+ * 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 React from 'react';
+
+export interface ClickEventBoundaryProps {
+ children: React.ReactElement;
+}
+
+export default function ClickEventBoundary({ children }: ClickEventBoundaryProps) {
+ return React.cloneElement(children, {
+ onClick: (e: React.SyntheticEvent<MouseEvent>) => {
+ e.stopPropagation();
+ if (typeof children.props.onClick === 'function') {
+ children.props.onClick(e);
+ }
+ },
+ });
+}
diff --git a/server/sonar-web/design-system/src/components/DeferredSpinner.tsx b/server/sonar-web/design-system/src/components/DeferredSpinner.tsx
new file mode 100644
index 00000000000..50df8bc2bf7
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/DeferredSpinner.tsx
@@ -0,0 +1,138 @@
+/*
+ * 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 tw, { theme } from 'twin.macro';
+import { translate } from '../helpers/l10n';
+import { themeColor } from '../helpers/theme';
+import { InputSearchWrapper } from './InputSearch';
+
+interface Props {
+ children?: React.ReactNode;
+ className?: string;
+ customSpinner?: JSX.Element;
+ loading?: boolean;
+ placeholder?: boolean;
+ timeout?: number;
+}
+
+interface State {
+ showSpinner: boolean;
+}
+
+const DEFAULT_TIMEOUT = 100;
+
+export default class DeferredSpinner extends React.PureComponent<Props, State> {
+ timer?: number;
+
+ state: State = { showSpinner: false };
+
+ componentDidMount() {
+ if (this.props.loading == null || this.props.loading === true) {
+ this.startTimer();
+ }
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (prevProps.loading === false && this.props.loading === true) {
+ this.stopTimer();
+ this.startTimer();
+ }
+ if (prevProps.loading === true && this.props.loading === false) {
+ this.stopTimer();
+ this.setState({ showSpinner: false });
+ }
+ }
+
+ componentWillUnmount() {
+ this.stopTimer();
+ }
+
+ startTimer = () => {
+ this.timer = window.setTimeout(
+ () => this.setState({ showSpinner: true }),
+ this.props.timeout || DEFAULT_TIMEOUT
+ );
+ };
+
+ stopTimer = () => {
+ window.clearTimeout(this.timer);
+ };
+
+ render() {
+ const { showSpinner } = this.state;
+ const { customSpinner, className, children, placeholder } = this.props;
+ if (showSpinner) {
+ if (customSpinner) {
+ return customSpinner;
+ }
+ return <Spinner className={className} role="status" />;
+ }
+ if (children) {
+ return children;
+ }
+ if (placeholder) {
+ return <Placeholder className={className} />;
+ }
+ return null;
+ }
+}
+
+const spinAnimation = keyframes`
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(-360deg);
+ }
+`;
+
+const Spinner = styled.div`
+ border: 2px solid transparent;
+ background: linear-gradient(0deg, ${themeColor('primary')} 50%, transparent 50% 100%) border-box,
+ linear-gradient(90deg, ${themeColor('primary')} 25%, transparent 75% 100%) border-box;
+ mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ animation: ${spinAnimation} 1s infinite linear;
+
+ ${tw`sw-h-4 sw-w-4`};
+ ${tw`sw-inline-block`};
+ ${tw`sw-box-border`};
+ ${tw`sw-rounded-pill`}
+
+ ${InputSearchWrapper} & {
+ top: calc((2.25rem - ${theme('spacing.4')}) / 2);
+ ${tw`sw-left-3`};
+ ${tw`sw-absolute`};
+ }
+`;
+
+Spinner.defaultProps = { 'aria-label': translate('loading'), role: 'status' };
+
+const Placeholder = styled.div`
+ position: relative;
+ visibility: hidden;
+
+ ${tw`sw-inline-flex sw-items-center sw-justify-center`};
+ ${tw`sw-h-4 sw-w-4`};
+`;
diff --git a/server/sonar-web/design-system/src/components/Dropdown.tsx b/server/sonar-web/design-system/src/components/Dropdown.tsx
new file mode 100644
index 00000000000..f04b595decd
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/Dropdown.tsx
@@ -0,0 +1,140 @@
+/*
+ * 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 React from 'react';
+import { translate } from '../helpers/l10n';
+import { PopupPlacement, PopupZLevel } from '../helpers/positioning';
+import { InputSizeKeys } from '../types/theme';
+import { DropdownMenu } from './DropdownMenu';
+import DropdownToggler from './DropdownToggler';
+import MenuIcon from './icons/MenuIcon';
+import { InteractiveIcon } from './InteractiveIcon';
+
+type OnClickCallback = (event?: React.MouseEvent<HTMLElement>) => void;
+type A11yAttrs = Pick<React.AriaAttributes, 'aria-controls' | 'aria-expanded' | 'aria-haspopup'> & {
+ id: string;
+ role: React.AriaRole;
+};
+interface RenderProps {
+ a11yAttrs: A11yAttrs;
+ closeDropdown: VoidFunction;
+ onToggleClick: OnClickCallback;
+ open: boolean;
+}
+
+interface Props {
+ allowResizing?: boolean;
+ children:
+ | ((renderProps: RenderProps) => JSX.Element)
+ | React.ReactElement<{ onClick: OnClickCallback }>;
+ className?: string;
+ closeOnClick?: boolean;
+ id: string;
+ onOpen?: VoidFunction;
+ overlay: React.ReactNode;
+ placement?: PopupPlacement;
+ size?: InputSizeKeys;
+ zLevel?: PopupZLevel;
+}
+
+interface State {
+ open: boolean;
+}
+
+export default class Dropdown extends React.PureComponent<Props, State> {
+ state: State = { open: false };
+
+ componentDidUpdate(_: Props, prevState: State) {
+ if (!prevState.open && this.state.open && this.props.onOpen) {
+ this.props.onOpen();
+ }
+ }
+
+ handleClose = () => {
+ this.setState({ open: false });
+ };
+
+ handleToggleClick: OnClickCallback = (event) => {
+ if (event) {
+ event.preventDefault();
+ event.currentTarget.blur();
+ }
+ this.setState((state) => ({ open: !state.open }));
+ };
+
+ render() {
+ const { open } = this.state;
+ const { allowResizing, className, closeOnClick = true, id, size = 'full', zLevel } = this.props;
+ const a11yAttrs: A11yAttrs = {
+ 'aria-controls': `${id}-dropdown`,
+ 'aria-expanded': open,
+ 'aria-haspopup': 'menu',
+ id: `${id}-trigger`,
+ role: 'button',
+ };
+
+ const children = React.isValidElement(this.props.children)
+ ? React.cloneElement(this.props.children, { onClick: this.handleToggleClick, ...a11yAttrs })
+ : this.props.children({
+ a11yAttrs,
+ closeDropdown: this.handleClose,
+ onToggleClick: this.handleToggleClick,
+ open,
+ });
+
+ return (
+ <DropdownToggler
+ allowResizing={allowResizing}
+ aria-labelledby={`${id}-trigger`}
+ className={className}
+ id={`${id}-dropdown`}
+ onRequestClose={this.handleClose}
+ open={open}
+ overlay={
+ <DropdownMenu onClick={closeOnClick ? this.handleClose : undefined} size={size}>
+ {this.props.overlay}
+ </DropdownMenu>
+ }
+ placement={this.props.placement}
+ zLevel={zLevel}
+ >
+ {children}
+ </DropdownToggler>
+ );
+ }
+}
+
+interface ActionsDropdownProps extends Omit<Props, 'children' | 'overlay'> {
+ buttonSize?: 'small' | 'medium';
+ children: React.ReactNode;
+}
+
+export function ActionsDropdown(props: ActionsDropdownProps) {
+ const { children, buttonSize, ...dropdownProps } = props;
+ return (
+ <Dropdown overlay={children} {...dropdownProps}>
+ <InteractiveIcon
+ Icon={MenuIcon}
+ aria-label={translate('menu')}
+ size={buttonSize}
+ stopPropagation={false}
+ />
+ </Dropdown>
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/DropdownMenu.tsx b/server/sonar-web/design-system/src/components/DropdownMenu.tsx
new file mode 100644
index 00000000000..d16011785b9
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/DropdownMenu.tsx
@@ -0,0 +1,370 @@
+/*
+ * 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 { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import React from 'react';
+import tw from 'twin.macro';
+import { INPUT_SIZES } from '../helpers/constants';
+import { translate } from '../helpers/l10n';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import { InputSizeKeys, ThemedProps } from '../types/theme';
+import Checkbox from './Checkbox';
+import { ClipboardBase } from './clipboard';
+import { BaseLink, LinkProps } from './Link';
+import NavLink from './NavLink';
+import RadioButton from './RadioButton';
+import Tooltip from './Tooltip';
+
+interface Props extends React.HtmlHTMLAttributes<HTMLMenuElement> {
+ children?: React.ReactNode;
+ className?: string;
+ innerRef?: React.Ref<HTMLUListElement>;
+ maxHeight?: string;
+ size?: InputSizeKeys;
+}
+
+export function DropdownMenu({
+ children,
+ className,
+ innerRef,
+ maxHeight = 'inherit',
+ size = 'small',
+ ...menuProps
+}: Props) {
+ return (
+ <DropdownMenuWrapper
+ className={classNames('dropdown-menu', className)}
+ ref={innerRef}
+ role="menu"
+ style={{ '--inputSize': INPUT_SIZES[size], maxHeight }}
+ {...menuProps}
+ >
+ {children}
+ </DropdownMenuWrapper>
+ );
+}
+
+interface ListItemProps {
+ children?: React.ReactNode;
+ className?: string;
+ innerRef?: React.Ref<HTMLLIElement>;
+ onFocus?: VoidFunction;
+ onPointerEnter?: VoidFunction;
+ onPointerLeave?: VoidFunction;
+}
+
+type ItemLinkProps = Omit<ListItemProps, 'innerRef'> &
+ Pick<LinkProps, 'disabled' | 'icon' | 'onClick' | 'to'> & {
+ innerRef?: React.Ref<HTMLAnchorElement>;
+ };
+
+export function ItemLink(props: ItemLinkProps) {
+ const { children, className, disabled, icon, onClick, innerRef, to, ...liProps } = props;
+ return (
+ <li {...liProps}>
+ <ItemLinkStyled
+ className={classNames(className, { disabled })}
+ disabled={disabled}
+ icon={icon}
+ onClick={onClick}
+ ref={innerRef}
+ role="menuitem"
+ showExternalIcon={false}
+ to={to}
+ >
+ {children}
+ </ItemLinkStyled>
+ </li>
+ );
+}
+
+interface ItemNavLinkProps extends ItemLinkProps {
+ end?: boolean;
+}
+
+export function ItemNavLink(props: ItemNavLinkProps) {
+ const { children, className, disabled, end, icon, onClick, innerRef, to, ...liProps } = props;
+ return (
+ <li {...liProps}>
+ <ItemNavLinkStyled
+ className={classNames(className, { disabled })}
+ disabled={disabled}
+ end={end}
+ onClick={onClick}
+ ref={innerRef}
+ role="menuitem"
+ to={to}
+ >
+ {icon}
+ {children}
+ </ItemNavLinkStyled>
+ </li>
+ );
+}
+
+interface ItemButtonProps extends ListItemProps {
+ disabled?: boolean;
+ icon?: React.ReactNode;
+ onClick: React.MouseEventHandler<HTMLButtonElement>;
+}
+
+export function ItemButton(props: ItemButtonProps) {
+ const { children, className, disabled, icon, innerRef, onClick, ...liProps } = props;
+ return (
+ <li ref={innerRef} role="none" {...liProps}>
+ <ItemButtonStyled className={className} disabled={disabled} onClick={onClick} role="menuitem">
+ {icon}
+ {children}
+ </ItemButtonStyled>
+ </li>
+ );
+}
+
+export const ItemDangerButton = styled(ItemButton)`
+ --color: ${themeContrast('dropdownMenuDanger')};
+`;
+
+interface ItemCheckboxProps extends ListItemProps {
+ checked: boolean;
+ disabled?: boolean;
+ id?: string;
+ onCheck: (checked: boolean, id?: string) => void;
+}
+
+export function ItemCheckbox(props: ItemCheckboxProps) {
+ const { checked, children, className, disabled, id, innerRef, onCheck, onFocus, ...liProps } =
+ props;
+ return (
+ <li ref={innerRef} role="none" {...liProps}>
+ <ItemCheckboxStyled
+ checked={checked}
+ className={classNames(className, { disabled })}
+ disabled={disabled}
+ id={id}
+ onCheck={onCheck}
+ onFocus={onFocus}
+ >
+ {children}
+ </ItemCheckboxStyled>
+ </li>
+ );
+}
+
+interface ItemRadioButtonProps extends ListItemProps {
+ checked: boolean;
+ disabled?: boolean;
+ onCheck: (value: string) => void;
+ value: string;
+}
+
+export function ItemRadioButton(props: ItemRadioButtonProps) {
+ const { checked, children, className, disabled, innerRef, onCheck, value, ...liProps } = props;
+ return (
+ <li ref={innerRef} role="none" {...liProps}>
+ <ItemRadioButtonStyled
+ checked={checked}
+ className={classNames(className, { disabled })}
+ disabled={disabled}
+ onCheck={onCheck}
+ value={value}
+ >
+ {children}
+ </ItemRadioButtonStyled>
+ </li>
+ );
+}
+
+interface ItemCopyProps {
+ children?: React.ReactNode;
+ className?: string;
+ copyValue: string;
+}
+
+export function ItemCopy(props: ItemCopyProps) {
+ const { children, className, copyValue } = props;
+ return (
+ <ClipboardBase>
+ {({ setCopyButton, copySuccess }) => (
+ <Tooltip overlay={translate('copied_action')} visible={copySuccess}>
+ <li role="none">
+ <ItemButtonStyled
+ className={className}
+ data-clipboard-text={copyValue}
+ ref={setCopyButton}
+ role="menuitem"
+ >
+ {children}
+ </ItemButtonStyled>
+ </li>
+ </Tooltip>
+ )}
+ </ClipboardBase>
+ );
+}
+
+interface ItemDownloadProps extends ListItemProps {
+ download: string;
+ href: string;
+}
+
+export function ItemDownload(props: ItemDownloadProps) {
+ const { children, className, download, href, innerRef, ...liProps } = props;
+ return (
+ <li ref={innerRef} role="none" {...liProps}>
+ <ItemDownloadStyled
+ className={className}
+ download={download}
+ href={href}
+ rel="noopener noreferrer"
+ role="menuitem"
+ target="_blank"
+ >
+ {children}
+ </ItemDownloadStyled>
+ </li>
+ );
+}
+
+export const ItemHeaderHighlight = styled.span`
+ color: ${themeContrast('searchHighlight')};
+ font-weight: 600;
+`;
+
+export const ItemHeader = styled.li`
+ background-color: ${themeColor('dropdownMenuHeader')};
+ color: ${themeContrast('dropdownMenuHeader')};
+
+ ${tw`sw-py-2 sw-px-3`}
+`;
+ItemHeader.defaultProps = { className: 'dropdown-menu-header', role: 'menuitem' };
+
+export const ItemDivider = styled.li`
+ height: 1px;
+ background-color: ${themeColor('popupBorder')};
+
+ ${tw`sw-my-1 sw--mx-2`}
+ ${tw`sw-overflow-hidden`};
+`;
+ItemDivider.defaultProps = { role: 'separator' };
+
+const DropdownMenuWrapper = styled.ul`
+ background-color: ${themeColor('dropdownMenu')};
+ color: ${themeContrast('dropdownMenu')};
+ width: var(--inputSize);
+ list-style: none;
+
+ ${tw`sw-flex sw-flex-col`}
+ ${tw`sw-box-border`};
+ ${tw`sw-min-w-input-small`}
+ ${tw`sw-py-2`}
+ ${tw`sw-body-sm`}
+
+ &:focus {
+ outline: none;
+ }
+`;
+
+const itemStyle = (props: ThemedProps) => css`
+ color: var(--color);
+ background-color: ${themeColor('dropdownMenu')(props)};
+ border: none;
+ border-bottom: none;
+ text-decoration: none;
+ transition: none;
+
+ ${tw`sw-flex sw-items-center`}
+ ${tw`sw-body-sm`}
+ ${tw`sw-box-border`}
+ ${tw`sw-w-full`}
+ ${tw`sw-text-left`}
+ ${tw`sw-py-2 sw-px-3`}
+ ${tw`sw-truncate`};
+ ${tw`sw-cursor-pointer`}
+
+ &.active,
+ &:active,
+ &.active:active,
+ &:hover,
+ &.active:hover {
+ color: var(--color);
+ background-color: ${themeColor('dropdownMenuHover')(props)};
+ text-decoration: none;
+ outline: none;
+ border: none;
+ border-bottom: none;
+ }
+
+ &:focus,
+ &:focus-within,
+ &.active:focus,
+ &.active:focus-within {
+ color: var(--color);
+ background-color: ${themeColor('dropdownMenuFocus')(props)};
+ text-decoration: none;
+ outline: ${themeBorder('focus', 'dropdownMenuFocusBorder')(props)};
+ outline-offset: -4px;
+ border: none;
+ border-bottom: none;
+ }
+
+ &:disabled,
+ &.disabled {
+ color: ${themeContrast('dropdownMenuDisabled')(props)};
+ background-color: ${themeColor('dropdownMenuDisabled')(props)};
+ pointer-events: none !important;
+
+ ${tw`sw-cursor-not-allowed`};
+ }
+
+ & > svg {
+ ${tw`sw-mr-2`}
+ }
+`;
+
+const ItemNavLinkStyled = styled(NavLink)`
+ --color: ${themeContrast('dropdownMenu')};
+ ${itemStyle};
+`;
+
+const ItemLinkStyled = styled(BaseLink)`
+ --color: ${themeContrast('dropdownMenu')};
+ ${itemStyle}
+`;
+
+const ItemButtonStyled = styled.button`
+ --color: ${themeContrast('dropdownMenu')};
+ ${itemStyle}
+`;
+
+const ItemDownloadStyled = styled.a`
+ --color: ${themeContrast('dropdownMenu')};
+ ${itemStyle}
+`;
+
+const ItemCheckboxStyled = styled(Checkbox)`
+ --color: ${themeContrast('dropdownMenu')};
+ ${itemStyle}
+`;
+
+const ItemRadioButtonStyled = styled(RadioButton)`
+ --color: ${themeContrast('dropdownMenu')};
+ ${itemStyle}
+`;
diff --git a/server/sonar-web/design-system/src/components/DropdownToggler.tsx b/server/sonar-web/design-system/src/components/DropdownToggler.tsx
new file mode 100644
index 00000000000..f46f3dc8456
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/DropdownToggler.tsx
@@ -0,0 +1,48 @@
+/*
+ * 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 EscKeydownHandler from './EscKeydownHandler';
+import OutsideClickHandler from './OutsideClickHandler';
+import { PortalPopup } from './popups';
+
+type PopupProps = PortalPopup['props'];
+
+interface Props extends PopupProps {
+ onRequestClose: VoidFunction;
+ open: boolean;
+}
+
+export default function DropdownToggler(props: Props) {
+ const { children, open, onRequestClose, overlay, ...popupProps } = props;
+
+ return (
+ <PortalPopup
+ overlay={
+ open ? (
+ <OutsideClickHandler onClickOutside={onRequestClose}>
+ <EscKeydownHandler onKeydown={onRequestClose}>{overlay}</EscKeydownHandler>
+ </OutsideClickHandler>
+ ) : undefined
+ }
+ {...popupProps}
+ >
+ {children}
+ </PortalPopup>
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/EscKeydownHandler.tsx b/server/sonar-web/design-system/src/components/EscKeydownHandler.tsx
new file mode 100644
index 00000000000..9b0155ab6dd
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/EscKeydownHandler.tsx
@@ -0,0 +1,48 @@
+/*
+ * 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 React from 'react';
+import { Key } from '../helpers/keyboard';
+
+interface Props {
+ children: React.ReactNode;
+ onKeydown: () => void;
+}
+
+export default class EscKeydownHandler extends React.Component<Props> {
+ componentDidMount() {
+ setTimeout(() => {
+ document.addEventListener('keydown', this.handleKeyDown, false);
+ }, 0);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('keydown', this.handleKeyDown, false);
+ }
+
+ handleKeyDown = (event: KeyboardEvent) => {
+ if (event.code === Key.Escape) {
+ this.props.onKeydown();
+ }
+ };
+
+ render() {
+ return this.props.children;
+ }
+}
diff --git a/server/sonar-web/design-system/src/components/GenericAvatar.tsx b/server/sonar-web/design-system/src/components/GenericAvatar.tsx
new file mode 100644
index 00000000000..4d8fa6901be
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/GenericAvatar.tsx
@@ -0,0 +1,60 @@
+/*
+ * 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 { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeAvatarColor } from '../helpers/theme';
+import { IconProps } from './icons/Icon';
+
+export interface GenericAvatarProps {
+ Icon?: React.ComponentType<IconProps>;
+ className?: string;
+ name: string;
+ size?: number;
+}
+
+export function GenericAvatar({ className, Icon, name, size = 24 }: GenericAvatarProps) {
+ const theme = useTheme();
+ const text = name.length > 0 ? name[0].toUpperCase() : '';
+
+ return (
+ <StyledGenericAvatar aria-label={name} className={className} name={name} role="img" size={size}>
+ {Icon ? <Icon fill={themeAvatarColor(name, true)({ theme })} /> : text}
+ </StyledGenericAvatar>
+ );
+}
+
+export const StyledGenericAvatar = styled.div<{ name: string; size: number }>`
+ ${tw`sw-text-center`};
+ ${tw`sw-align-top`};
+ ${tw`sw-select-none`};
+ ${tw`sw-font-regular`};
+ ${tw`sw-rounded-1`};
+ ${tw`sw-inline-flex`};
+ ${tw`sw-items-center`};
+ ${tw`sw-justify-center`};
+ height: ${({ size }) => size}px;
+ width: ${({ size }) => size}px;
+ background-color: ${({ name, theme }) => themeAvatarColor(name)({ theme })};
+ color: ${({ name, theme }) => themeAvatarColor(name, true)({ theme })};
+ font-size: ${({ size }) => Math.max(Math.floor(size / 2), 8)}px;
+ line-height: ${({ size }) => size}px;
+`;
diff --git a/server/sonar-web/design-system/src/components/InputSearch.tsx b/server/sonar-web/design-system/src/components/InputSearch.tsx
new file mode 100644
index 00000000000..5e5e9c0e3ee
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/InputSearch.tsx
@@ -0,0 +1,243 @@
+/*
+ * 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 styled from '@emotion/styled';
+import classNames from 'classnames';
+import { debounce } from 'lodash';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import tw, { theme } from 'twin.macro';
+import { DEBOUNCE_DELAY, INPUT_SIZES } from '../helpers/constants';
+import { Key } from '../helpers/keyboard';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import { isDefined } from '../helpers/types';
+import { InputSizeKeys } from '../types/theme';
+import DeferredSpinner from './DeferredSpinner';
+import CloseIcon from './icons/CloseIcon';
+import SearchIcon from './icons/SearchIcon';
+import { InteractiveIcon } from './InteractiveIcon';
+
+interface Props {
+ autoFocus?: boolean;
+ className?: string;
+ clearIconAriaLabel: string;
+ id?: string;
+ innerRef?: React.RefCallback<HTMLInputElement>;
+ loading?: boolean;
+ maxLength?: number;
+ minLength?: number;
+ onBlur?: React.FocusEventHandler<HTMLInputElement>;
+ onChange: (value: string) => void;
+ onFocus?: React.FocusEventHandler<HTMLInputElement>;
+ onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
+ onMouseDown?: React.MouseEventHandler<HTMLInputElement>;
+ placeholder: string;
+ searchInputAriaLabel: string;
+ size?: InputSizeKeys;
+ tooShortText: string;
+ value?: string;
+}
+
+const DEFAULT_MAX_LENGTH = 100;
+
+export default function InputSearch({
+ autoFocus,
+ id,
+ className,
+ innerRef,
+ onBlur,
+ onChange,
+ onFocus,
+ onKeyDown,
+ onMouseDown,
+ placeholder,
+ loading,
+ minLength,
+ maxLength = DEFAULT_MAX_LENGTH,
+ size = 'medium',
+ value: parentValue,
+ tooShortText,
+ searchInputAriaLabel,
+ clearIconAriaLabel,
+}: Props) {
+ const input = useRef<null | HTMLElement>(null);
+ const [value, setValue] = useState(parentValue ?? '');
+ const debouncedOnChange = useMemo(() => debounce(onChange, DEBOUNCE_DELAY), [onChange]);
+
+ const tooShort = isDefined(minLength) && value.length > 0 && value.length < minLength;
+ const inputClassName = classNames('js-input-search', {
+ touched: value.length > 0 && (!minLength || minLength > value.length),
+ 'sw-pr-10': value.length > 0,
+ });
+
+ useEffect(() => {
+ if (parentValue !== undefined) {
+ setValue(parentValue);
+ }
+ }, [parentValue]);
+
+ const changeValue = (newValue: string) => {
+ if (newValue.length === 0 || !minLength || minLength <= newValue.length) {
+ debouncedOnChange(newValue);
+ }
+ };
+
+ const handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
+ const eventValue = event.currentTarget.value;
+ setValue(eventValue);
+ changeValue(eventValue);
+ };
+
+ const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === Key.Escape) {
+ event.preventDefault();
+ handleClearClick();
+ }
+ onKeyDown?.(event);
+ };
+
+ const handleClearClick = () => {
+ onChange('');
+ if (parentValue === undefined || parentValue === '') {
+ setValue('');
+ }
+ input.current?.focus();
+ };
+ const ref = (node: HTMLInputElement | null) => {
+ input.current = node;
+ innerRef?.(node);
+ };
+
+ return (
+ <InputSearchWrapper
+ className={className}
+ id={id}
+ onMouseDown={onMouseDown}
+ style={{ '--inputSize': INPUT_SIZES[size] }}
+ title={tooShort && isDefined(minLength) ? tooShortText : ''}
+ >
+ <StyledInputWrapper className="sw-flex sw-items-center">
+ <input
+ aria-label={searchInputAriaLabel}
+ autoComplete="off"
+ autoFocus={autoFocus}
+ className={inputClassName}
+ maxLength={maxLength}
+ onBlur={onBlur}
+ onChange={handleInputChange}
+ onFocus={onFocus}
+ onKeyDown={handleInputKeyDown}
+ placeholder={placeholder}
+ ref={ref}
+ role="searchbox"
+ type="search"
+ value={value}
+ />
+ <DeferredSpinner loading={loading !== undefined ? loading : false}>
+ <StyledSearchIcon />
+ </DeferredSpinner>
+ {value && (
+ <StyledInteractiveIcon
+ Icon={CloseIcon}
+ aria-label={clearIconAriaLabel}
+ className="js-input-search-clear"
+ onClick={handleClearClick}
+ size="small"
+ />
+ )}
+
+ {tooShort && isDefined(minLength) && (
+ <StyledNote className="sw-ml-1" role="note">
+ {tooShortText}
+ </StyledNote>
+ )}
+ </StyledInputWrapper>
+ </InputSearchWrapper>
+ );
+}
+
+export const InputSearchWrapper = styled.div`
+ width: var(--inputSize);
+
+ ${tw`sw-relative sw-inline-block`}
+ ${tw`sw-whitespace-nowrap`}
+ ${tw`sw-align-middle`}
+ ${tw`sw-h-control`}
+`;
+
+export const StyledInputWrapper = styled.div`
+ input {
+ background: ${themeColor('inputBackground')};
+ color: ${themeContrast('inputBackground')};
+ border: ${themeBorder('default', 'inputBorder')};
+
+ ${tw`sw-rounded-2`}
+ ${tw`sw-box-border`}
+ ${tw`sw-pl-10`}
+ ${tw`sw-body-sm`}
+ ${tw`sw-w-full sw-h-control`}
+
+ &::placeholder {
+ color: ${themeColor('inputPlaceholder')};
+
+ ${tw`sw-truncate`}
+ }
+
+ &:hover {
+ border: ${themeBorder('default', 'inputFocus')};
+ }
+
+ &:focus,
+ &:active {
+ border: ${themeBorder('default', 'inputFocus')};
+ outline: ${themeBorder('focus', 'inputFocus')};
+ }
+
+ &::-webkit-search-decoration,
+ &::-webkit-search-cancel-button,
+ &::-webkit-search-results-button,
+ &::-webkit-search-results-decoration {
+ ${tw`sw-hidden sw-appearance-none`}
+ }
+ }
+`;
+
+const StyledSearchIcon = styled(SearchIcon)`
+ color: ${themeColor('inputBorder')};
+ top: calc((${theme('height.control')} - ${theme('spacing.4')}) / 2);
+
+ ${tw`sw-left-3`}
+ ${tw`sw-absolute`}
+`;
+
+export const StyledInteractiveIcon = styled(InteractiveIcon)`
+ ${tw`sw-absolute`}
+ ${tw`sw-right-2`}
+`;
+
+const StyledNote = styled.span`
+ color: ${themeColor('inputPlaceholder')};
+ top: calc(1px + ${theme('inset.2')});
+
+ ${tw`sw-absolute`}
+ ${tw`sw-left-12 sw-right-10`}
+ ${tw`sw-body-sm`}
+ ${tw`sw-text-right`}
+ ${tw`sw-truncate`}
+ ${tw`sw-pointer-events-none`}
+`;
diff --git a/server/sonar-web/design-system/src/components/InteractiveIcon.tsx b/server/sonar-web/design-system/src/components/InteractiveIcon.tsx
new file mode 100644
index 00000000000..ebd9cb73e9a
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/InteractiveIcon.tsx
@@ -0,0 +1,182 @@
+/*
+ * 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 { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import React from 'react';
+import tw from 'twin.macro';
+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';
+
+export type InteractiveIconSize = 'small' | 'medium';
+
+export interface InteractiveIconProps {
+ Icon: React.ComponentType<IconProps>;
+ 'aria-label': string;
+ children?: React.ReactNode;
+ className?: string;
+ currentColor?: boolean;
+ disabled?: boolean;
+ id?: string;
+ innerRef?: React.Ref<HTMLButtonElement>;
+ onClick?: VoidFunction;
+ size?: InteractiveIconSize;
+ stopPropagation?: boolean;
+ to?: LinkProps['to'];
+}
+
+export class InteractiveIconBase extends React.PureComponent<InteractiveIconProps> {
+ handleClick = (event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
+ const { disabled, onClick, stopPropagation = true } = this.props;
+ event.currentTarget.blur();
+
+ if (stopPropagation) {
+ event.stopPropagation();
+ }
+
+ if (onClick && !disabled) {
+ onClick();
+ }
+ };
+
+ render() {
+ const {
+ Icon,
+ children,
+ disabled,
+ innerRef,
+ onClick,
+ size = 'medium',
+ to,
+ ...htmlProps
+ } = this.props;
+
+ const props = {
+ ...htmlProps,
+ 'aria-disabled': disabled,
+ disabled,
+ size,
+ type: 'button' as const,
+ };
+
+ if (to) {
+ return (
+ <IconLink
+ {...props}
+ onClick={onClick}
+ showExternalIcon={false}
+ stopPropagation={true}
+ to={to}
+ >
+ <Icon className={classNames({ 'sw-mr-1': isDefined(children) })} />
+ {children}
+ </IconLink>
+ );
+ }
+
+ return (
+ <IconButton {...props} onClick={this.handleClick} ref={innerRef}>
+ <Icon className={classNames({ 'sw-mr-1': isDefined(children) })} />
+ {children}
+ </IconButton>
+ );
+ }
+}
+
+const buttonIconStyle = (props: ThemedProps & { size: InteractiveIconSize }) => css`
+ box-sizing: border-box;
+ border: none;
+ outline: none;
+ text-decoration: none;
+ color: var(--color);
+ background-color: var(--background);
+ transition: background-color 0.2s ease, outline 0.2s ease, color 0.2s ease;
+
+ ${tw`sw-inline-flex sw-items-center sw-justify-center`}
+ ${tw`sw-cursor-pointer`}
+
+ ${{
+ small: tw`sw-h-6 sw-px-1 sw-rounded-1/2`,
+ medium: tw`sw-h-control sw-px-[0.625rem] sw-rounded-2`,
+ }[props.size]}
+
+
+ &:hover,
+ &:focus,
+ &:active {
+ color: var(--colorHover);
+ background-color: var(--backgroundHover);
+ }
+
+ &:focus,
+ &:active {
+ outline: ${themeBorder('focus', 'var(--focus)')(props)};
+ }
+
+ &:disabled,
+ &:disabled:hover {
+ color: ${themeContrast('buttonDisabled')(props)};
+ background-color: var(--background);
+
+ ${tw`sw-cursor-not-allowed`}
+ }
+`;
+
+const IconLink = styled(BaseLink)`
+ ${buttonIconStyle}
+`;
+
+const IconButton = styled.button`
+ ${buttonIconStyle}
+`;
+
+export const InteractiveIcon: React.FC<InteractiveIconProps> = styled(InteractiveIconBase)`
+ --background: ${themeColor('interactiveIcon')};
+ --backgroundHover: ${themeColor('interactiveIconHover')};
+ --color: ${({ currentColor, theme }) =>
+ currentColor ? 'currentColor' : themeContrast('interactiveIcon')({ theme })};
+ --colorHover: ${themeContrast('interactiveIconHover')};
+ --focus: ${themeColor('interactiveIconFocus', 0.2)};
+`;
+
+export const DiscreetInteractiveIcon: React.FC<InteractiveIconProps> = styled(InteractiveIcon)`
+ --color: ${themeColor('discreetInteractiveIcon')};
+`;
+
+export const DestructiveIcon: React.FC<InteractiveIconProps> = styled(InteractiveIconBase)`
+ --background: ${themeColor('destructiveIcon')};
+ --backgroundHover: ${themeColor('destructiveIconHover')};
+ --color: ${themeContrast('destructiveIcon')};
+ --colorHover: ${themeContrast('destructiveIconHover')};
+ --focus: ${themeColor('destructiveIconFocus', 0.2)};
+`;
+
+export const DismissProductNewsIcon: React.FC<InteractiveIconProps> = styled(InteractiveIcon)`
+ --background: ${themeColor('productNews')};
+ --backgroundHover: ${themeColor('productNewsHover')};
+ --color: ${themeContrast('productNews')};
+ --colorHover: ${themeContrast('productNewsHover')};
+ --focus: ${themeColor('interactiveIconFocus', 0.2)};
+
+ height: 28px;
+`;
diff --git a/server/sonar-web/design-system/src/components/Link.tsx b/server/sonar-web/design-system/src/components/Link.tsx
new file mode 100644
index 00000000000..5f427ece7e2
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/Link.tsx
@@ -0,0 +1,173 @@
+/*
+ * 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 { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import React, { HTMLAttributeAnchorTarget } from 'react';
+import { Link as RouterLink, LinkProps as RouterLinkProps } from 'react-router-dom';
+import tw, { theme as twTheme } from 'twin.macro';
+import { themeBorder, themeColor } from '../helpers/theme';
+import OpenNewTabIcon from './icons/OpenNewTabIcon';
+import { TooltipWrapperInner } from './Tooltip';
+
+export interface LinkProps extends RouterLinkProps {
+ blurAfterClick?: boolean;
+ disabled?: boolean;
+ forceExternal?: boolean;
+ icon?: React.ReactNode;
+ onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
+ preventDefault?: boolean;
+ showExternalIcon?: boolean;
+ stopPropagation?: boolean;
+ target?: HTMLAttributeAnchorTarget;
+}
+
+function BaseLinkWithRef(props: LinkProps, ref: React.ForwardedRef<HTMLAnchorElement>) {
+ const {
+ children,
+ blurAfterClick,
+ disabled,
+ icon,
+ onClick,
+ preventDefault,
+ showExternalIcon = !icon,
+ stopPropagation,
+ target = '_blank',
+ to,
+ ...rest
+ } = props;
+ const isExternal = typeof to === 'string' && to.startsWith('http');
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent<HTMLAnchorElement>) => {
+ if (blurAfterClick) {
+ event.currentTarget.blur();
+ }
+
+ if (preventDefault || disabled) {
+ event.preventDefault();
+ }
+
+ if (stopPropagation) {
+ event.stopPropagation();
+ }
+
+ if (onClick && !disabled) {
+ onClick(event);
+ }
+ },
+ [onClick, blurAfterClick, preventDefault, stopPropagation, disabled]
+ );
+
+ return isExternal ? (
+ <a
+ {...rest}
+ href={to}
+ onClick={handleClick}
+ ref={ref}
+ rel="noopener noreferrer"
+ target={target}
+ >
+ {icon}
+ {children}
+ {showExternalIcon && <OpenNewTabIcon className="sw-ml-1" />}
+ </a>
+ ) : (
+ <RouterLink ref={ref} {...rest} onClick={handleClick} to={to}>
+ {icon}
+ {children}
+ </RouterLink>
+ );
+}
+
+export const BaseLink = React.forwardRef(BaseLinkWithRef);
+
+const StyledBaseLink = styled(BaseLink)`
+ color: var(--color);
+ border-bottom: ${({ children, icon, theme }) =>
+ icon && !children ? themeBorder('default', 'transparent')({ theme }) : 'var(--border)'};
+
+ &:visited {
+ color: var(--color);
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ color: var(--active);
+ border-bottom: ${({ children, icon, theme }) =>
+ icon && !children ? themeBorder('default', 'transparent')({ theme }) : 'var(--borderActive)'};
+ }
+
+ & > svg {
+ ${tw`sw-align-text-bottom!`}
+ }
+
+ ${({ icon }) =>
+ icon &&
+ css`
+ margin-left: calc(${twTheme('width.icon')} + ${twTheme('spacing.1')});
+
+ & > svg,
+ & > img {
+ ${tw`sw-mr-1`}
+
+ margin-left: calc(-1 * (${twTheme('width.icon')} + ${twTheme('spacing.1')}));
+ }
+ `};
+`;
+
+export const HoverLink = styled(StyledBaseLink)`
+ text-decoration: none;
+
+ --color: ${themeColor('linkDiscreet')};
+ --active: ${themeColor('linkActive')};
+ --border: ${themeBorder('default', 'transparent')};
+ --borderActive: ${themeBorder('default', 'linkActive')};
+
+ ${TooltipWrapperInner} & {
+ --active: ${themeColor('linkTooltipActive')};
+ --borderActive: ${themeBorder('default', 'linkTooltipActive')};
+ }
+`;
+HoverLink.displayName = 'HoverLink';
+
+export const DiscreetLink = styled(HoverLink)`
+ --border: ${themeBorder('default', 'linkDiscreet')};
+`;
+DiscreetLink.displayName = 'DiscreetLink';
+
+const StandoutLink = styled(StyledBaseLink)`
+ ${tw`sw-font-semibold`}
+ ${tw`sw-no-underline`}
+
+ --color: ${themeColor('linkDefault')};
+ --active: ${themeColor('linkActive')};
+ --border: ${themeBorder('default', 'linkDefault')};
+ --borderActive: ${themeBorder('default', 'linkDefault')};
+
+ ${TooltipWrapperInner} & {
+ --color: ${themeColor('linkTooltipDefault')};
+ --active: ${themeColor('linkTooltipActive')};
+ --border: ${themeBorder('default', 'linkTooltipDefault')};
+ --borderActive: ${themeBorder('default', 'linkTooltipActive')};
+ }
+`;
+StandoutLink.displayName = 'StandoutLink';
+
+export default StandoutLink;
diff --git a/server/sonar-web/design-system/src/components/MainAppBar.tsx b/server/sonar-web/design-system/src/components/MainAppBar.tsx
new file mode 100644
index 00000000000..97303a00158
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/MainAppBar.tsx
@@ -0,0 +1,89 @@
+/*
+ * 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 styled from '@emotion/styled';
+import tw from 'twin.macro';
+import {
+ LAYOUT_GLOBAL_NAV_HEIGHT,
+ LAYOUT_LOGO_MARGIN_RIGHT,
+ LAYOUT_LOGO_MAX_HEIGHT,
+ LAYOUT_LOGO_MAX_WIDTH,
+} from '../helpers/constants';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+
+const MainAppBarContainerDiv = styled.div`
+ height: ${LAYOUT_GLOBAL_NAV_HEIGHT}px;
+`;
+
+const MainAppBarDiv = styled.div`
+ ${tw`sw-fixed`}
+ ${tw`sw-flex`};
+ ${tw`sw-items-center`};
+ ${tw`sw-left-0`};
+ ${tw`sw-px-6`};
+ ${tw`sw-right-0`};
+ ${tw`sw-w-full`};
+ ${tw`sw-box-border`};
+ ${tw`sw-z-global-navbar`};
+
+ background: ${themeColor('mainBar')};
+ border-bottom: ${themeBorder('default')};
+ color: ${themeContrast('mainBar')};
+ height: ${LAYOUT_GLOBAL_NAV_HEIGHT}px;
+`;
+
+const MainAppBarNavLogoDiv = styled.div`
+ margin-right: ${LAYOUT_LOGO_MARGIN_RIGHT}px;
+
+ img,
+ svg {
+ ${tw`sw-object-contain`};
+
+ max-height: ${LAYOUT_LOGO_MAX_HEIGHT}px;
+ max-width: ${LAYOUT_LOGO_MAX_WIDTH}px;
+ }
+`;
+
+const MainAppBarNavLogoLink = styled.a`
+ border: none;
+`;
+
+const MainAppBarNavRightDiv = styled.div`
+ flex-grow: 2;
+ height: 100%;
+`;
+
+export function MainAppBar({
+ children,
+ Logo,
+}: React.PropsWithChildren<{ Logo: React.ElementType }>) {
+ return (
+ <MainAppBarContainerDiv>
+ <MainAppBarDiv>
+ <MainAppBarNavLogoDiv>
+ <MainAppBarNavLogoLink href="/">
+ <Logo />
+ </MainAppBarNavLogoLink>
+ </MainAppBarNavLogoDiv>
+ <MainAppBarNavRightDiv>{children}</MainAppBarNavRightDiv>
+ </MainAppBarDiv>
+ </MainAppBarContainerDiv>
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/MainMenu.tsx b/server/sonar-web/design-system/src/components/MainMenu.tsx
new file mode 100644
index 00000000000..e61964a77e3
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/MainMenu.tsx
@@ -0,0 +1,30 @@
+/*
+ * 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 styled from '@emotion/styled';
+import tw from 'twin.macro';
+
+const MainMenuUl = styled.ul`
+ ${tw`sw-flex sw-gap-8 sw-items-center`}
+`;
+
+export function MainMenu({ children }: React.PropsWithChildren<{}>) {
+ return <MainMenuUl>{children}</MainMenuUl>;
+}
diff --git a/server/sonar-web/design-system/src/components/MainMenuItem.tsx b/server/sonar-web/design-system/src/components/MainMenuItem.tsx
new file mode 100644
index 00000000000..9749ba9028a
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/MainMenuItem.tsx
@@ -0,0 +1,59 @@
+/*
+ * 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 styled from '@emotion/styled';
+import tw from 'twin.macro';
+import { LAYOUT_GLOBAL_NAV_HEIGHT } from '../helpers/constants';
+import { themeBorder, themeContrast } from '../helpers/theme';
+
+export const MainMenuItem = styled.li`
+ & a {
+ ${tw`sw-block sw-box-border`};
+ ${tw`sw-text-sm sw-font-semibold`};
+ ${tw`sw-whitespace-nowrap`};
+ ${tw`sw-no-underline`};
+ ${tw`sw-select-none`};
+ ${tw`sw-font-sans`};
+
+ color: ${themeContrast('mainBar')};
+ letter-spacing: 0.03em;
+ line-height: calc(${LAYOUT_GLOBAL_NAV_HEIGHT}px - 3px); // - 3px border bottom
+ border-bottom: ${themeBorder('active', 'transparent', 1)};
+
+ &:visited {
+ border-bottom: ${themeBorder('active', 'transparent', 1)};
+ color: ${themeContrast('mainBar')};
+ }
+
+ &:active,
+ &.active,
+ &:focus {
+ border-bottom: ${themeBorder('active', 'menuBorder', 1)};
+ color: ${themeContrast('mainBar')};
+ }
+
+ &:hover,
+ &.hover,
+ &[aria-expanded='true'] {
+ border-bottom: ${themeBorder('active', 'menuBorder', 1)};
+ color: ${themeContrast('mainBarHover')};
+ }
+ }
+`;
diff --git a/server/sonar-web/design-system/src/components/NavLink.tsx b/server/sonar-web/design-system/src/components/NavLink.tsx
new file mode 100644
index 00000000000..8075c5e182c
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/NavLink.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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 React from 'react';
+import { NavLink as RouterNavLink, NavLinkProps as RouterNavLinkProps } from 'react-router-dom';
+
+export interface NavLinkProps extends RouterNavLinkProps {
+ blurAfterClick?: boolean;
+ disabled?: boolean;
+ onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
+ preventDefault?: boolean;
+ stopPropagation?: boolean;
+}
+
+// Styling this component directly with Emotion should be avoided due to conflicts with react-router's classname.
+// Use NavBarTabs as an example of this exception.
+function NavLinkWithRef(props: NavLinkProps, ref: React.ForwardedRef<HTMLAnchorElement>) {
+ const {
+ blurAfterClick,
+ children,
+ disabled,
+ onClick,
+ preventDefault,
+ stopPropagation,
+ ...otherProps
+ } = props;
+
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent<HTMLAnchorElement>) => {
+ if (blurAfterClick) {
+ // explicitly lose focus after click
+ event.currentTarget.blur();
+ }
+
+ if (preventDefault || disabled) {
+ event.preventDefault();
+ }
+
+ if (stopPropagation) {
+ event.stopPropagation();
+ }
+
+ if (onClick && !disabled) {
+ onClick(event);
+ }
+ },
+ [onClick, blurAfterClick, preventDefault, stopPropagation, disabled]
+ );
+
+ return (
+ <RouterNavLink onClick={handleClick} ref={ref} {...otherProps}>
+ {children}
+ </RouterNavLink>
+ );
+}
+
+const NavLink = React.forwardRef(NavLinkWithRef);
+export default NavLink;
diff --git a/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx b/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx
new file mode 100644
index 00000000000..07de4f5422f
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx
@@ -0,0 +1,68 @@
+/*
+ * 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 React from 'react';
+import { findDOMNode } from 'react-dom';
+
+export type MouseEventListener = 'click' | 'mousedown';
+interface Props {
+ children: React.ReactNode;
+ listenerType?: MouseEventListener;
+ onClickOutside: () => void;
+}
+
+export default class OutsideClickHandler extends React.Component<Props> {
+ mounted = false;
+
+ componentDidMount() {
+ this.mounted = true;
+ setTimeout(() => {
+ this.addClickHandler();
+ }, 0);
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ this.removeClickHandler();
+ }
+
+ addClickHandler = () => {
+ const { listenerType = 'click' } = this.props;
+ window.addEventListener(listenerType, this.handleWindowClick);
+ };
+
+ removeClickHandler = () => {
+ const { listenerType = 'click' } = this.props;
+ window.removeEventListener(listenerType, this.handleWindowClick);
+ };
+
+ handleWindowClick = (event: MouseEvent) => {
+ if (this.mounted) {
+ // eslint-disable-next-line react/no-find-dom-node
+ const node = findDOMNode(this);
+ if (!node || !node.contains(event.target as Node)) {
+ this.props.onClickOutside();
+ }
+ }
+ };
+
+ render() {
+ return this.props.children;
+ }
+}
diff --git a/server/sonar-web/design-system/src/components/RadioButton.tsx b/server/sonar-web/design-system/src/components/RadioButton.tsx
new file mode 100644
index 00000000000..89858e73574
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/RadioButton.tsx
@@ -0,0 +1,125 @@
+/*
+ * 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 styled from '@emotion/styled';
+import classNames from 'classnames';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor } from '../helpers/theme';
+
+type AllowedRadioButtonAttributes = Pick<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ 'aria-label' | 'autoFocus' | 'id' | 'name' | 'style' | 'title' | 'type'
+>;
+
+interface Props extends AllowedRadioButtonAttributes {
+ checked: boolean;
+ children?: React.ReactNode;
+ className?: string;
+ disabled?: boolean;
+ onCheck: (value: string) => void;
+ value: string;
+}
+
+export default function RadioButton({
+ checked,
+ children,
+ className,
+ disabled,
+ onCheck,
+ value,
+ ...htmlProps
+}: Props) {
+ const handleChange = () => {
+ if (!disabled) {
+ onCheck(value);
+ }
+ };
+
+ return (
+ <label className={classNames('sw-flex sw-items-center', className)}>
+ <RadioButtonStyled
+ aria-disabled={disabled}
+ checked={checked}
+ disabled={disabled}
+ onChange={handleChange}
+ type="radio"
+ value={value}
+ {...htmlProps}
+ />
+ {children}
+ </label>
+ );
+}
+
+export const RadioButtonStyled = styled.input`
+ appearance: none; //disables native style
+ border: ${themeBorder('default', 'radioBorder')};
+
+ ${tw`sw-w-4 sw-min-w-4 sw-h-4 sw-min-h-4`}
+ ${tw`sw-p-1 sw-mr-2`}
+ ${tw`sw-inline-block`}
+ ${tw`sw-box-border`}
+ ${tw`sw-rounded-pill`}
+
+ &:hover {
+ background: ${themeColor('radioHover')};
+ }
+
+ &:focus,
+ &:focus-visible {
+ background: ${themeColor('radioHover')};
+ border: ${themeBorder('default', 'radioFocusBorder')};
+ outline: ${themeBorder('focus', 'radioFocusOutline')};
+ }
+
+ &:focus:checked,
+ &:focus-visible:checked,
+ &:hover:checked,
+ &:checked {
+ // Color cannot be used with multiple backgrounds, only image is allowed
+ background-image: linear-gradient(to right, ${themeColor('radio')}, ${themeColor('radio')}),
+ linear-gradient(to right, ${themeColor('radioChecked')}, ${themeColor('radioChecked')});
+ background-clip: content-box, padding-box;
+ border: ${themeBorder('default', 'radioBorder')};
+ }
+
+ &:disabled {
+ background: ${themeColor('radioDisabledBackground')};
+ border: ${themeBorder('default', 'radioDisabledBorder')};
+ background-clip: unset;
+
+ ${tw`sw-cursor-not-allowed`}
+
+ &:checked {
+ background-image: linear-gradient(
+ to right,
+ ${themeColor('radioDisabled')},
+ ${themeColor('radioDisabled')}
+ ),
+ linear-gradient(
+ to right,
+ ${themeColor('radioDisabledBackground')},
+ ${themeColor('radioDisabledBackground')}
+ );
+ background-clip: content-box, padding-box;
+ border: ${themeBorder('default', 'radioDisabledBorder')};
+ }
+ }
+`;
diff --git a/server/sonar-web/design-system/src/components/SonarQubeLogo.tsx b/server/sonar-web/design-system/src/components/SonarQubeLogo.tsx
new file mode 100644
index 00000000000..fcbe6b22798
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/SonarQubeLogo.tsx
@@ -0,0 +1,50 @@
+/*
+ * 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 styled from '@emotion/styled';
+
+const SonarQubeLogoSvg = styled.svg`
+ height: 40px;
+ width: 132px;
+`;
+
+export function SonarQubeLogo() {
+ return (
+ <SonarQubeLogoSvg viewBox="0 0 540.33 156.33" xmlns="http://www.w3.org/2000/svg">
+ <path
+ d="M11.89 101.92a29.92 29.92 0 0 0 13.23 3.74c4.65 0 6.57-1.62 6.57-4.14s-1.51-3.74-7.27-5.66c-10.21-3.44-14.15-9-14-14.85 0-9.2 7.89-16.17 20.11-16.17a33.07 33.07 0 0 1 13.95 2.83l-2.78 10.6A24.24 24.24 0 0 0 31 75.44c-3.74 0-5.87 1.51-5.87 4 0 2.33 1.93 3.54 8 5.66 9.4 3.23 13.34 8 13.44 15.26 0 9.19-7.27 16-21.42 16-6.47 0-12.22-1.42-16-3.44zM100.63 90.09c0 18.09-12.83 26.38-26.08 26.38C60.11 116.48 49 107 49 91s10.5-26.17 26.37-26.17c15.16 0 25.26 10.41 25.26 25.26zm-35.78.51c0 8.49 3.54 14.85 10.11 14.85 6 0 9.8-6 9.8-14.85 0-7.38-2.83-14.87-9.8-14.87-7.37.01-10.11 7.59-10.11 14.87zM106.11 81.71c0-6.16-.2-11.42-.41-15.76H119l.7 6.76h.31a18.08 18.08 0 0 1 15.25-7.88c10.11 0 17.69 6.66 17.69 21.22v29.31h-15.31V88c0-6.37-2.22-10.71-7.78-10.71a8.18 8.18 0 0 0-7.78 5.71 10.41 10.41 0 0 0-.61 3.84v28.51h-15.36zM189.39 115.36l-.91-5h-.3c-3.23 3.95-8.3 6.07-14.15 6.07-10 0-16-7.29-16-15.16 0-12.83 11.52-19 29-18.91v-.7c0-2.63-1.42-6.37-9-6.37a27.8 27.8 0 0 0-13.64 3.73l-2.84-9.9c3.44-1.93 10.21-4.35 19.2-4.35 16.48 0 21.73 9.7 21.73 21.32v17.18a75.92 75.92 0 0 0 .71 12zM187.58 92c-8.08-.1-14.35 1.83-14.35 7.78 0 3.95 2.63 5.87 6.07 5.87a8.39 8.39 0 0 0 8-5.66 10.87 10.87 0 0 0 .31-2.63zM210.63 82.21c0-7.27-.2-12-.41-16.26h13.24L224 75h.4c2.53-7.17 8.59-10.2 13.34-10.2a16.56 16.56 0 0 1 3.26.2v14.48a21.82 21.82 0 0 0-4.14-.41c-5.66 0-9.5 3-10.52 7.78a18.94 18.94 0 0 0-.3 3.44v25.07h-15.41zM342.35 102c0 5 .1 9.5.41 13.34h-7.89l-.51-8h-.19a18.43 18.43 0 0 1-16.17 9.1c-7.68 0-16.89-4.24-16.89-21.42V66.44H310v27.09c0 9.29 2.83 15.57 10.92 15.57a12.88 12.88 0 0 0 11.72-8.1 13.15 13.15 0 0 0 .81-4.55v-30h8.9zM352.67 115.36c.2-3.34.4-8.3.4-12.64V43.6h8.79v30.73h.2c3.13-5.46 8.79-9 16.68-9 12.12 0 20.71 10.11 20.61 25 0 17.49-11 26.18-21.92 26.18-7.08 0-12.73-2.73-16.37-9.2h-.31l-.4 8.09zm9.19-19.61a16.48 16.48 0 0 0 .41 3.23 13.71 13.71 0 0 0 13.33 10.41c9.31 0 14.85-7.58 14.85-18.79 0-9.8-5-18.19-14.55-18.19a14.17 14.17 0 0 0-13.54 10.91 17.47 17.47 0 0 0-.51 3.64zM411.5 92.52c.19 12 7.88 17 16.77 17a32.24 32.24 0 0 0 13.54-2.52l1.52 6.37c-3.13 1.41-8.49 3-16.27 3-15.06 0-24.06-9.9-24.06-24.65s8.69-26.38 22.94-26.38c16 0 20.21 14 20.21 23a33.67 33.67 0 0 1-.3 4.14zm26.07-6.37c.1-5.66-2.31-14.46-12.32-14.46-9 0-12.94 8.3-13.65 14.46z"
+ fill="#1b171b"
+ />
+ <path
+ d="M290.55 75.25a26.41 26.41 0 1 0-11.31 39.07l10.22 16.6 8.11-5.51-10.22-16.6a26.42 26.42 0 0 0 3.2-33.56M279.1 105.4a18.5 18.5 0 1 1 4.9-25.7 18.52 18.52 0 0 1-4.9 25.7"
+ fill="#1b171b"
+ fillRule="evenodd"
+ />
+ <path
+ d="M506.94 115.57h-6.27c0-50.44-41.62-91.48-92.78-91.48v-6.26c54.62 0 99.05 43.84 99.05 97.74z"
+ fill="#4e9bcd"
+ />
+ <path
+ d="M511.27 81.93c-7.52-31.65-33.16-58.06-65.27-67.29l1.44-5c33.93 9.74 61 37.65 68.95 71.1zM516.09 52.23a96 96 0 0 0-37.17-41.49l2.17-3.57a100.24 100.24 0 0 1 38.8 43.31z"
+ fill="#4e9bcd"
+ />
+ </SonarQubeLogoSvg>
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/Text.tsx b/server/sonar-web/design-system/src/components/Text.tsx
new file mode 100644
index 00000000000..277f5eca4b5
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/Text.tsx
@@ -0,0 +1,62 @@
+/*
+ * 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 styled from '@emotion/styled';
+import tw from 'twin.macro';
+import { themeColor, themeContrast } from '../helpers/theme';
+
+interface MainTextProps {
+ match?: string;
+ name: string;
+}
+
+export function SearchText({ match, name }: MainTextProps) {
+ return match ? (
+ <StyledText
+ // Safe: comes from the search engine, that injects bold tags into component names
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: match }}
+ />
+ ) : (
+ <StyledText title={name}>{name}</StyledText>
+ );
+}
+
+export function TextMuted({ text }: { text: string }) {
+ return <StyledMutedText title={text}>{text}</StyledMutedText>;
+}
+
+export const StyledText = styled.span`
+ ${tw`sw-inline-block`};
+ ${tw`sw-truncate`};
+ ${tw`sw-font-semibold`};
+ ${tw`sw-max-w-abs-600`}
+
+ mark {
+ ${tw`sw-inline-block`};
+
+ background: ${themeColor('searchHighlight')};
+ color: ${themeContrast('searchHighlight')};
+ }
+`;
+
+const StyledMutedText = styled(StyledText)`
+ ${tw`sw-font-regular`};
+ color: ${themeColor('dropdownMenuSubTitle')};
+`;
diff --git a/server/sonar-web/design-system/src/components/Tooltip.tsx b/server/sonar-web/design-system/src/components/Tooltip.tsx
new file mode 100644
index 00000000000..a298b7fdfd0
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/Tooltip.tsx
@@ -0,0 +1,504 @@
+/*
+ * 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, ThemeContext } from '@emotion/react';
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import { throttle } from 'lodash';
+import React from 'react';
+import { createPortal, findDOMNode } from 'react-dom';
+import tw from 'twin.macro';
+import { THROTTLE_SCROLL_DELAY } from '../helpers/constants';
+import {
+ BasePlacement,
+ PLACEMENT_FLIP_MAP,
+ PopupPlacement,
+ popupPositioning,
+} from '../helpers/positioning';
+import { themeColor, themeContrast } from '../helpers/theme';
+
+const MILLISECONDS_IN_A_SECOND = 1000;
+
+export interface TooltipProps {
+ children: React.ReactElement<{}>;
+ mouseEnterDelay?: number;
+ mouseLeaveDelay?: number;
+ onHide?: VoidFunction;
+ onShow?: VoidFunction;
+ overlay: React.ReactNode;
+ placement?: BasePlacement;
+ visible?: boolean;
+}
+
+interface Measurements {
+ height: number;
+ left: number;
+ leftFix: number;
+ top: number;
+ topFix: number;
+ width: number;
+}
+
+interface OwnState {
+ flipped: boolean;
+ placement?: PopupPlacement;
+ visible: boolean;
+}
+
+type State = OwnState & Partial<Measurements>;
+
+function isMeasured(state: State): state is OwnState & Measurements {
+ return state.height !== undefined;
+}
+
+export default function Tooltip(props: TooltipProps) {
+ // overlay is a ReactNode, so it can be a boolean, `undefined` or `null`
+ // this allows to easily render a tooltip conditionally
+ // more generaly we avoid rendering empty tooltips
+ return props.overlay ? <TooltipInner {...props}>{props.children}</TooltipInner> : props.children;
+}
+
+export class TooltipInner extends React.Component<TooltipProps, State> {
+ throttledPositionTooltip: VoidFunction;
+ mouseEnterTimeout?: number;
+ mouseLeaveTimeout?: number;
+ tooltipNode?: HTMLElement | null;
+ mounted = false;
+ mouseIn = false;
+
+ static defaultProps = {
+ mouseEnterDelay: 0.1,
+ };
+
+ constructor(props: TooltipProps) {
+ super(props);
+ this.state = {
+ flipped: false,
+ placement: props.placement,
+ visible: props.visible !== undefined ? props.visible : false,
+ };
+ this.throttledPositionTooltip = throttle(this.positionTooltip, THROTTLE_SCROLL_DELAY);
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ if (this.props.visible === true) {
+ this.positionTooltip();
+ this.addEventListeners();
+ }
+ }
+
+ componentDidUpdate(prevProps: TooltipProps, prevState: State) {
+ if (this.props.placement !== prevProps.placement) {
+ this.setState({ placement: this.props.placement }, () =>
+ this.onUpdatePlacement(this.hasVisibleChanged(prevState.visible, prevProps.visible))
+ );
+ } else if (this.hasVisibleChanged(prevState.visible, prevProps.visible)) {
+ this.onUpdateVisible();
+ } else if (!this.state.flipped && this.needsFlipping(this.state)) {
+ this.setState(
+ ({ placement = PopupPlacement.Bottom }) => ({
+ flipped: true,
+ placement: PLACEMENT_FLIP_MAP[placement],
+ }),
+ () => {
+ if (this.state.visible) {
+ // Force a re-positioning, as "only" updating the state doesn't
+ // recompute the position, only re-renders with the previous
+ // position (which is no longer correct).
+ this.positionTooltip();
+ }
+ }
+ );
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ this.removeEventListeners();
+ this.clearTimeouts();
+ }
+
+ static contextType = ThemeContext;
+
+ onUpdatePlacement = (visibleHasChanged: boolean) => {
+ this.setState({ placement: this.props.placement }, () => {
+ if (this.isVisible()) {
+ this.positionTooltip();
+ if (visibleHasChanged) {
+ this.addEventListeners();
+ }
+ }
+ });
+ };
+
+ onUpdateVisible = () => {
+ if (this.isVisible()) {
+ this.positionTooltip();
+ this.addEventListeners();
+ } else {
+ this.clearPosition();
+ this.removeEventListeners();
+ }
+ };
+
+ addEventListeners = () => {
+ window.addEventListener('resize', this.throttledPositionTooltip);
+ window.addEventListener('scroll', this.throttledPositionTooltip);
+ };
+
+ removeEventListeners = () => {
+ window.removeEventListener('resize', this.throttledPositionTooltip);
+ window.removeEventListener('scroll', this.throttledPositionTooltip);
+ };
+
+ clearTimeouts = () => {
+ window.clearTimeout(this.mouseEnterTimeout);
+ window.clearTimeout(this.mouseLeaveTimeout);
+ };
+
+ hasVisibleChanged = (prevStateVisible: boolean, prevPropsVisible?: boolean) => {
+ if (this.props.visible === undefined) {
+ return prevPropsVisible || this.state.visible !== prevStateVisible;
+ }
+ return this.props.visible !== prevPropsVisible;
+ };
+
+ isVisible = () => {
+ return this.props.visible ?? this.state.visible;
+ };
+
+ getPlacement = (): PopupPlacement => {
+ return this.state.placement || PopupPlacement.Bottom;
+ };
+
+ tooltipNodeRef = (node: HTMLElement | null) => {
+ this.tooltipNode = node;
+ };
+
+ adjustArrowPosition = (
+ placement: PopupPlacement,
+ { leftFix, topFix, height, width }: Measurements
+ ) => {
+ switch (placement) {
+ case PopupPlacement.Left:
+ case PopupPlacement.Right:
+ return {
+ marginTop: Math.max(0, Math.min(-topFix, height / 2 - ARROW_WIDTH * 2)),
+ };
+ default:
+ return {
+ marginLeft: Math.max(0, Math.min(-leftFix, width / 2 - ARROW_WIDTH * 2)),
+ };
+ }
+ };
+
+ positionTooltip = () => {
+ // `findDOMNode(this)` will search for the DOM node for the current component
+ // first it will find a React.Fragment (see `render`),
+ // so it will get the DOM node of the first child, i.e. DOM node of `this.props.children`
+ // docs: https://reactjs.org/docs/refs-and-the-dom.html#exposing-dom-refs-to-parent-components
+
+ // eslint-disable-next-line react/no-find-dom-node
+ const toggleNode = findDOMNode(this);
+ if (toggleNode && toggleNode instanceof Element && this.tooltipNode) {
+ const { height, left, leftFix, top, topFix, width } = popupPositioning(
+ toggleNode,
+ this.tooltipNode,
+ this.getPlacement()
+ );
+
+ // save width and height (and later set in `render`) to avoid resizing the popup element,
+ // when it's placed close to the window edge
+ this.setState({
+ left: window.scrollX + left,
+ leftFix,
+ top: window.scrollY + top,
+ topFix,
+ width,
+ height,
+ });
+ }
+ };
+
+ clearPosition = () => {
+ this.setState({
+ flipped: false,
+ left: undefined,
+ leftFix: undefined,
+ top: undefined,
+ topFix: undefined,
+ width: undefined,
+ height: undefined,
+ placement: this.props.placement,
+ });
+ };
+
+ handlePointerEnter = () => {
+ this.mouseEnterTimeout = window.setTimeout(() => {
+ // for some reason even after the `this.mouseEnterTimeout` is cleared, it still triggers
+ // to workaround this issue, check that its value is not `undefined`
+ // (if it's `undefined`, it means the timer has been reset)
+ if (
+ this.mounted &&
+ this.props.visible === undefined &&
+ this.mouseEnterTimeout !== undefined
+ ) {
+ this.setState({ visible: true });
+ }
+ }, (this.props.mouseEnterDelay || 0) * MILLISECONDS_IN_A_SECOND);
+
+ if (this.props.onShow) {
+ this.props.onShow();
+ }
+ };
+
+ handlePointerLeave = () => {
+ if (this.mouseEnterTimeout !== undefined) {
+ window.clearTimeout(this.mouseEnterTimeout);
+ this.mouseEnterTimeout = undefined;
+ }
+
+ if (!this.mouseIn) {
+ this.mouseLeaveTimeout = window.setTimeout(() => {
+ if (this.mounted && this.props.visible === undefined && !this.mouseIn) {
+ this.setState({ visible: false });
+ }
+ }, (this.props.mouseLeaveDelay || 0) * MILLISECONDS_IN_A_SECOND);
+
+ if (this.props.onHide) {
+ this.props.onHide();
+ }
+ }
+ };
+
+ handleOverlayPointerEnter = () => {
+ this.mouseIn = true;
+ };
+
+ handleOverlayPointerLeave = () => {
+ this.mouseIn = false;
+ this.handlePointerLeave();
+ };
+
+ handleChildPointerEnter = () => {
+ this.handlePointerEnter();
+
+ const { children } = this.props;
+ if (typeof children.props.onPointerEnter === 'function') {
+ children.props.onPointerEnter();
+ }
+ };
+
+ handleChildPointerLeave = () => {
+ this.handlePointerLeave();
+
+ const { children } = this.props;
+ if (typeof children.props.onPointerLeave === 'function') {
+ children.props.onPointerLeave();
+ }
+ };
+
+ needsFlipping = ({ leftFix, topFix }: State) => {
+ // We can live with a tooltip that's slightly positioned over the toggle
+ // node. Only trigger if it really starts overlapping, as the re-positioning
+ // is quite expensive, needing 2 re-renders.
+ const repositioningThreshold = 8;
+ switch (this.getPlacement()) {
+ case PopupPlacement.Left:
+ case PopupPlacement.Right:
+ return Boolean(leftFix && Math.abs(leftFix) > repositioningThreshold);
+ case PopupPlacement.Top:
+ case PopupPlacement.Bottom:
+ return Boolean(topFix && Math.abs(topFix) > repositioningThreshold);
+ default:
+ return false;
+ }
+ };
+
+ render() {
+ const placement = this.getPlacement();
+ const style = isMeasured(this.state)
+ ? {
+ left: this.state.left,
+ top: this.state.top,
+ width: this.state.width,
+ height: this.state.height,
+ }
+ : undefined;
+
+ return (
+ <>
+ {React.cloneElement(this.props.children, {
+ onPointerEnter: this.handleChildPointerEnter,
+ onPointerLeave: this.handleChildPointerLeave,
+ })}
+ {this.isVisible() && (
+ <TooltipPortal>
+ <TooltipWrapper
+ className={classNames(placement)}
+ onPointerEnter={this.handleOverlayPointerEnter}
+ onPointerLeave={this.handleOverlayPointerLeave}
+ ref={this.tooltipNodeRef}
+ role="tooltip"
+ style={style}
+ >
+ <TooltipWrapperInner>{this.props.overlay}</TooltipWrapperInner>
+ <TooltipWrapperArrow
+ style={
+ isMeasured(this.state)
+ ? this.adjustArrowPosition(placement, this.state)
+ : undefined
+ }
+ />
+ </TooltipWrapper>
+ </TooltipPortal>
+ )}
+ </>
+ );
+ }
+}
+
+class TooltipPortal extends React.Component {
+ el: HTMLElement;
+
+ constructor(props: {}) {
+ super(props);
+ this.el = document.createElement('div');
+ }
+
+ componentDidMount() {
+ document.body.appendChild(this.el);
+ }
+
+ componentWillUnmount() {
+ document.body.removeChild(this.el);
+ }
+
+ render() {
+ return createPortal(this.props.children, this.el);
+ }
+}
+
+const fadeIn = keyframes`
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+`;
+
+const ARROW_WIDTH = 6;
+const ARROW_HEIGHT = 7;
+const ARROW_MARGIN = 3;
+
+export const TooltipWrapper = styled.div`
+ animation: ${fadeIn} 0.3s forwards;
+
+ ${tw`sw-absolute`}
+ ${tw`sw-z-tooltip`};
+ ${tw`sw-block`};
+ ${tw`sw-box-border`};
+ ${tw`sw-h-auto`};
+ ${tw`sw-body-sm`};
+
+ &.top {
+ margin-top: -${ARROW_MARGIN}px;
+ padding: ${ARROW_HEIGHT}px 0;
+ }
+
+ &.right {
+ margin-left: ${ARROW_MARGIN}px;
+ padding: 0 ${ARROW_HEIGHT}px;
+ }
+
+ &.bottom {
+ margin-top: ${ARROW_MARGIN}px;
+ padding: ${ARROW_HEIGHT}px 0;
+ }
+
+ &.left {
+ margin-left: -${ARROW_MARGIN}px;
+ padding: 0 ${ARROW_HEIGHT}px;
+ }
+`;
+
+const TooltipWrapperArrow = styled.div`
+ ${tw`sw-absolute`};
+ ${tw`sw-w-0`};
+ ${tw`sw-h-0`};
+ ${tw`sw-border-solid`};
+ ${tw`sw-border-transparent`};
+ ${TooltipWrapper}.top & {
+ border-width: ${ARROW_HEIGHT}px ${ARROW_WIDTH}px 0;
+ border-top-color: ${themeColor('tooltipBackground')};
+ transform: translateX(-${ARROW_WIDTH}px);
+
+ ${tw`sw-bottom-0`};
+ ${tw`sw-left-1/2`};
+ }
+
+ ${TooltipWrapper}.right & {
+ border-width: ${ARROW_WIDTH}px ${ARROW_HEIGHT}px ${ARROW_WIDTH}px 0;
+ border-right-color: ${themeColor('tooltipBackground')};
+ transform: translateY(-${ARROW_WIDTH}px);
+
+ ${tw`sw-top-1/2`};
+ ${tw`sw-left-0`};
+ }
+
+ ${TooltipWrapper}.left & {
+ border-width: ${ARROW_WIDTH}px 0 ${ARROW_WIDTH}px ${ARROW_HEIGHT}px;
+ border-left-color: ${themeColor('tooltipBackground')};
+ transform: translateY(-${ARROW_WIDTH}px);
+
+ ${tw`sw-top-1/2`};
+ ${tw`sw-right-0`};
+ }
+
+ ${TooltipWrapper}.bottom & {
+ border-width: 0 ${ARROW_WIDTH}px ${ARROW_HEIGHT}px;
+ border-bottom-color: ${themeColor('tooltipBackground')};
+ transform: translateX(-${ARROW_WIDTH}px);
+
+ ${tw`sw-top-0`};
+ ${tw`sw-left-1/2`};
+ }
+`;
+
+export const TooltipWrapperInner = styled.div`
+ color: ${themeContrast('tooltipBackground')};
+ background-color: ${themeColor('tooltipBackground')};
+
+ ${tw`sw-max-w-[22rem]`}
+ ${tw`sw-py-3 sw-px-4`};
+ ${tw`sw-overflow-hidden`};
+ ${tw`sw-text-left`};
+ ${tw`sw-no-underline`};
+ ${tw`sw-break-words`};
+ ${tw`sw-rounded-2`};
+
+ hr {
+ background-color: ${themeColor('tooltipSeparator')};
+
+ ${tw`sw-mx-4`};
+ }
+`;
diff --git a/server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx
new file mode 100644
index 00000000000..d0aa180d0fa
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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.
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+
+import { fireEvent, screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { Avatar } from '../Avatar';
+
+const gravatarServerUrl = 'http://example.com/{EMAIL_MD5}.jpg?s={SIZE}';
+
+it('should render avatar with border', () => {
+ setupWithProps({ border: true, hash: '7daf6c79d4802916d83f6266e24850af' });
+ expect(screen.getByRole('img')).toHaveStyle('border: 1px solid rgb(225,230,243)');
+});
+
+it('should be able to render with hash only', () => {
+ setupWithProps({ hash: '7daf6c79d4802916d83f6266e24850af' });
+ expect(screen.getByRole('img')).toHaveAttribute(
+ 'src',
+ 'http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=48'
+ );
+});
+
+it('should fall back to generated on error', () => {
+ setupWithProps({ hash: '7daf6c79d4802916d83f6266e24850af' });
+ fireEvent(screen.getByRole('img'), new Event('error'));
+ expect(screen.getByRole('img')).not.toHaveAttribute('src');
+});
+
+it('should fall back to dummy avatar', () => {
+ setupWithProps({ enableGravatar: false });
+ expect(screen.getByRole('img')).not.toHaveAttribute('src');
+});
+
+it('should return null if no name is set', () => {
+ setupWithProps({ name: undefined });
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+});
+
+it('should display organization avatar correctly', () => {
+ const avatar = 'http://example.com/avatar.png';
+ setupWithProps({ organizationAvatar: avatar, organizationName: 'my-org' });
+ expect(screen.getByRole('img')).toHaveAttribute('src', avatar);
+});
+
+function setupWithProps(props: Partial<FCProps<typeof Avatar>> = {}) {
+ return render(
+ <Avatar enableGravatar={true} gravatarServerUrl={gravatarServerUrl} name="foo" {...props} />
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx
new file mode 100644
index 00000000000..d6b7c43d467
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx
@@ -0,0 +1,73 @@
+/*
+ * 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 * as React from 'react';
+import DeferredSpinner from '../DeferredSpinner';
+
+beforeAll(() => {
+ jest.useFakeTimers();
+});
+
+afterEach(() => {
+ jest.runOnlyPendingTimers();
+});
+
+afterAll(() => {
+ jest.useRealTimers();
+});
+
+it('renders children before timeout', () => {
+ renderDeferredSpinner({ children: <a href="#">foo</a> });
+ expect(screen.getByRole('link')).toBeInTheDocument();
+ jest.runAllTimers();
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+});
+
+it('renders spinner after timeout', () => {
+ renderDeferredSpinner();
+ expect(screen.queryByLabelText('loading')).not.toBeInTheDocument();
+ jest.runAllTimers();
+ expect(screen.getByLabelText('loading')).toBeInTheDocument();
+});
+
+it('allows setting a custom class name', () => {
+ renderDeferredSpinner({ className: 'foo' });
+ jest.runAllTimers();
+ expect(screen.getByLabelText('loading')).toHaveClass('foo');
+});
+
+it('can be controlled by the loading prop', () => {
+ const { rerender } = renderDeferredSpinner({ loading: true });
+ jest.runAllTimers();
+ expect(screen.getByLabelText('loading')).toBeInTheDocument();
+
+ rerender(prepareDeferredSpinner({ loading: false }));
+ expect(screen.queryByLabelText('loading')).not.toBeInTheDocument();
+});
+
+function renderDeferredSpinner(props: Partial<DeferredSpinner['props']> = {}) {
+ // We don't use our renderComponent() helper here, as we have some tests that
+ // require changes in props.
+ return render(prepareDeferredSpinner(props));
+}
+
+function prepareDeferredSpinner(props: Partial<DeferredSpinner['props']> = {}) {
+ return <DeferredSpinner {...props} />;
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx
new file mode 100644
index 00000000000..52139a0489d
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx
@@ -0,0 +1,65 @@
+/*
+ * 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 { screen } from '@testing-library/react';
+import { renderWithRouter } from '../../helpers/testUtils';
+import { ButtonSecondary } from '../buttons';
+import Dropdown, { ActionsDropdown } from '../Dropdown';
+
+describe('Dropdown', () => {
+ it('renders', async () => {
+ const { user } = setupWithChildren();
+ expect(screen.getByRole('button')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button'));
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ });
+
+ it('toggles with render prop', async () => {
+ const { user } = setupWithChildren(({ onToggleClick }) => (
+ <ButtonSecondary onClick={onToggleClick} />
+ ));
+
+ await user.click(screen.getByRole('button'));
+ expect(screen.getByRole('menu')).toBeVisible();
+ });
+
+ function setupWithChildren(children?: Dropdown['props']['children']) {
+ return renderWithRouter(
+ <Dropdown id="test-menu" overlay={<div id="overlay" />}>
+ {children ?? <ButtonSecondary />}
+ </Dropdown>
+ );
+ }
+});
+
+describe('ActionsDropdown', () => {
+ it('renders', () => {
+ setup();
+ expect(screen.getByRole('button')).toHaveAccessibleName('menu');
+ });
+
+ function setup() {
+ return renderWithRouter(
+ <ActionsDropdown id="test-menu">
+ <div id="overlay" />
+ </ActionsDropdown>
+ );
+ }
+});
diff --git a/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx
new file mode 100644
index 00000000000..350c6874e22
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx
@@ -0,0 +1,100 @@
+/*
+ * 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 { screen } from '@testing-library/react';
+import { noop } from 'lodash';
+import { render, renderWithRouter } from '../../helpers/testUtils';
+import {
+ DropdownMenu,
+ ItemButton,
+ ItemCheckbox,
+ ItemCopy,
+ ItemDangerButton,
+ ItemDivider,
+ ItemHeader,
+ ItemLink,
+ ItemNavLink,
+ ItemRadioButton,
+} from '../DropdownMenu';
+import MenuIcon from '../icons/MenuIcon';
+import Tooltip from '../Tooltip';
+
+beforeEach(() => {
+ jest.useFakeTimers();
+});
+
+afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+});
+
+it('should render a full menu correctly', () => {
+ renderDropdownMenu();
+ expect(screen.getByRole('menuitem', { name: 'My header' })).toBeInTheDocument();
+ expect(screen.getByRole('menuitem', { name: 'Test menu item' })).toBeInTheDocument();
+ expect(screen.getByRole('menuitem', { name: 'Test disabled item' })).toHaveClass('disabled');
+});
+
+it('menu items should work with tooltips', async () => {
+ const { user } = render(
+ <Tooltip overlay="test tooltip">
+ <ItemButton onClick={jest.fn()}>button</ItemButton>
+ </Tooltip>,
+ {},
+ { delay: null }
+ );
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+ await user.hover(screen.getByRole('menuitem'));
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+ jest.runAllTimers();
+ expect(screen.getByRole('tooltip')).toBeVisible();
+
+ await user.unhover(screen.getByRole('menuitem'));
+ expect(screen.getByRole('tooltip')).toBeVisible();
+
+ jest.runAllTimers();
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+});
+
+function renderDropdownMenu() {
+ return renderWithRouter(
+ <DropdownMenu>
+ <ItemHeader>My header</ItemHeader>
+ <ItemNavLink to="/test">Test menu item</ItemNavLink>
+ <ItemDivider />
+ <ItemLink disabled={true} to="/test-disabled">
+ Test disabled item
+ </ItemLink>
+ <ItemButton icon={<MenuIcon />} onClick={noop}>
+ Button
+ </ItemButton>
+ <ItemDangerButton onClick={noop}>DangerButton</ItemDangerButton>
+ <ItemCopy copyValue="copy">Copy</ItemCopy>
+ <ItemCheckbox checked={true} onCheck={noop}>
+ Checkbox item
+ </ItemCheckbox>
+ <ItemRadioButton checked={false} onCheck={noop} value="radios">
+ Radio item
+ </ItemRadioButton>
+ </DropdownMenu>
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx b/server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx
new file mode 100644
index 00000000000..83b7fdf6bc3
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx
@@ -0,0 +1,51 @@
+/*
+ * 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 { screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { GenericAvatar } from '../GenericAvatar';
+import { CustomIcon, IconProps } from '../icons/Icon';
+
+function TestIcon(props: IconProps) {
+ return (
+ <CustomIcon {...props}>
+ <path d="l10 10" />
+ </CustomIcon>
+ );
+}
+
+it('should render single word and size', () => {
+ render(<GenericAvatar name="foo" size={15} />);
+ const image = screen.getByRole('img');
+ expect(image).toHaveAttribute('size', '15');
+ expect(screen.getByText('F')).toBeInTheDocument();
+});
+
+it('should render multiple word with default size', () => {
+ render(<GenericAvatar name="foo bar" />);
+ const image = screen.getByRole('img');
+ expect(image).toHaveAttribute('size', '24');
+ expect(screen.getByText('F')).toBeInTheDocument();
+});
+
+it('should render without name', () => {
+ render(<GenericAvatar Icon={TestIcon} name="" size={32} />);
+ const image = screen.getByRole('img');
+ expect(image).toHaveAttribute('size', '32');
+});
diff --git a/server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx b/server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx
new file mode 100644
index 00000000000..1d9f6068e56
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx
@@ -0,0 +1,90 @@
+/*
+ * 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 { screen, waitFor } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import InputSearch from '../InputSearch';
+
+it('should warn when input is too short', async () => {
+ const { user } = setupWithProps({ value: 'f' });
+ expect(screen.getByRole('note')).toBeInTheDocument();
+ await user.type(screen.getByRole('searchbox'), 'oo');
+ expect(screen.queryByRole('note')).not.toBeInTheDocument();
+});
+
+it('should show clear button only when there is a value', async () => {
+ const { user } = setupWithProps({ value: 'f' });
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ await user.clear(screen.getByRole('searchbox'));
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+});
+
+it('should attach ref', () => {
+ const ref = jest.fn();
+ setupWithProps({ innerRef: ref });
+ expect(ref).toHaveBeenCalled();
+ expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLInputElement);
+});
+
+it('should trigger reset correctly with clear button', async () => {
+ const onChange = jest.fn();
+ const { user } = setupWithProps({ onChange });
+ await user.click(screen.getByRole('button'));
+ expect(onChange).toHaveBeenCalledWith('');
+});
+
+it('should trigger change correctly', async () => {
+ const onChange = jest.fn();
+ const { user } = setupWithProps({ onChange, value: 'f' });
+ await user.type(screen.getByRole('searchbox'), 'oo');
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledWith('foo');
+ });
+});
+
+it('should not change when value is too short', async () => {
+ const onChange = jest.fn();
+ const { user } = setupWithProps({ onChange, value: '', minLength: 3 });
+ await user.type(screen.getByRole('searchbox'), 'fo');
+ expect(onChange).not.toHaveBeenCalled();
+});
+
+it('should clear input using escape', async () => {
+ const onChange = jest.fn();
+ const { user } = setupWithProps({ onChange, value: 'foo' });
+ await user.type(screen.getByRole('searchbox'), '{Escape}');
+ expect(onChange).toHaveBeenCalledWith('');
+});
+
+function setupWithProps(props: Partial<FCProps<typeof InputSearch>> = {}) {
+ return render(
+ <InputSearch
+ clearIconAriaLabel=""
+ maxLength={150}
+ minLength={2}
+ onChange={jest.fn()}
+ placeholder="placeholder"
+ searchInputAriaLabel=""
+ tooShortText=""
+ value="foo"
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/Link-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Link-test.tsx
new file mode 100644
index 00000000000..295469720f0
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/Link-test.tsx
@@ -0,0 +1,129 @@
+/*
+ * 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 { screen } from '@testing-library/react';
+import React from 'react';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { render } from '../../helpers/testUtils';
+import Link, { DiscreetLink } from '../Link';
+
+beforeAll(() => {
+ const { location } = window;
+ delete (window as any).location;
+ window.location = { ...location, href: '' };
+});
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+// This functionality won't be needed once we update the breadcrumbs
+it('should remove focus after link is clicked', async () => {
+ const { user } = setupWithMemoryRouter(
+ <Link blurAfterClick={true} icon={<div>Icon</div>} to="/initial" />
+ );
+
+ await user.click(screen.getByRole('link'));
+
+ expect(screen.getByRole('link')).not.toHaveFocus();
+});
+
+it('should prevent default when preventDefault is true', async () => {
+ const { user } = setupWithMemoryRouter(<Link preventDefault={true} to="/second" />);
+
+ expect(screen.getByText('/initial')).toBeVisible();
+
+ await user.click(screen.getByRole('link'));
+
+ // prevent default behavior of page navigation
+ expect(screen.getByText('/initial')).toBeVisible();
+ expect(screen.queryByText('/second')).not.toBeInTheDocument();
+});
+
+it('should stop propagation when stopPropagation is true', async () => {
+ const buttonOnClick = jest.fn();
+
+ const { user } = setupWithMemoryRouter(
+ <button onClick={buttonOnClick} type="button">
+ <Link stopPropagation={true} to="/second" />
+ </button>
+ );
+
+ await user.click(screen.getByRole('link'));
+
+ expect(buttonOnClick).not.toHaveBeenCalled();
+});
+
+it('should call onClick when one is passed', async () => {
+ const onClick = jest.fn();
+ const { user } = setupWithMemoryRouter(
+ <Link onClick={onClick} stopPropagation={true} to="/second" />
+ );
+
+ await user.click(screen.getByRole('link'));
+
+ expect(onClick).toHaveBeenCalled();
+});
+
+it('internal link should be clickable', async () => {
+ const { user } = setupWithMemoryRouter(<Link to="/second">internal link</Link>);
+ expect(screen.getByRole('link')).toBeVisible();
+
+ await user.click(screen.getByRole('link'));
+
+ expect(screen.getByText('/second')).toBeVisible();
+});
+
+it('external links are indicated by OpenNewTabIcon', () => {
+ setupWithMemoryRouter(<Link to="https://google.com">external link</Link>);
+ expect(screen.getByRole('link')).toBeVisible();
+
+ expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
+});
+
+it('discreet links also can be external indicated by the OpenNewTabIcon', () => {
+ setupWithMemoryRouter(<DiscreetLink to="https://google.com">external link</DiscreetLink>);
+ expect(screen.getByRole('link')).toBeVisible();
+
+ expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
+});
+
+function ShowPath() {
+ const { pathname } = useLocation();
+ return <pre>{pathname}</pre>;
+}
+
+const setupWithMemoryRouter = (component: JSX.Element, initialEntries = ['/initial']) => {
+ return render(
+ <MemoryRouter initialEntries={initialEntries}>
+ <Routes>
+ <Route
+ element={
+ <>
+ {component}
+ <ShowPath />
+ </>
+ }
+ path="/initial"
+ />
+ <Route element={<ShowPath />} path="/second" />
+ </Routes>
+ </MemoryRouter>
+ );
+};
diff --git a/server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx b/server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx
new file mode 100644
index 00000000000..fdc66f2a441
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+
+import { screen } from '@testing-library/react';
+import { LAYOUT_LOGO_MAX_HEIGHT, LAYOUT_LOGO_MAX_WIDTH } from '../../helpers/constants';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { MainAppBar } from '../MainAppBar';
+import { SonarQubeLogo } from '../SonarQubeLogo';
+
+it('should render the main app bar with max-height and max-width constraints on the logo', () => {
+ setupWithProps();
+
+ expect(screen.getByRole('img')).toHaveStyle({
+ border: 'none',
+ 'max-height': `${LAYOUT_LOGO_MAX_HEIGHT}px`,
+ 'max-width': `${LAYOUT_LOGO_MAX_WIDTH}px`,
+ 'object-fit': 'contain',
+ });
+});
+
+it('should render the logo', () => {
+ const element = setupWithProps({ Logo: SonarQubeLogo });
+
+ // eslint-disable-next-line testing-library/no-node-access
+ expect(element.container.querySelector('svg')).toHaveStyle({ height: '40px', width: '132px' });
+});
+
+function setupWithProps(
+ props: FCProps<typeof MainAppBar> = {
+ Logo: () => <img alt="logo" src="http://example.com/logo.png" />,
+ }
+) {
+ return render(<MainAppBar {...props} />);
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx b/server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx
new file mode 100644
index 00000000000..b3120afbe71
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+
+import { screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { MainMenuItem } from '../MainMenuItem';
+
+it('should render default', () => {
+ render(
+ <MainMenuItem>
+ <a>Hi</a>
+ </MainMenuItem>
+ );
+
+ expect(screen.getByText('Hi')).toHaveStyle({
+ color: 'rgb(62, 67, 87)',
+ 'border-bottom': '3px solid transparent',
+ });
+});
+
+it('should render active link', () => {
+ render(
+ <MainMenuItem>
+ <a className="active">Hi</a>
+ </MainMenuItem>
+ );
+
+ expect(screen.getByText('Hi')).toHaveStyle({
+ color: 'rgb(62, 67, 87)',
+ 'border-bottom': '3px solid rgba(123,135,217,1)',
+ });
+});
+
+it('should render hovered link', () => {
+ render(
+ <MainMenuItem>
+ <a className="hover">Hi</a>
+ </MainMenuItem>
+ );
+
+ expect(screen.getByText('Hi')).toHaveStyle({
+ color: 'rgb(42, 47, 64)',
+ 'border-bottom': '3px solid rgba(123,135,217,1)',
+ });
+});
diff --git a/server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx b/server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx
new file mode 100644
index 00000000000..548cfb6c238
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx
@@ -0,0 +1,112 @@
+/*
+ * 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 { screen } from '@testing-library/react';
+import React from 'react';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { render } from '../../helpers/testUtils';
+import NavLink from '../NavLink';
+
+beforeAll(() => {
+ const { location } = window;
+ delete (window as any).location;
+ window.location = { ...location, href: '' };
+});
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+it('should remove focus after link is clicked', async () => {
+ const { user } = setupWithMemoryRouter(<NavLink blurAfterClick={true} to="/initial" />);
+
+ await user.click(screen.getByRole('link'));
+
+ expect(screen.getByRole('link')).not.toHaveFocus();
+});
+
+it('should prevent default when preventDefault is true', async () => {
+ const { user } = setupWithMemoryRouter(<NavLink preventDefault={true} to="/second" />);
+
+ expect(screen.getByText('/initial')).toBeVisible();
+
+ await user.click(screen.getByRole('link'));
+
+ // prevent default behavior of page navigation
+ expect(screen.getByText('/initial')).toBeVisible();
+ expect(screen.queryByText('/second')).not.toBeInTheDocument();
+});
+
+it('should stop propagation when stopPropagation is true', async () => {
+ const buttonOnClick = jest.fn();
+
+ const { user } = setupWithMemoryRouter(
+ <button onClick={buttonOnClick} type="button">
+ <NavLink stopPropagation={true} to="/second" />
+ </button>
+ );
+
+ await user.click(screen.getByRole('link'));
+
+ expect(buttonOnClick).not.toHaveBeenCalled();
+});
+
+it('should call onClick when one is passed', async () => {
+ const onClick = jest.fn();
+ const { user } = setupWithMemoryRouter(
+ <NavLink onClick={onClick} stopPropagation={true} to="/second" />
+ );
+
+ await user.click(screen.getByRole('link'));
+
+ expect(onClick).toHaveBeenCalled();
+});
+
+it('NavLink should be clickable', async () => {
+ const { user } = setupWithMemoryRouter(<NavLink to="/second">internal link</NavLink>);
+ expect(screen.getByRole('link')).toBeVisible();
+
+ await user.click(screen.getByRole('link'));
+
+ expect(screen.getByText('/second')).toBeVisible();
+});
+
+function ShowPath() {
+ const { pathname } = useLocation();
+ return <pre>{pathname}</pre>;
+}
+
+const setupWithMemoryRouter = (component: JSX.Element, initialEntries = ['/initial']) => {
+ return render(
+ <MemoryRouter initialEntries={initialEntries}>
+ <Routes>
+ <Route
+ element={
+ <>
+ {component}
+ <ShowPath />
+ </>
+ }
+ path="/initial"
+ />
+ <Route element={<ShowPath />} path="/second" />
+ </Routes>
+ </MemoryRouter>
+ );
+};
diff --git a/server/sonar-web/design-system/src/components/__tests__/Text-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Text-test.tsx
new file mode 100644
index 00000000000..5743a92a7b0
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/Text-test.tsx
@@ -0,0 +1,41 @@
+/*
+ * 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.
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+
+import { screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { SearchText, TextMuted } from '../Text';
+
+it('should render SearchText', () => {
+ render(<SearchText match="hi" name="hiya" />);
+
+ expect(screen.getByText('hi')).toHaveStyle({
+ 'font-weight': '600',
+ });
+});
+
+it('should render TextMuted', () => {
+ render(<TextMuted text="Hi" />);
+
+ expect(screen.getByText('Hi')).toHaveStyle({
+ color: 'rgb(106, 117, 144)',
+ });
+});
diff --git a/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx
new file mode 100644
index 00000000000..8b448d17521
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx
@@ -0,0 +1,126 @@
+/*
+ * 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 { screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import Tooltip, { TooltipInner } from '../Tooltip';
+
+jest.mock('react-dom', () => {
+ const reactDom = jest.requireActual('react-dom');
+ return { ...reactDom, findDOMNode: jest.fn().mockReturnValue(undefined) };
+});
+
+describe('TooltipInner', () => {
+ it('should open & close', async () => {
+ const onShow = jest.fn();
+ const onHide = jest.fn();
+ const { user } = setupWithProps({ onHide, onShow });
+
+ await user.hover(screen.getByRole('note'));
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+ expect(onShow).toHaveBeenCalled();
+
+ await user.unhover(screen.getByRole('note'));
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ expect(onHide).toHaveBeenCalled();
+ });
+
+ it('should not shadow children pointer events', async () => {
+ const onShow = jest.fn();
+ const onHide = jest.fn();
+ const onPointerEnter = jest.fn();
+ const onPointerLeave = jest.fn();
+ const { user } = setupWithProps(
+ { onHide, onShow },
+ <div onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave} role="note" />
+ );
+
+ await user.hover(screen.getByRole('note'));
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+ expect(onShow).toHaveBeenCalled();
+ expect(onPointerEnter).toHaveBeenCalled();
+
+ await user.unhover(screen.getByRole('note'));
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ expect(onHide).toHaveBeenCalled();
+ expect(onPointerLeave).toHaveBeenCalled();
+ });
+
+ it('should not open when mouse goes away quickly', async () => {
+ const { user } = setupWithProps();
+
+ await user.hover(screen.getByRole('note'));
+ await user.unhover(screen.getByRole('note'));
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+
+ it('should position the tooltip correctly', async () => {
+ const onShow = jest.fn();
+ const onHide = jest.fn();
+ const { user } = setupWithProps({ onHide, onShow });
+
+ await user.hover(screen.getByRole('note'));
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+ expect(screen.getByRole('tooltip')).toHaveClass('bottom');
+ });
+
+ function setupWithProps(
+ props: Partial<TooltipInner['props']> = {},
+ children = <div role="note" />
+ ) {
+ return render(
+ <TooltipInner mouseLeaveDelay={0} overlay={<span id="overlay" />} {...props}>
+ {children}
+ </TooltipInner>
+ );
+ }
+});
+
+describe('Tooltip', () => {
+ it('should not render tooltip without overlay', async () => {
+ const { user } = setupWithProps({ overlay: undefined });
+ await user.hover(screen.getByRole('note'));
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+
+ it('should not render undefined tooltips', async () => {
+ const { user } = setupWithProps({ overlay: undefined, visible: true });
+ await user.hover(screen.getByRole('note'));
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+
+ it('should not render empty tooltips', async () => {
+ const { user } = setupWithProps({ overlay: '', visible: true });
+ await user.hover(screen.getByRole('note'));
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+
+ function setupWithProps(
+ props: Partial<FCProps<typeof Tooltip>> = {},
+ children = <div role="note" />
+ ) {
+ return render(
+ <Tooltip overlay={<span id="overlay" />} {...props}>
+ {children}
+ </Tooltip>
+ );
+ }
+});
diff --git a/server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx b/server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx
new file mode 100644
index 00000000000..84a44f103b1
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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 { screen, waitForElementToBeRemoved } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithContext } from '../../helpers/testUtils';
+import { ClipboardButton, ClipboardIconButton } from '../clipboard';
+
+beforeEach(() => {
+ jest.useFakeTimers();
+});
+
+afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+});
+
+describe('ClipboardButton', () => {
+ it('should display correctly', async () => {
+ /* Delay: null is necessary to play well with fake timers
+ * https://github.com/testing-library/user-event/issues/833
+ */
+ const user = userEvent.setup({ delay: null });
+ renderClipboardButton();
+
+ expect(screen.getByRole('button', { name: 'copy' })).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'copy' }));
+
+ expect(await screen.findByText('copied_action')).toBeVisible();
+
+ await waitForElementToBeRemoved(() => screen.queryByText('copied_action'));
+ jest.runAllTimers();
+ });
+
+ it('should render a custom label if provided', () => {
+ renderClipboardButton('Foo Bar');
+ expect(screen.getByRole('button', { name: 'Foo Bar' })).toBeInTheDocument();
+ });
+
+ function renderClipboardButton(children?: React.ReactNode) {
+ renderWithContext(<ClipboardButton copyValue="foo">{children}</ClipboardButton>);
+ }
+});
+
+describe('ClipboardIconButton', () => {
+ it('should display correctly', () => {
+ renderWithContext(<ClipboardIconButton copyValue="foo" />);
+
+ const copyButton = screen.getByRole('button', { name: 'copy_to_clipboard' });
+ expect(copyButton).toBeInTheDocument();
+ });
+});
diff --git a/server/sonar-web/design-system/src/components/buttons.tsx b/server/sonar-web/design-system/src/components/buttons.tsx
new file mode 100644
index 00000000000..442026354ea
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/buttons.tsx
@@ -0,0 +1,219 @@
+/*
+ * 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 { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import { ThemedProps } from '../types/theme';
+import { BaseLink, LinkProps } from './Link';
+
+type AllowedButtonAttributes = Pick<
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
+ 'aria-label' | 'autoFocus' | 'id' | 'name' | 'style' | 'title' | 'type'
+>;
+
+export interface ButtonProps extends AllowedButtonAttributes {
+ children?: React.ReactNode;
+ className?: string;
+ disabled?: boolean;
+ icon?: React.ReactNode;
+ innerRef?: React.Ref<HTMLButtonElement>;
+ onClick?: VoidFunction;
+
+ preventDefault?: boolean;
+ reloadDocument?: LinkProps['reloadDocument'];
+ stopPropagation?: boolean;
+ target?: LinkProps['target'];
+ to?: LinkProps['to'];
+}
+
+class Button extends React.PureComponent<ButtonProps> {
+ handleClick = (event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
+ const { disabled, onClick, stopPropagation = false, type } = this.props;
+ const { preventDefault = type !== 'submit' } = this.props;
+
+ event.currentTarget.blur();
+
+ if (preventDefault || disabled) {
+ event.preventDefault();
+ }
+
+ if (stopPropagation) {
+ event.stopPropagation();
+ }
+
+ if (onClick && !disabled) {
+ onClick();
+ }
+ };
+
+ render() {
+ const {
+ children,
+ disabled,
+ icon,
+ innerRef,
+ onClick,
+ preventDefault,
+ stopPropagation,
+ to,
+ type = 'button',
+ ...htmlProps
+ } = this.props;
+
+ const props = {
+ ...htmlProps,
+ 'aria-disabled': disabled,
+ disabled,
+ type,
+ };
+
+ if (to) {
+ return (
+ <BaseButtonLink {...props} onClick={onClick} to={to}>
+ {icon}
+ {children}
+ </BaseButtonLink>
+ );
+ }
+
+ return (
+ <BaseButton {...props} onClick={this.handleClick} ref={innerRef}>
+ {icon}
+ {children}
+ </BaseButton>
+ );
+ }
+}
+
+const buttonStyle = (props: ThemedProps) => css`
+ box-sizing: border-box;
+ text-decoration: none;
+ outline: none;
+ border: var(--border);
+ color: var(--color);
+ background-color: var(--background);
+ transition: background-color 0.2s ease, outline 0.2s ease;
+
+ ${tw`sw-inline-flex sw-items-center`}
+ ${tw`sw-h-control`}
+ ${tw`sw-body-sm-highlight`}
+ ${tw`sw-py-2 sw-px-4`}
+ ${tw`sw-rounded-2`}
+ ${tw`sw-cursor-pointer`}
+
+ &:hover {
+ color: var(--color);
+ background-color: var(--backgroundHover);
+ }
+
+ &:focus,
+ &:active {
+ color: var(--color);
+ outline: ${themeBorder('focus', 'var(--focus)')(props)};
+ }
+
+ &:disabled,
+ &:disabled:hover {
+ color: ${themeContrast('buttonDisabled')(props)};
+ background-color: ${themeColor('buttonDisabled')(props)};
+ border: ${themeBorder('default', 'buttonDisabledBorder')(props)};
+
+ ${tw`sw-cursor-not-allowed`}
+ }
+
+ & > svg {
+ ${tw`sw-mr-1`}
+ }
+`;
+
+const BaseButtonLink = styled(BaseLink)`
+ ${buttonStyle}
+`;
+
+const BaseButton = styled.button`
+ ${buttonStyle}
+
+ /* Workaround for tooltips issue with onMouseLeave in disabled buttons: https://github.com/facebook/react/issues/4251 */
+ & [disabled] {
+ ${tw`sw-pointer-events-none`};
+ }
+`;
+
+export const ButtonPrimary: React.FC<ButtonProps> = styled(Button)`
+ --background: ${themeColor('button')};
+ --backgroundHover: ${themeColor('buttonHover')};
+ --color: ${themeContrast('primary')};
+ --focus: ${themeColor('button', 0.2)};
+ --border: ${themeBorder('default', 'transparent')};
+`;
+
+export const ButtonSecondary: React.FC<ButtonProps> = styled(Button)`
+ --background: ${themeColor('buttonSecondary')};
+ --backgroundHover: ${themeColor('buttonSecondaryHover')};
+ --color: ${themeContrast('buttonSecondary')};
+ --focus: ${themeColor('buttonSecondaryBorder', 0.2)};
+ --border: ${themeBorder('default', 'buttonSecondaryBorder')};
+`;
+
+export const DangerButtonPrimary: React.FC<ButtonProps> = styled(Button)`
+ --background: ${themeColor('dangerButton')};
+ --backgroundHover: ${themeColor('dangerButtonHover')};
+ --color: ${themeContrast('dangerButton')};
+ --focus: ${themeColor('dangerButtonFocus', 0.2)};
+ --border: ${themeBorder('default', 'transparent')};
+`;
+
+export const DangerButtonSecondary: React.FC<ButtonProps> = styled(Button)`
+ --background: ${themeColor('dangerButtonSecondary')};
+ --backgroundHover: ${themeColor('dangerButtonSecondaryHover')};
+ --color: ${themeContrast('dangerButtonSecondary')};
+ --focus: ${themeColor('dangerButtonSecondaryFocus', 0.2)};
+ --border: ${themeBorder('default', 'dangerButtonSecondaryBorder')};
+`;
+
+interface ThirdPartyProps extends Omit<ButtonProps, 'Icon'> {
+ iconPath: string;
+ name: string;
+}
+
+export function ThirdPartyButton({ children, iconPath, name, ...buttonProps }: ThirdPartyProps) {
+ const size = 16;
+ return (
+ <ThirdPartyButtonStyled {...buttonProps}>
+ <img alt={name} className="sw-mr-1" height={size} src={iconPath} width={size} />
+ {children}
+ </ThirdPartyButtonStyled>
+ );
+}
+
+const ThirdPartyButtonStyled: React.FC<ButtonProps> = styled(Button)`
+ --background: ${themeColor('thirdPartyButton')};
+ --backgroundHover: ${themeColor('thirdPartyButtonHover')};
+ --color: ${themeContrast('thirdPartyButton')};
+ --focus: ${themeColor('thirdPartyButtonBorder', 0.2)};
+ --border: ${themeBorder('default', 'thirdPartyButtonBorder')};
+`;
+
+export const BareButton = styled.button`
+ all: unset;
+ cursor: pointer;
+`;
diff --git a/server/sonar-web/design-system/src/components/clipboard.tsx b/server/sonar-web/design-system/src/components/clipboard.tsx
new file mode 100644
index 00000000000..ea05963f772
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/clipboard.tsx
@@ -0,0 +1,170 @@
+/*
+ * 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 classNames from 'classnames';
+import Clipboard from 'clipboard';
+import React from 'react';
+import { INTERACTIVE_TOOLTIP_DELAY } from '../helpers/constants';
+import { translate } from '../helpers/l10n';
+import { ButtonSecondary } from './buttons';
+import CopyIcon from './icons/CopyIcon';
+import { IconProps } from './icons/Icon';
+import { DiscreetInteractiveIcon, InteractiveIcon, InteractiveIconSize } from './InteractiveIcon';
+import Tooltip from './Tooltip';
+
+const COPY_SUCCESS_NOTIFICATION_LIFESPAN = 1000;
+
+export interface State {
+ copySuccess: boolean;
+}
+
+interface RenderProps {
+ copySuccess: boolean;
+ setCopyButton: (node: HTMLElement | null) => void;
+}
+
+interface BaseProps {
+ children: (props: RenderProps) => React.ReactNode;
+}
+
+export class ClipboardBase extends React.PureComponent<BaseProps, State> {
+ private clipboard?: Clipboard;
+ private copyButton?: HTMLElement | null;
+ mounted = false;
+ state: State = { copySuccess: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ if (this.copyButton) {
+ this.clipboard = new Clipboard(this.copyButton);
+ this.clipboard.on('success', this.handleSuccessCopy);
+ }
+ }
+
+ componentDidUpdate() {
+ if (this.clipboard) {
+ this.clipboard.destroy();
+ }
+ if (this.copyButton) {
+ this.clipboard = new Clipboard(this.copyButton);
+ this.clipboard.on('success', this.handleSuccessCopy);
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ if (this.clipboard) {
+ this.clipboard.destroy();
+ }
+ }
+
+ setCopyButton = (node: HTMLElement | null) => {
+ this.copyButton = node;
+ };
+
+ handleSuccessCopy = () => {
+ if (this.mounted) {
+ this.setState({ copySuccess: true });
+ setTimeout(() => {
+ if (this.mounted) {
+ this.setState({ copySuccess: false });
+ }
+ }, COPY_SUCCESS_NOTIFICATION_LIFESPAN);
+ }
+ };
+
+ render() {
+ return this.props.children({
+ setCopyButton: this.setCopyButton,
+ copySuccess: this.state.copySuccess,
+ });
+ }
+}
+
+interface ButtonProps {
+ children?: React.ReactNode;
+ className?: string;
+ copyValue: string;
+ icon?: React.ReactNode;
+}
+
+export function ClipboardButton({
+ icon = <CopyIcon />,
+ className,
+ children,
+ copyValue,
+}: ButtonProps) {
+ return (
+ <ClipboardBase>
+ {({ setCopyButton, copySuccess }) => (
+ <Tooltip overlay={translate('copied_action')} visible={copySuccess}>
+ <ButtonSecondary
+ className={classNames('sw-select-none', className)}
+ data-clipboard-text={copyValue}
+ icon={icon}
+ innerRef={setCopyButton}
+ >
+ {children || translate('copy')}
+ </ButtonSecondary>
+ </Tooltip>
+ )}
+ </ClipboardBase>
+ );
+}
+
+interface IconButtonProps {
+ Icon?: React.ComponentType<IconProps>;
+ 'aria-label'?: string;
+ className?: string;
+ copyValue: string;
+ discreet?: boolean;
+ size?: InteractiveIconSize;
+}
+
+export function ClipboardIconButton(props: IconButtonProps) {
+ const { className, copyValue, discreet, size = 'small', Icon = CopyIcon } = props;
+ const InteractiveIconComponent = discreet ? DiscreetInteractiveIcon : InteractiveIcon;
+
+ return (
+ <ClipboardBase>
+ {({ setCopyButton, copySuccess }) => {
+ return (
+ <Tooltip
+ mouseEnterDelay={INTERACTIVE_TOOLTIP_DELAY}
+ overlay={
+ <div className="sw-w-abs-150 sw-text-center">
+ {translate(copySuccess ? 'copied_action' : 'copy_to_clipboard')}
+ </div>
+ }
+ {...(copySuccess ? { visible: copySuccess } : undefined)}
+ >
+ <InteractiveIconComponent
+ Icon={Icon}
+ aria-label={props['aria-label'] ?? translate('copy_to_clipboard')}
+ className={className}
+ data-clipboard-text={copyValue}
+ innerRef={setCopyButton}
+ size={size}
+ />
+ </Tooltip>
+ );
+ }}
+ </ClipboardBase>
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/CheckIcon.tsx b/server/sonar-web/design-system/src/components/icons/CheckIcon.tsx
new file mode 100644
index 00000000000..dff5e8b4455
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/CheckIcon.tsx
@@ -0,0 +1,36 @@
+/*
+ * 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 { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers/theme';
+import { CustomIcon, IconProps } from './Icon';
+
+export default function CheckIcon({ fill = 'iconCheck', ...iconProps }: IconProps) {
+ const theme = useTheme();
+ return (
+ <CustomIcon {...iconProps}>
+ <path
+ clipRule="evenodd"
+ d="M11.6634 5.47789c.2884.29737.2811.77218-.0163 1.06054L7.52211 10.5384c-.29414.2852-.76273.2816-1.05244-.0081l-2-1.99997c-.29289-.29289-.29289-.76777 0-1.06066s.76777-.29289 1.06066 0L7.0081 8.94744l3.5948-3.48586c.2974-.28836.7722-.28105 1.0605.01631Z"
+ fill={themeColor(fill)({ theme })}
+ fillRule="evenodd"
+ />
+ </CustomIcon>
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/ClockIcon.tsx b/server/sonar-web/design-system/src/components/icons/ClockIcon.tsx
new file mode 100644
index 00000000000..15f81c7a302
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/ClockIcon.tsx
@@ -0,0 +1,23 @@
+/*
+ * 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 { ClockIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(ClockIcon);
diff --git a/server/sonar-web/design-system/src/components/icons/CloseIcon.tsx b/server/sonar-web/design-system/src/components/icons/CloseIcon.tsx
new file mode 100644
index 00000000000..79fb0888398
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/CloseIcon.tsx
@@ -0,0 +1,23 @@
+/*
+ * 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 { XIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(XIcon, 'CloseIcon');
diff --git a/server/sonar-web/design-system/src/components/icons/CopyIcon.tsx b/server/sonar-web/design-system/src/components/icons/CopyIcon.tsx
new file mode 100644
index 00000000000..e9f12579961
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/CopyIcon.tsx
@@ -0,0 +1,23 @@
+/*
+ * 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 { CopyIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(CopyIcon);
diff --git a/server/sonar-web/design-system/src/components/icons/Icon.tsx b/server/sonar-web/design-system/src/components/icons/Icon.tsx
new file mode 100644
index 00000000000..0603fe83cfe
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/Icon.tsx
@@ -0,0 +1,86 @@
+/*
+ * 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 { useTheme } from '@emotion/react';
+import { OcticonProps } from '@primer/octicons-react';
+import React from 'react';
+import { theme } from 'twin.macro';
+import { themeColor } from '../../helpers/theme';
+import { CSSColor, ThemeColors } from '../../types/theme';
+
+interface Props {
+ 'aria-label'?: string;
+ children: React.ReactNode;
+ className?: string;
+}
+
+export interface IconProps extends Omit<Props, 'children'> {
+ fill?: ThemeColors | CSSColor;
+}
+
+export function CustomIcon(props: Props) {
+ const { 'aria-label': ariaLabel, children, className, ...iconProps } = props;
+ return (
+ <svg
+ aria-hidden={ariaLabel ? 'false' : 'true'}
+ aria-label={ariaLabel}
+ className={className}
+ fill="none"
+ height={theme('height.icon')}
+ role="img"
+ style={{
+ clipRule: 'evenodd',
+ display: 'inline-block',
+ fillRule: 'evenodd',
+ userSelect: 'none',
+ verticalAlign: 'middle',
+ strokeLinejoin: 'round',
+ strokeMiterlimit: 1.414,
+ }}
+ version="1.1"
+ viewBox="0 0 16 16"
+ width={theme('width.icon')}
+ xmlSpace="preserve"
+ xmlnsXlink="http://www.w3.org/1999/xlink"
+ {...iconProps}
+ >
+ {children}
+ </svg>
+ );
+}
+
+export function OcticonHoc(
+ WrappedOcticon: React.ComponentType<OcticonProps>,
+ displayName?: string
+): React.ComponentType<IconProps> {
+ function IconWrapper({ fill, ...props }: IconProps) {
+ const theme = useTheme();
+ return (
+ <WrappedOcticon
+ fill={fill && themeColor(fill)({ theme })}
+ size="small"
+ verticalAlign="middle"
+ {...props}
+ />
+ );
+ }
+
+ IconWrapper.displayName = displayName || WrappedOcticon.displayName || WrappedOcticon.name;
+ return IconWrapper;
+}
diff --git a/server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx b/server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx
new file mode 100644
index 00000000000..5fcebecdf93
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx
@@ -0,0 +1,36 @@
+/*
+ * 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 { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers/theme';
+import { CustomIcon, IconProps } from './Icon';
+
+export default function MenuHelpIcon({ fill = 'currentColor', ...iconProps }: IconProps) {
+ const theme = useTheme();
+ return (
+ <CustomIcon {...iconProps}>
+ <path
+ clipRule="evenodd"
+ d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16Zm.507-5.451H6.66v-.166c.005-1.704.462-2.226 1.28-2.742.6-.38 1.062-.803 1.062-1.441 0-.677-.53-1.116-1.188-1.116-.638 0-1.227.424-1.257 1.218H4.571c.044-1.948 1.486-2.873 3.254-2.873 1.933 0 3.307.993 3.307 2.698 0 1.144-.595 1.86-1.505 2.4-.77.463-1.11.906-1.12 1.856v.166Zm.282 1.948a1.185 1.185 0 0 1-1.169 1.169 1.164 1.164 0 1 1 0-2.328c.624 0 1.164.52 1.169 1.159Z"
+ fill={themeColor(fill)({ theme })}
+ fillRule="evenodd"
+ />
+ </CustomIcon>
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/MenuIcon.tsx b/server/sonar-web/design-system/src/components/icons/MenuIcon.tsx
new file mode 100644
index 00000000000..ea30d7ddf9a
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/MenuIcon.tsx
@@ -0,0 +1,29 @@
+/*
+ * 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 styled from '@emotion/styled';
+import { KebabHorizontalIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+const MenuIcon = styled(OcticonHoc(KebabHorizontalIcon))`
+ transform: rotate(90deg);
+`;
+
+MenuIcon.displayName = 'MenuIcon';
+export default MenuIcon;
diff --git a/server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx b/server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx
new file mode 100644
index 00000000000..a09077285e8
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx
@@ -0,0 +1,37 @@
+/*
+ * 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 { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers/theme';
+import { CustomIcon, IconProps } from './Icon';
+
+export default function MenuSearchIcon({ fill = 'currentColor', ...iconProps }: IconProps) {
+ const theme = useTheme();
+
+ return (
+ <CustomIcon {...iconProps}>
+ <path
+ clipRule="evenodd"
+ d="M12 7c0 2.76142-2.23858 5-5 5S2 9.76142 2 7s2.23858-5 5-5 5 2.23858 5 5Zm-.8078 5.6064C10.0236 13.4816 8.57234 14 7 14c-3.86599 0-7-3.134-7-7 0-3.86599 3.13401-7 7-7 3.866 0 7 3.13401 7 7 0 1.57234-.5184 3.0236-1.3936 4.1922l3.0505 3.0504c.3905.3906.3905 1.0237 0 1.4143-.3906.3905-1.0237.3905-1.4143 0l-3.0504-3.0505Z"
+ fill={themeColor(fill)({ theme })}
+ fillRule="evenodd"
+ />
+ </CustomIcon>
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx b/server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx
new file mode 100644
index 00000000000..f856c0ce7ee
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx
@@ -0,0 +1,23 @@
+/*
+ * 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 { LinkExternalIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(LinkExternalIcon, 'OpenNewTabIcon');
diff --git a/server/sonar-web/design-system/src/components/icons/SearchIcon.tsx b/server/sonar-web/design-system/src/components/icons/SearchIcon.tsx
new file mode 100644
index 00000000000..674ac699a6e
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/SearchIcon.tsx
@@ -0,0 +1,23 @@
+/*
+ * 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 { SearchIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(SearchIcon);
diff --git a/server/sonar-web/design-system/src/components/icons/StarIcon.tsx b/server/sonar-web/design-system/src/components/icons/StarIcon.tsx
new file mode 100644
index 00000000000..f83c9a340a5
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/StarIcon.tsx
@@ -0,0 +1,23 @@
+/*
+ * 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 { StarIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(StarIcon);
diff --git a/server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx b/server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx
new file mode 100644
index 00000000000..4d25af63048
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx
@@ -0,0 +1,54 @@
+/*
+ * 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 { CheckIcon } from '@primer/octicons-react';
+import { screen } from '@testing-library/react';
+import { render } from '../../../helpers/testUtils';
+import { CustomIcon, OcticonHoc } from '../Icon';
+
+it('should render custom icon correctly', () => {
+ render(
+ <CustomIcon>
+ <path d="test" />
+ </CustomIcon>
+ );
+
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ expect(screen.getByRole('img', { hidden: true })).toContainHTML('<path d="test"/>');
+});
+
+it('should not be hidden when aria-label is set', () => {
+ render(
+ <CustomIcon aria-label="test">
+ <path d="test" />
+ </CustomIcon>
+ );
+
+ expect(screen.getByRole('img')).toBeVisible();
+});
+
+describe('Octicon HOC', () => {
+ it('should render correctly', () => {
+ const Wrapped = OcticonHoc(CheckIcon, 'TestIcon');
+
+ render(<Wrapped aria-label="visible" />);
+
+ expect(screen.getByRole('img')).toBeVisible();
+ });
+});
diff --git a/server/sonar-web/design-system/src/components/icons/index.ts b/server/sonar-web/design-system/src/components/icons/index.ts
new file mode 100644
index 00000000000..8b30b791711
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/index.ts
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+export { default as ClockIcon } from './ClockIcon';
+export { default as MenuHelpIcon } from './MenuHelpIcon';
+export { default as MenuSearchIcon } from './MenuSearchIcon';
+export { default as OpenNewTabIcon } from './OpenNewTabIcon';
+export { default as StarIcon } from './StarIcon';
diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts
index a96434d2ea2..e7bdcf4ca80 100644
--- a/server/sonar-web/design-system/src/components/index.ts
+++ b/server/sonar-web/design-system/src/components/index.ts
@@ -18,4 +18,21 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-export * from './DummyComponent';
+export * from './Avatar';
+export * from './buttons';
+export { default as DeferredSpinner } from './DeferredSpinner';
+export { default as Dropdown } from './Dropdown';
+export * from './DropdownMenu';
+export { default as DropdownToggler } from './DropdownToggler';
+export * from './GenericAvatar';
+export * from './icons';
+export { default as InputSearch } from './InputSearch';
+export * from './InteractiveIcon';
+export { default as Link } from './Link';
+export * from './MainAppBar';
+export * from './MainMenu';
+export { MainMenuItem } from './MainMenuItem';
+export * from './popups';
+export * from './SonarQubeLogo';
+export * from './Text';
+export { default as Tooltip } from './Tooltip';
diff --git a/server/sonar-web/design-system/src/components/popups.tsx b/server/sonar-web/design-system/src/components/popups.tsx
new file mode 100644
index 00000000000..e517ceb7f7d
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/popups.tsx
@@ -0,0 +1,256 @@
+/*
+ * 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 styled from '@emotion/styled';
+import classNames from 'classnames';
+import { throttle } from 'lodash';
+import React, { AriaRole } from 'react';
+import { createPortal, findDOMNode } from 'react-dom';
+import tw from 'twin.macro';
+import { THROTTLE_SCROLL_DELAY } from '../helpers/constants';
+import { PopupPlacement, popupPositioning, PopupZLevel } from '../helpers/positioning';
+import { themeBorder, themeColor, themeContrast, themeShadow } from '../helpers/theme';
+import ClickEventBoundary from './ClickEventBoundary';
+
+interface PopupProps {
+ 'aria-labelledby'?: string;
+ children?: React.ReactNode;
+ className?: string;
+ id?: string;
+ placement?: PopupPlacement;
+ role?: AriaRole;
+ style?: React.CSSProperties;
+ zLevel?: PopupZLevel;
+}
+
+function PopupBase(props: PopupProps, ref: React.Ref<HTMLDivElement>) {
+ const {
+ children,
+ className,
+ placement = PopupPlacement.Bottom,
+ style,
+ zLevel = PopupZLevel.Default,
+ ...ariaProps
+ } = props;
+ return (
+ <ClickEventBoundary>
+ <PopupWrapper
+ className={classNames(`is-${placement}`, className)}
+ ref={ref || React.createRef()}
+ style={style}
+ zLevel={zLevel}
+ {...ariaProps}
+ >
+ {children}
+ </PopupWrapper>
+ </ClickEventBoundary>
+ );
+}
+
+const PopupWithRef = React.forwardRef(PopupBase);
+PopupWithRef.displayName = 'Popup';
+
+export const Popup = PopupWithRef;
+
+interface PortalPopupProps extends Omit<PopupProps, 'style'> {
+ allowResizing?: boolean;
+ children: React.ReactNode;
+ overlay: React.ReactNode;
+}
+
+interface Measurements {
+ height: number;
+ left: number;
+ top: number;
+ width: number;
+}
+
+type State = Partial<Measurements>;
+
+function isMeasured(state: State): state is Measurements {
+ return state.height !== undefined;
+}
+
+export class PortalPopup extends React.PureComponent<PortalPopupProps, State> {
+ mounted = false;
+ popupNode = React.createRef<HTMLDivElement>();
+ throttledPositionTooltip: () => void;
+
+ constructor(props: PortalPopupProps) {
+ super(props);
+ this.state = {};
+ this.throttledPositionTooltip = throttle(this.positionPopup, THROTTLE_SCROLL_DELAY);
+ }
+
+ componentDidMount() {
+ this.positionPopup();
+ this.addEventListeners();
+ this.mounted = true;
+ }
+
+ componentDidUpdate(prevProps: PortalPopupProps) {
+ if (this.props.placement !== prevProps.placement || this.props.overlay !== prevProps.overlay) {
+ this.positionPopup();
+ }
+ }
+
+ componentWillUnmount() {
+ this.removeEventListeners();
+ this.mounted = false;
+ }
+
+ addEventListeners = () => {
+ window.addEventListener('resize', this.throttledPositionTooltip);
+ if (this.props.zLevel !== PopupZLevel.Global) {
+ window.addEventListener('scroll', this.throttledPositionTooltip);
+ }
+ };
+
+ removeEventListeners = () => {
+ window.removeEventListener('resize', this.throttledPositionTooltip);
+ if (this.props.zLevel !== PopupZLevel.Global) {
+ window.removeEventListener('scroll', this.throttledPositionTooltip);
+ }
+ };
+
+ positionPopup = () => {
+ if (this.mounted) {
+ // `findDOMNode(this)` will search for the DOM node for the current component
+ // first it will find a React.Fragment (see `render`),
+ // so it will get the DOM node of the first child, i.e. DOM node of `this.props.children`
+ // docs: https://reactjs.org/docs/refs-and-the-dom.html#exposing-dom-refs-to-parent-components
+
+ // eslint-disable-next-line react/no-find-dom-node
+ const toggleNode = findDOMNode(this);
+ if (toggleNode && toggleNode instanceof Element && this.popupNode.current) {
+ const { placement, zLevel } = this.props;
+ const isGlobal = zLevel === PopupZLevel.Global;
+ const { height, left, top, width } = popupPositioning(
+ toggleNode,
+ this.popupNode.current,
+ placement
+ );
+
+ // save width and height (and later set in `render`) to avoid resizing the popup element,
+ // when it's placed close to the window edge
+ this.setState({
+ left: left + (isGlobal ? 0 : window.scrollX),
+ top: top + (isGlobal ? 0 : window.scrollY),
+ width,
+ height,
+ });
+ }
+ }
+ };
+
+ render() {
+ const {
+ allowResizing,
+ children,
+ overlay,
+ placement = PopupPlacement.Bottom,
+ ...popupProps
+ } = this.props;
+
+ let style: React.CSSProperties | undefined;
+ if (isMeasured(this.state)) {
+ style = { left: this.state.left, top: this.state.top };
+ if (!allowResizing) {
+ style.width = this.state.width;
+ style.height = this.state.height;
+ }
+ }
+ return (
+ <>
+ {this.props.children}
+ {this.props.overlay && (
+ <PortalWrapper>
+ <Popup placement={placement} ref={this.popupNode} style={style} {...popupProps}>
+ {overlay}
+ </Popup>
+ </PortalWrapper>
+ )}
+ </>
+ );
+ }
+}
+
+const PopupWrapper = styled.div<{ zLevel: PopupZLevel }>`
+ position: ${({ zLevel }) => (zLevel === PopupZLevel.Global ? 'fixed' : 'absolute')};
+ background-color: ${themeColor('popup')};
+ color: ${themeContrast('popup')};
+ border: ${themeBorder('default', 'popupBorder')};
+ box-shadow: ${themeShadow('md')};
+
+ ${tw`sw-box-border`};
+ ${tw`sw-rounded-2`};
+ ${tw`sw-cursor-default`};
+ ${tw`sw-overflow-hidden`};
+ ${({ zLevel }) =>
+ ({
+ [PopupZLevel.Default]: tw`sw-z-popup`,
+ [PopupZLevel.Global]: tw`sw-z-global-popup`,
+ [PopupZLevel.Content]: tw`sw-z-content-popup`,
+ }[zLevel])};
+
+ &.is-bottom,
+ &.is-bottom-left,
+ &.is-bottom-right {
+ ${tw`sw-mt-2`};
+ }
+
+ &.is-top,
+ &.is-top-left,
+ &.is-top-right {
+ ${tw`sw--mt-2`};
+ }
+
+ &.is-left,
+ &.is-left-top,
+ &.is-left-bottom {
+ ${tw`sw--ml-2`};
+ }
+
+ &.is-right,
+ &.is-right-top,
+ &.is-right-bottom {
+ ${tw`sw-ml-2`};
+ }
+`;
+
+class PortalWrapper extends React.Component {
+ el: HTMLElement;
+
+ constructor(props: {}) {
+ super(props);
+ this.el = document.createElement('div');
+ }
+
+ componentDidMount() {
+ document.body.appendChild(this.el);
+ }
+
+ componentWillUnmount() {
+ document.body.removeChild(this.el);
+ }
+
+ render() {
+ return createPortal(this.props.children, this.el);
+ }
+}
diff --git a/server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts b/server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts
new file mode 100644
index 00000000000..eead6e02c5c
--- /dev/null
+++ b/server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts
@@ -0,0 +1,61 @@
+/*
+ * 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 * as colors from '../colors';
+
+describe('#stringToColor', () => {
+ it('should return a color for a text', () => {
+ expect(colors.stringToColor('skywalker')).toBe('#97f047');
+ });
+});
+
+describe('#isDarkColor', () => {
+ it('should be dark', () => {
+ expect(colors.isDarkColor('#000000')).toBe(true);
+ expect(colors.isDarkColor('#222222')).toBe(true);
+ expect(colors.isDarkColor('#000')).toBe(true);
+ });
+ it('should be light', () => {
+ expect(colors.isDarkColor('#FFFFFF')).toBe(false);
+ expect(colors.isDarkColor('#CDCDCD')).toBe(false);
+ expect(colors.isDarkColor('#FFF')).toBe(false);
+ });
+});
+
+describe('#getTextColor', () => {
+ it('should return dark color', () => {
+ expect(colors.getTextColor('#FFF', 'dark', 'light')).toBe('dark');
+ expect(colors.getTextColor('#FFF')).toBe('#222');
+ });
+ it('should return light color', () => {
+ expect(colors.getTextColor('#000', 'dark', 'light')).toBe('light');
+ expect(colors.getTextColor('#000')).toBe('#fff');
+ });
+});
+
+describe('rgb array to color', () => {
+ it('should return rgb color without opacity', () => {
+ expect(colors.getRGBAString([0, 0, 0])).toBe('rgb(0,0,0)');
+ expect(colors.getRGBAString([255, 255, 255])).toBe('rgb(255,255,255)');
+ });
+ it('should return rgba color with opacity', () => {
+ expect(colors.getRGBAString([5, 6, 100], 0.05)).toBe('rgba(5,6,100,0.05)');
+ expect(colors.getRGBAString([255, 255, 255], 0)).toBe('rgba(255,255,255,0)');
+ });
+});
diff --git a/server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts b/server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts
new file mode 100644
index 00000000000..7953d3531c9
--- /dev/null
+++ b/server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts
@@ -0,0 +1,167 @@
+/*
+ * 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 { PopupPlacement, popupPositioning } from '../positioning';
+
+const toggleRect = {
+ getBoundingClientRect: jest.fn().mockReturnValue({
+ left: 400,
+ top: 200,
+ width: 50,
+ height: 20,
+ }),
+} as any;
+
+const popupRect = {
+ getBoundingClientRect: jest.fn().mockReturnValue({
+ width: 200,
+ height: 100,
+ }),
+} as any;
+
+beforeAll(() => {
+ Object.defineProperties(document.documentElement, {
+ clientWidth: {
+ configurable: true,
+ value: 1000,
+ },
+ clientHeight: {
+ configurable: true,
+ value: 1000,
+ },
+ });
+});
+
+it('should calculate positioning based on placement', () => {
+ const fixes = { leftFix: 0, topFix: 0 };
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Bottom)).toMatchObject({
+ left: 325,
+ top: 220,
+ ...fixes,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.BottomLeft)).toMatchObject({
+ left: 400,
+ top: 220,
+ ...fixes,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.BottomRight)).toMatchObject({
+ left: 250,
+ top: 220,
+ ...fixes,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Top)).toMatchObject({
+ left: 325,
+ top: 100,
+ ...fixes,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.TopLeft)).toMatchObject({
+ left: 400,
+ top: 100,
+ ...fixes,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.TopRight)).toMatchObject({
+ left: 250,
+ top: 100,
+ ...fixes,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Left)).toMatchObject({
+ left: 200,
+ top: 160,
+ ...fixes,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.LeftBottom)).toMatchObject({
+ left: 200,
+ top: 120,
+ ...fixes,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.LeftTop)).toMatchObject({
+ left: 200,
+ top: 200,
+ ...fixes,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Right)).toMatchObject({
+ left: 450,
+ top: 160,
+ ...fixes,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.RightBottom)).toMatchObject({
+ left: 450,
+ top: 120,
+ ...fixes,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.RightTop)).toMatchObject({
+ left: 450,
+ top: 200,
+ ...fixes,
+ });
+});
+
+it('should position the element in the boundaries of the screen', () => {
+ toggleRect.getBoundingClientRect.mockReturnValueOnce({
+ left: 0,
+ top: 850,
+ width: 50,
+ height: 50,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Bottom)).toMatchObject({
+ left: 4,
+ leftFix: 79,
+ top: 896,
+ topFix: -4,
+ });
+ toggleRect.getBoundingClientRect.mockReturnValueOnce({
+ left: 900,
+ top: 0,
+ width: 50,
+ height: 50,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Top)).toMatchObject({
+ left: 796,
+ leftFix: -29,
+ top: 4,
+ topFix: 104,
+ });
+});
+
+it('should position the element outside the boundaries of the screen when the toggle is outside', () => {
+ toggleRect.getBoundingClientRect.mockReturnValueOnce({
+ left: -100,
+ top: 1100,
+ width: 50,
+ height: 50,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Bottom)).toMatchObject({
+ left: -75,
+ leftFix: 100,
+ top: 1025,
+ topFix: -125,
+ });
+ toggleRect.getBoundingClientRect.mockReturnValueOnce({
+ left: 1500,
+ top: -200,
+ width: 50,
+ height: 50,
+ });
+ expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Top)).toMatchObject({
+ left: 1325,
+ leftFix: -100,
+ top: -175,
+ topFix: 125,
+ });
+});
diff --git a/server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts b/server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts
new file mode 100644
index 00000000000..66f1b97ce05
--- /dev/null
+++ b/server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts
@@ -0,0 +1,148 @@
+/*
+ * 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 * as ThemeHelper from '../../helpers/theme';
+import { lightTheme } from '../../theme';
+
+const props = {
+ color: 'rgb(0,0,0)',
+};
+
+describe('getProp', () => {
+ it('should work', () => {
+ expect(ThemeHelper.getProp('color')(props)).toEqual('rgb(0,0,0)');
+ });
+});
+
+describe('themeColor', () => {
+ it('should work for light theme', () => {
+ expect(ThemeHelper.themeColor('backgroundPrimary')({ theme: lightTheme })).toEqual(
+ 'rgb(252,252,253)'
+ );
+ });
+
+ it('should work with a theme-defined opacity', () => {
+ expect(ThemeHelper.themeColor('bannerIconHover')({ theme: lightTheme })).toEqual(
+ 'rgba(217,45,32,0.2)'
+ );
+ });
+
+ it('should work for all kind of color parameters', () => {
+ expect(ThemeHelper.themeColor('transparent')({ theme: lightTheme })).toEqual('transparent');
+ expect(ThemeHelper.themeColor('currentColor')({ theme: lightTheme })).toEqual('currentColor');
+ expect(ThemeHelper.themeColor('var(--test)')({ theme: lightTheme })).toEqual('var(--test)');
+ expect(ThemeHelper.themeColor('rgb(0,0,0)')({ theme: lightTheme })).toEqual('rgb(0,0,0)');
+ expect(ThemeHelper.themeColor('rgba(0,0,0,1)')({ theme: lightTheme })).toEqual('rgba(0,0,0,1)');
+ expect(
+ ThemeHelper.themeColor(ThemeHelper.themeContrast('backgroundPrimary')({ theme: lightTheme }))(
+ {
+ theme: lightTheme,
+ }
+ )
+ ).toEqual('rgb(8,9,12)');
+ expect(
+ ThemeHelper.themeColor(ThemeHelper.themeAvatarColor('luke')({ theme: lightTheme }))({
+ theme: lightTheme,
+ })
+ ).toEqual('rgb(209,215,254)');
+ });
+});
+
+describe('themeContrast', () => {
+ it('should work for light theme', () => {
+ expect(ThemeHelper.themeContrast('backgroundPrimary')({ theme: lightTheme })).toEqual(
+ 'rgb(8,9,12)'
+ );
+ });
+
+ it('should work for all kind of color parameters', () => {
+ expect(ThemeHelper.themeContrast('var(--test)')({ theme: lightTheme })).toEqual('var(--test)');
+ expect(ThemeHelper.themeContrast('rgb(0,0,0)')({ theme: lightTheme })).toEqual('rgb(0,0,0)');
+ expect(ThemeHelper.themeContrast('rgba(0,0,0,1)')({ theme: lightTheme })).toEqual(
+ 'rgba(0,0,0,1)'
+ );
+ expect(
+ ThemeHelper.themeContrast(ThemeHelper.themeColor('backgroundPrimary')({ theme: lightTheme }))(
+ {
+ theme: lightTheme,
+ }
+ )
+ ).toEqual('rgb(252,252,253)');
+ expect(
+ ThemeHelper.themeContrast(ThemeHelper.themeAvatarColor('luke')({ theme: lightTheme }))({
+ theme: lightTheme,
+ })
+ ).toEqual('rgb(209,215,254)');
+ expect(
+ ThemeHelper.themeContrast('backgroundPrimary')({
+ theme: {
+ ...lightTheme,
+ contrasts: { ...lightTheme.contrasts, backgroundPrimary: 'inherit' },
+ },
+ })
+ ).toEqual('inherit');
+ });
+});
+
+describe('themeBorder', () => {
+ it('should work for light theme', () => {
+ expect(ThemeHelper.themeBorder()({ theme: lightTheme })).toEqual('1px solid rgb(235,235,235)');
+ });
+ it('should allow to override the color of the border', () => {
+ expect(ThemeHelper.themeBorder('focus', 'primaryLight')({ theme: lightTheme })).toEqual(
+ '4px solid rgba(123,135,217,0.2)'
+ );
+ });
+ it('should allow to override the opacity of the border', () => {
+ expect(ThemeHelper.themeBorder('focus', undefined, 0.5)({ theme: lightTheme })).toEqual(
+ '4px solid rgba(197,205,223,0.5)'
+ );
+ });
+ it('should allow to pass a CSS prop as color name', () => {
+ expect(
+ ThemeHelper.themeBorder('focus', 'var(--outlineColor)', 0.5)({ theme: lightTheme })
+ ).toEqual('4px solid var(--outlineColor)');
+ });
+});
+
+describe('themeShadow', () => {
+ it('should work for light theme', () => {
+ expect(ThemeHelper.themeShadow('xs')({ theme: lightTheme })).toEqual(
+ '0px 1px 2px 0px rgba(29,33,47,0.05)'
+ );
+ });
+ it('should allow to override the color of the shadow', () => {
+ expect(ThemeHelper.themeShadow('xs', 'backgroundPrimary')({ theme: lightTheme })).toEqual(
+ '0px 1px 2px 0px rgba(252,252,253,0.05)'
+ );
+ expect(ThemeHelper.themeShadow('xs', 'transparent')({ theme: lightTheme })).toEqual(
+ '0px 1px 2px 0px transparent'
+ );
+ });
+ it('should allow to override the opacity of the shadow', () => {
+ expect(ThemeHelper.themeShadow('xs', 'backgroundPrimary', 0.8)({ theme: lightTheme })).toEqual(
+ '0px 1px 2px 0px rgba(252,252,253,0.8)'
+ );
+ });
+ it('should allow to pass a CSS prop as color name', () => {
+ expect(ThemeHelper.themeShadow('xs', 'var(--shadowColor)')({ theme: lightTheme })).toEqual(
+ '0px 1px 2px 0px var(--shadowColor)'
+ );
+ });
+});
diff --git a/server/sonar-web/design-system/src/helpers/colors.ts b/server/sonar-web/design-system/src/helpers/colors.ts
new file mode 100644
index 00000000000..d0cb5e215ca
--- /dev/null
+++ b/server/sonar-web/design-system/src/helpers/colors.ts
@@ -0,0 +1,56 @@
+/*
+ * 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 { CSSColor } from '../types/theme';
+
+/* eslint-disable no-bitwise, no-mixed-operators */
+export function stringToColor(str: string) {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ let color = '#';
+ for (let i = 0; i < 3; i++) {
+ const value = (hash >> (i * 8)) & 0xff;
+ color += ('00' + value.toString(16)).substr(-2);
+ }
+ return color;
+}
+
+export function isDarkColor(color: string) {
+ color = color.substr(1);
+ if (color.length === 3) {
+ // shortcut notation: #f90
+ color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2];
+ }
+ const rgb = parseInt(color.substr(1), 16);
+ const r = (rgb >> 16) & 0xff;
+ const g = (rgb >> 8) & 0xff;
+ const b = (rgb >> 0) & 0xff;
+ const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ return luma < 140;
+}
+
+export function getTextColor(background: string, dark = '#222', light = '#fff') {
+ return isDarkColor(background) ? light : dark;
+}
+
+export function getRGBAString([r, g, b]: Array<number | string>, a?: number | string) {
+ return (a !== undefined ? `rgba(${r},${g},${b},${a})` : `rgb(${r},${g},${b})`) as CSSColor;
+}
diff --git a/server/sonar-web/design-system/src/helpers/constants.ts b/server/sonar-web/design-system/src/helpers/constants.ts
new file mode 100644
index 00000000000..68a385c3c1c
--- /dev/null
+++ b/server/sonar-web/design-system/src/helpers/constants.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 { theme } from 'twin.macro';
+
+export const DEFAULT_LOCALE = 'en';
+export const IS_SSR = typeof window === 'undefined';
+export const REACT_DOM_CONTAINER = '#___gatsby';
+
+export const RULE_STATUSES = ['READY', 'BETA', 'DEPRECATED'];
+
+export const THROTTLE_SCROLL_DELAY = 10;
+export const THROTTLE_KEYPRESS_DELAY = 100;
+
+export const DEBOUNCE_DELAY = 250;
+
+export const DEBOUNCE_LONG_DELAY = 1000;
+
+export const DEBOUNCE_SUCCESS_DELAY = 1000;
+
+export const INTERACTIVE_TOOLTIP_DELAY = 0.5;
+
+export const LEAK_PERIOD = 'sonar.leak.period';
+
+export const LEAK_PERIOD_TYPE = 'sonar.leak.period.type';
+
+export const INPUT_SIZES = {
+ small: theme('width.input-small'),
+ medium: theme('width.input-medium'),
+ large: theme('width.input-large'),
+ full: theme('width.full'),
+ auto: theme('width.auto'),
+};
+
+export const LAYOUT_VIEWPORT_MIN_WIDTH = 1280;
+export const LAYOUT_MAIN_CONTENT_GUTTER = 60;
+export const LAYOUT_SIDEBAR_WIDTH = 240;
+export const LAYOUT_SIDEBAR_COLLAPSED_WIDTH = 60;
+export const LAYOUT_SIDEBAR_BREAKPOINT = 1320;
+export const LAYOUT_BANNER_HEIGHT = 44;
+export const LAYOUT_BRANDING_ICON_WIDTH = 198;
+export const LAYOUT_FILTERBAR_HEADER = 56;
+export const LAYOUT_GLOBAL_NAV_HEIGHT = 52;
+export const LAYOUT_LOGO_MARGIN_RIGHT = 45;
+export const LAYOUT_LOGO_MAX_HEIGHT = 40;
+export const LAYOUT_LOGO_MAX_WIDTH = 150;
+export const LAYOUT_FOOTER_HEIGHT = 52;
+export const LAYOUT_NOTIFICATIONSBAR_WIDTH = 350;
+
+export const CORE_CONCEPTS_WIDTH = 350;
+
+export const DARK_THEME_ID = 'dark-theme';
diff --git a/server/sonar-web/design-system/src/components/DummyComponent.tsx b/server/sonar-web/design-system/src/helpers/index.ts
index 8470a1351a3..764e245473d 100644
--- a/server/sonar-web/design-system/src/components/DummyComponent.tsx
+++ b/server/sonar-web/design-system/src/helpers/index.ts
@@ -17,7 +17,5 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-
-export function DummyComponent() {
- return <div>I&apos;m a dummy</div>;
-}
+export * from './constants';
+export * from './positioning';
diff --git a/server/sonar-web/design-system/src/helpers/keyboard.ts b/server/sonar-web/design-system/src/helpers/keyboard.ts
new file mode 100644
index 00000000000..42bc6bdf52e
--- /dev/null
+++ b/server/sonar-web/design-system/src/helpers/keyboard.ts
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+export enum Key {
+ ArrowLeft = 'ArrowLeft',
+ ArrowUp = 'ArrowUp',
+ ArrowRight = 'ArrowRight',
+ ArrowDown = 'ArrowDown',
+
+ Alt = 'Alt',
+ Backspace = 'Backspace',
+ CapsLock = 'CapsLock',
+ Meta = 'Meta',
+ Control = 'Control',
+ Delete = 'Delete',
+ End = 'End',
+ Enter = 'Enter',
+ Escape = 'Escape',
+ Home = 'Home',
+ PageDown = 'PageDown',
+ PageUp = 'PageUp',
+ Shift = 'Shift',
+ Space = ' ',
+ Tab = 'Tab',
+}
+
+export function isShortcut(event: KeyboardEvent): boolean {
+ return event.ctrlKey || event.metaKey;
+}
+
+const INPUT_TAGS = ['INPUT', 'SELECT', 'TEXTAREA', 'UBCOMMENT'];
+
+export function isInput(event: KeyboardEvent): boolean {
+ const { tagName } = event.target as HTMLElement;
+ return INPUT_TAGS.includes(tagName);
+}
diff --git a/server/sonar-web/design-system/src/helpers/l10n.ts b/server/sonar-web/design-system/src/helpers/l10n.ts
new file mode 100644
index 00000000000..96cf9467685
--- /dev/null
+++ b/server/sonar-web/design-system/src/helpers/l10n.ts
@@ -0,0 +1,30 @@
+/*
+ * 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.
+ */
+
+export function translate(keys: string): string {
+ return keys;
+}
+
+export function translateWithParameters(
+ messageKey: string,
+ ...parameters: Array<string | number>
+): string {
+ return `${messageKey}.${parameters.join('.')}`;
+}
diff --git a/server/sonar-web/design-system/src/helpers/positioning.ts b/server/sonar-web/design-system/src/helpers/positioning.ts
new file mode 100644
index 00000000000..09384e2299b
--- /dev/null
+++ b/server/sonar-web/design-system/src/helpers/positioning.ts
@@ -0,0 +1,185 @@
+/*
+ * 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.
+ */
+/**
+ * Positioning rules:
+ * - Bottom = below the block, horizontally centered
+ * - BottomLeft = below the block, horizontally left-aligned
+ * - BottomRight = below the block, horizontally right-aligned
+ * - Left = Left of the block, vertically centered
+ * - LeftTop = on the left-side of the block, vertically top-aligned
+ * - LeftBottom = on the left-side of the block, vertically bottom-aligned
+ * - Right = Right of the block, vertically centered
+ * - RightTop = on the right-side of the block, vertically top-aligned
+ * - RightBottom = on the right-side of the block, vetically bottom-aligned
+ * - Top = above the block, horizontally centered
+ * - TopLeft = above the block, horizontally left-aligned
+ * - TopRight = above the block, horizontally right-aligned
+ */
+export enum PopupPlacement {
+ Bottom = 'bottom',
+ BottomLeft = 'bottom-left',
+ BottomRight = 'bottom-right',
+ Left = 'left',
+ LeftTop = 'left-top',
+ LeftBottom = 'left-bottom',
+ Right = 'right',
+ RightTop = 'right-top',
+ RightBottom = 'right-bottom',
+ Top = 'top',
+ TopLeft = 'top-left',
+ TopRight = 'top-right',
+}
+
+export enum PopupZLevel {
+ Content = 'content',
+ Default = 'popup',
+ Global = 'global',
+}
+
+export type BasePlacement = Extract<
+ PopupPlacement,
+ PopupPlacement.Bottom | PopupPlacement.Top | PopupPlacement.Left | PopupPlacement.Right
+>;
+
+export const PLACEMENT_FLIP_MAP: { [key in PopupPlacement]: PopupPlacement } = {
+ [PopupPlacement.Left]: PopupPlacement.Right,
+ [PopupPlacement.LeftBottom]: PopupPlacement.RightBottom,
+ [PopupPlacement.LeftTop]: PopupPlacement.RightTop,
+ [PopupPlacement.Right]: PopupPlacement.Left,
+ [PopupPlacement.RightBottom]: PopupPlacement.LeftBottom,
+ [PopupPlacement.RightTop]: PopupPlacement.LeftTop,
+ [PopupPlacement.Top]: PopupPlacement.Bottom,
+ [PopupPlacement.TopLeft]: PopupPlacement.BottomLeft,
+ [PopupPlacement.TopRight]: PopupPlacement.BottomRight,
+ [PopupPlacement.Bottom]: PopupPlacement.Top,
+ [PopupPlacement.BottomLeft]: PopupPlacement.TopLeft,
+ [PopupPlacement.BottomRight]: PopupPlacement.TopRight,
+};
+
+const MARGIN_TO_EDGE = 4;
+
+export function popupPositioning(
+ toggleNode: Element,
+ popupNode: Element,
+ placement: PopupPlacement = PopupPlacement.Bottom
+) {
+ const toggleRect = toggleNode.getBoundingClientRect();
+ const popupRect = popupNode.getBoundingClientRect();
+
+ let left = 0;
+ let top = 0;
+
+ switch (placement) {
+ case PopupPlacement.Bottom:
+ left = toggleRect.left + toggleRect.width / 2 - popupRect.width / 2;
+ top = toggleRect.top + toggleRect.height;
+ break;
+ case PopupPlacement.BottomLeft:
+ left = toggleRect.left;
+ top = toggleRect.top + toggleRect.height;
+ break;
+ case PopupPlacement.BottomRight:
+ left = toggleRect.left + toggleRect.width - popupRect.width;
+ top = toggleRect.top + toggleRect.height;
+ break;
+ case PopupPlacement.Left:
+ left = toggleRect.left - popupRect.width;
+ top = toggleRect.top + toggleRect.height / 2 - popupRect.height / 2;
+ break;
+ case PopupPlacement.LeftTop:
+ left = toggleRect.left - popupRect.width;
+ top = toggleRect.top;
+ break;
+ case PopupPlacement.LeftBottom:
+ left = toggleRect.left - popupRect.width;
+ top = toggleRect.top + toggleRect.height - popupRect.height;
+ break;
+ case PopupPlacement.Right:
+ left = toggleRect.left + toggleRect.width;
+ top = toggleRect.top + toggleRect.height / 2 - popupRect.height / 2;
+ break;
+ case PopupPlacement.RightTop:
+ left = toggleRect.left + toggleRect.width;
+ top = toggleRect.top;
+ break;
+ case PopupPlacement.RightBottom:
+ left = toggleRect.left + toggleRect.width;
+ top = toggleRect.top + toggleRect.height - popupRect.height;
+ break;
+ case PopupPlacement.Top:
+ left = toggleRect.left + toggleRect.width / 2 - popupRect.width / 2;
+ top = toggleRect.top - popupRect.height;
+ break;
+ case PopupPlacement.TopLeft:
+ left = toggleRect.left;
+ top = toggleRect.top - popupRect.height;
+ break;
+ case PopupPlacement.TopRight:
+ left = toggleRect.left + toggleRect.width - popupRect.width;
+ top = toggleRect.top - popupRect.height;
+ break;
+ }
+
+ const inBoundariesLeft = Math.min(
+ Math.max(left, getMinLeftPlacement(toggleRect)),
+ getMaxLeftPlacement(toggleRect, popupRect)
+ );
+ const inBoundariesTop = Math.min(
+ Math.max(top, getMinTopPlacement(toggleRect)),
+ getMaxTopPlacement(toggleRect, popupRect)
+ );
+
+ return {
+ height: popupRect.height,
+ left: inBoundariesLeft,
+ leftFix: inBoundariesLeft - left,
+ top: inBoundariesTop,
+ topFix: inBoundariesTop - top,
+ width: popupRect.width,
+ };
+}
+
+function getMinLeftPlacement(toggleRect: DOMRect) {
+ return Math.min(
+ MARGIN_TO_EDGE, // Left edge of the sceen
+ toggleRect.left + toggleRect.width / 2 // Left edge of the screen when scrolled
+ );
+}
+
+function getMaxLeftPlacement(toggleRect: DOMRect, popupRect: DOMRect) {
+ return Math.max(
+ document.documentElement.clientWidth - popupRect.width - MARGIN_TO_EDGE, // Right edge of the screen
+ toggleRect.left + toggleRect.width / 2 - popupRect.width // Right edge of the screen when scrolled
+ );
+}
+
+function getMinTopPlacement(toggleRect: DOMRect) {
+ return Math.min(
+ MARGIN_TO_EDGE, // Top edge of the sceen
+ toggleRect.top + toggleRect.height / 2 // Top edge of the screen when scrolled
+ );
+}
+
+function getMaxTopPlacement(toggleRect: DOMRect, popupRect: DOMRect) {
+ return Math.max(
+ document.documentElement.clientHeight - popupRect.height - MARGIN_TO_EDGE, // Bottom edge of the screen
+ toggleRect.top + toggleRect.height / 2 - popupRect.height // Bottom edge of the screen when scrolled
+ );
+}
diff --git a/server/sonar-web/design-system/src/helpers/testUtils.tsx b/server/sonar-web/design-system/src/helpers/testUtils.tsx
new file mode 100644
index 00000000000..558906fe0dc
--- /dev/null
+++ b/server/sonar-web/design-system/src/helpers/testUtils.tsx
@@ -0,0 +1,117 @@
+/*
+ * 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 as rtlRender, RenderOptions } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Options as UserEventsOptions } from '@testing-library/user-event/dist/types/options';
+import { InitialEntry } from 'history';
+import { identity, kebabCase } from 'lodash';
+import React, { PropsWithChildren, ReactNode } from 'react';
+import { HelmetProvider } from 'react-helmet-async';
+import { IntlProvider } from 'react-intl';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+
+export function render(
+ ui: React.ReactElement,
+ options?: RenderOptions,
+ userEventOptions?: UserEventsOptions
+) {
+ return { ...rtlRender(ui, options), user: userEvent.setup(userEventOptions) };
+}
+
+type RenderContextOptions = Omit<RenderOptions, 'wrapper'> & {
+ initialEntries?: InitialEntry[];
+ userEventOptions?: UserEventsOptions;
+};
+
+export function renderWithContext(
+ ui: React.ReactElement,
+ { userEventOptions, ...options }: RenderContextOptions = {}
+) {
+ return render(ui, { ...options, wrapper: getContextWrapper() }, userEventOptions);
+}
+
+type RenderRouterOptions = { additionalRoutes?: ReactNode };
+
+export function renderWithRouter(
+ ui: React.ReactElement,
+ options: RenderContextOptions & RenderRouterOptions = {}
+) {
+ const { additionalRoutes, userEventOptions, ...renderOptions } = options;
+
+ function RouterWrapper({ children }: React.PropsWithChildren<{}>) {
+ return (
+ <HelmetProvider>
+ <MemoryRouter>
+ <Routes>
+ <Route element={children} path="/" />
+ {additionalRoutes}
+ </Routes>
+ </MemoryRouter>
+ </HelmetProvider>
+ );
+ }
+
+ return render(ui, { ...renderOptions, wrapper: RouterWrapper }, userEventOptions);
+}
+
+function getContextWrapper() {
+ return function ContextWrapper({ children }: React.PropsWithChildren<{}>) {
+ return (
+ <HelmetProvider>
+ <IntlProvider defaultLocale="en" locale="en">
+ {children}
+ </IntlProvider>
+ </HelmetProvider>
+ );
+ };
+}
+
+export function mockComponent(name: string, transformProps: (props: any) => any = identity) {
+ function MockedComponent({ ...props }: PropsWithChildren<any>) {
+ return React.createElement('mocked-' + kebabCase(name), transformProps(props));
+ }
+
+ MockedComponent.displayName = `mocked(${name})`;
+ return MockedComponent;
+}
+
+export const debounceTimer = jest.fn().mockImplementation((callback, timeout) => {
+ let timeoutId: number;
+ const debounced = jest.fn((...args) => {
+ window.clearTimeout(timeoutId);
+ timeoutId = window.setTimeout(() => callback(...args), timeout);
+ });
+ (debounced as any).cancel = jest.fn(() => {
+ window.clearTimeout(timeoutId);
+ });
+ return debounced;
+});
+
+export function flushPromises(usingFakeTime = false): Promise<void> {
+ return new Promise((resolve) => {
+ if (usingFakeTime) {
+ jest.useRealTimers();
+ }
+ setTimeout(resolve, 0);
+ if (usingFakeTime) {
+ jest.useFakeTimers();
+ }
+ });
+}
diff --git a/server/sonar-web/design-system/src/helpers/theme.ts b/server/sonar-web/design-system/src/helpers/theme.ts
new file mode 100644
index 00000000000..6dab879cf4a
--- /dev/null
+++ b/server/sonar-web/design-system/src/helpers/theme.ts
@@ -0,0 +1,130 @@
+/*
+ * 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 { CSSColor, Theme, ThemeColors, ThemeContrasts, ThemedProps } from '../types/theme';
+import { getRGBAString } from './colors';
+
+export function getProp<T>(name: keyof Omit<T, keyof ThemedProps>) {
+ return (props: T) => props[name];
+}
+
+export function themeColor(name: ThemeColors | CSSColor, opacity?: number) {
+ return function ({ theme }: ThemedProps) {
+ return getColor(theme, [], name, opacity);
+ };
+}
+
+export function themeContrast(name: ThemeColors | CSSColor) {
+ return function ({ theme }: ThemedProps) {
+ return getContrast(theme, name);
+ };
+}
+
+export function themeBorder(
+ name: keyof Theme['borders'] = 'default',
+ color?: ThemeColors | CSSColor,
+ opacity?: number
+) {
+ return function ({ theme }: ThemedProps) {
+ const [width, style, ...rgba] = theme.borders[name];
+ return `${width} ${style} ${getColor(theme, rgba as number[], color, opacity)}`;
+ };
+}
+
+export function themeShadow(
+ name: keyof Theme['shadows'],
+ color?: ThemeColors | CSSColor,
+ opacity?: number
+) {
+ return function ({ theme }: ThemedProps) {
+ const shadows = theme.shadows[name];
+ return shadows
+ .map((item) => {
+ const [x, y, blur, spread, ...rgba] = item;
+ return `${x}px ${y}px ${blur}px ${spread}px ${getColor(theme, rgba, color, opacity)}`;
+ })
+ .join(',');
+ };
+}
+
+export function themeAvatarColor(name: string, contrast = false) {
+ return function ({ theme }: ThemedProps) {
+ let hash = 0;
+ for (let i = 0; i < name.length; i++) {
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
+ }
+
+ // Reduces number length to avoid modulo's limit.
+ hash = parseInt(hash.toString().slice(-5), 10);
+ if (contrast) {
+ return getColor(theme, theme.avatar.contrast[hash % theme.avatar.contrast.length]);
+ }
+ return getColor(theme, theme.avatar.color[hash % theme.avatar.color.length]);
+ };
+}
+
+export function themeImage(imageKey: keyof Theme['images']) {
+ return function ({ theme }: ThemedProps) {
+ return theme.images[imageKey];
+ };
+}
+
+function getColor(
+ theme: Theme,
+ [r, g, b, a]: number[],
+ colorOverride?: ThemeColors | CSSColor,
+ opacityOverride?: number
+) {
+ // Custom CSS property or rgb(a) color, return it directly
+ if (
+ colorOverride?.startsWith('var(--') ||
+ colorOverride?.startsWith('rgb(') ||
+ colorOverride?.startsWith('rgba(')
+ ) {
+ return colorOverride as CSSColor;
+ }
+ // Is theme color overridden by a color name ?
+ const color = colorOverride ? theme.colors[colorOverride as ThemeColors] : [r, g, b];
+ if (typeof color === 'string') {
+ return color as CSSColor;
+ }
+
+ return getRGBAString(color, opacityOverride ?? color[3] ?? a);
+}
+
+// Simplified version of getColor for contrast colors, fallback to colors if contrast isn't found
+function getContrast(theme: Theme, colorOverride: ThemeContrasts | ThemeColors | CSSColor) {
+ // Custom CSS property or rgb(a) color, return it directly
+ if (
+ colorOverride?.startsWith('var(--') ||
+ colorOverride?.startsWith('rgb(') ||
+ colorOverride?.startsWith('rgba(')
+ ) {
+ return colorOverride as CSSColor;
+ }
+
+ // For contrast we always require a color override (it's the principle of a contrast)
+ const color =
+ theme.contrasts[colorOverride as ThemeContrasts] || theme.colors[colorOverride as ThemeColors];
+ if (typeof color === 'string') {
+ return color as CSSColor;
+ }
+
+ return getRGBAString(color, color[3]);
+}
diff --git a/server/sonar-web/design-system/src/helpers/types.ts b/server/sonar-web/design-system/src/helpers/types.ts
new file mode 100644
index 00000000000..05b2043827b
--- /dev/null
+++ b/server/sonar-web/design-system/src/helpers/types.ts
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+export function isDefined<T>(x: T | undefined | null): x is T {
+ return x !== undefined && x !== null;
+}
diff --git a/server/sonar-web/design-system/src/index.ts b/server/sonar-web/design-system/src/index.ts
new file mode 100644
index 00000000000..cd4bd05a51b
--- /dev/null
+++ b/server/sonar-web/design-system/src/index.ts
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+export * from './components';
+export * from './helpers';
+export * from './theme';
diff --git a/server/sonar-web/design-system/src/theme/colors.ts b/server/sonar-web/design-system/src/theme/colors.ts
new file mode 100644
index 00000000000..785f6f07c8b
--- /dev/null
+++ b/server/sonar-web/design-system/src/theme/colors.ts
@@ -0,0 +1,136 @@
+/*
+ * 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.
+ */
+export default {
+ white: [255, 255, 255],
+ black: [0, 0, 0],
+ sonarcloud: [243, 112, 42],
+ grey: { 50: [235, 235, 235], 100: [221, 221, 221] },
+ blueGrey: {
+ 25: [252, 252, 253],
+ 50: [239, 242, 249],
+ 100: [225, 230, 243],
+ 200: [197, 205, 223],
+ 300: [166, 173, 194],
+ 400: [106, 117, 144],
+ 500: [62, 67, 87],
+ 600: [42, 47, 64],
+ 700: [29, 33, 47],
+ 800: [18, 20, 29],
+ 900: [8, 9, 12],
+ },
+ indigo: {
+ 25: [244, 246, 255],
+ 50: [232, 235, 255],
+ 100: [209, 215, 254],
+ 200: [189, 198, 255],
+ 300: [159, 169, 237],
+ 400: [123, 135, 217],
+ 500: [93, 108, 208],
+ 600: [75, 86, 187],
+ 700: [71, 81, 143],
+ 800: [43, 51, 104],
+ 900: [27, 34, 80],
+ },
+ tangerine: {
+ 25: [255, 248, 244],
+ 50: [250, 230, 220],
+ 100: [246, 206, 187],
+ 200: [243, 185, 157],
+ 300: [240, 166, 130],
+ 400: [237, 148, 106],
+ 500: [235, 131, 82],
+ 600: [233, 116, 63],
+ 700: [231, 102, 49],
+ 800: [181, 68, 25],
+ 900: [130, 43, 10],
+ },
+ green: {
+ 50: [246, 254, 249],
+ 100: [236, 253, 243],
+ 200: [209, 250, 223],
+ 300: [166, 244, 197],
+ 400: [50, 213, 131],
+ 500: [18, 183, 106],
+ 600: [3, 152, 85],
+ 700: [2, 122, 72],
+ 800: [5, 96, 58],
+ 900: [5, 79, 49],
+ },
+ yellowGreen: {
+ 50: [247, 251, 230],
+ 100: [241, 250, 210],
+ 200: [225, 245, 168],
+ 300: [197, 230, 124],
+ 400: [166, 208, 91],
+ 500: [110, 183, 18],
+ 600: [104, 154, 48],
+ 700: [83, 128, 39],
+ 800: [63, 104, 29],
+ 900: [49, 85, 22],
+ },
+ yellow: {
+ 50: [252, 245, 228],
+ 100: [254, 245, 208],
+ 200: [252, 233, 163],
+ 300: [250, 220, 121],
+ 400: [248, 205, 92],
+ 500: [245, 184, 64],
+ 600: [209, 152, 52],
+ 700: [174, 122, 41],
+ 800: [140, 94, 30],
+ 900: [102, 64, 15],
+ },
+ orange: {
+ 50: [255, 240, 235],
+ 100: [254, 219, 199],
+ 200: [255, 214, 175],
+ 300: [254, 150, 75],
+ 400: [253, 113, 34],
+ 500: [247, 95, 9],
+ 600: [220, 94, 3],
+ 700: [181, 71, 8],
+ 800: [147, 55, 13],
+ 900: [122, 46, 14],
+ },
+ red: {
+ 50: [254, 243, 242],
+ 100: [254, 228, 226],
+ 200: [254, 205, 202],
+ 300: [253, 162, 155],
+ 400: [249, 112, 102],
+ 500: [240, 68, 56],
+ 600: [217, 45, 32],
+ 700: [180, 35, 24],
+ 800: [128, 27, 20],
+ 900: [93, 29, 19],
+ },
+ blue: {
+ 50: [245, 251, 255],
+ 100: [233, 244, 251],
+ 200: [184, 222, 241],
+ 300: [143, 202, 234],
+ 400: [110, 185, 228],
+ 500: [85, 170, 223],
+ 600: [69, 149, 203],
+ 700: [58, 127, 173],
+ 800: [49, 108, 146],
+ 900: [23, 67, 97],
+ },
+};
diff --git a/server/sonar-web/design-system/src/theme/index.ts b/server/sonar-web/design-system/src/theme/index.ts
new file mode 100644
index 00000000000..6b8c84a5721
--- /dev/null
+++ b/server/sonar-web/design-system/src/theme/index.ts
@@ -0,0 +1,20 @@
+/*
+ * 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.
+ */
+export { default as lightTheme } from './light';
diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts
new file mode 100644
index 00000000000..8b10b339326
--- /dev/null
+++ b/server/sonar-web/design-system/src/theme/light.ts
@@ -0,0 +1,743 @@
+/*
+ * 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 COLORS from './colors';
+
+const primary = {
+ light: COLORS.indigo[400],
+ default: COLORS.indigo[500],
+ dark: COLORS.indigo[600],
+};
+
+const secondary = {
+ light: COLORS.blueGrey[50],
+ default: COLORS.blueGrey[200],
+ dark: COLORS.blueGrey[400],
+ darker: COLORS.blueGrey[500],
+};
+
+const danger = {
+ lightest: COLORS.red[50],
+ lighter: COLORS.red[300],
+ light: COLORS.red[400],
+ default: COLORS.red[600],
+ dark: COLORS.red[700],
+ darker: COLORS.red[800],
+};
+
+const lightTheme = {
+ id: 'light-theme',
+ highlightTheme: 'atom-one-light.css',
+ logo: 'sonarcloud-logo-black.svg',
+
+ colors: {
+ transparent: 'transparent',
+ currentColor: 'currentColor',
+
+ backgroundPrimary: COLORS.blueGrey[25],
+ backgroundSecondary: COLORS.white,
+ border: COLORS.grey[50],
+ sonarcloud: COLORS.sonarcloud,
+
+ // primary
+ primaryLight: primary.light,
+ primary: primary.default,
+ primaryDark: primary.dark,
+
+ // danger
+ danger: danger.dark,
+
+ // buttons
+ button: primary.default,
+ buttonHover: primary.dark,
+ buttonSecondary: COLORS.white,
+ buttonSecondaryBorder: secondary.default,
+ buttonSecondaryHover: secondary.light,
+ buttonDisabled: secondary.light,
+ buttonDisabledBorder: secondary.default,
+
+ // danger buttons
+ dangerButton: danger.default,
+ dangerButtonHover: danger.dark,
+ dangerButtonFocus: danger.default,
+ dangerButtonSecondary: COLORS.white,
+ dangerButtonSecondaryBorder: danger.lighter,
+ dangerButtonSecondaryHover: danger.lightest,
+ dangerButtonSecondaryFocus: danger.light,
+
+ // third party button
+ thirdPartyButton: COLORS.white,
+ thirdPartyButtonBorder: secondary.default,
+ thirdPartyButtonHover: secondary.light,
+
+ // popup
+ popup: COLORS.white,
+ popupBorder: secondary.default,
+
+ // dropdown menu
+ dropdownMenu: COLORS.white,
+ dropdownMenuHover: secondary.light,
+ dropdownMenuFocus: COLORS.indigo[50],
+ dropdownMenuFocusBorder: primary.light,
+ dropdownMenuDisabled: COLORS.white,
+ dropdownMenuHeader: COLORS.white,
+ dropdownMenuDanger: danger.default,
+ dropdownMenuSubTitle: secondary.dark,
+
+ // radio
+ radio: primary.default,
+ radioBorder: primary.default,
+ radioHover: COLORS.indigo[50],
+ radioFocus: COLORS.indigo[50],
+ radioFocusBorder: COLORS.indigo[300],
+ radioFocusOutline: [...COLORS.indigo[300], 0.2],
+ radioChecked: COLORS.indigo[50],
+ radioDisabled: secondary.default,
+ radioDisabledBackground: secondary.light,
+ radioDisabledBorder: secondary.default,
+
+ // switch
+ switch: secondary.default,
+ switchDisabled: COLORS.blueGrey[100],
+ switchActive: primary.default,
+ switchHover: COLORS.blueGrey[300],
+ switchHoverActive: primary.light,
+ switchButton: COLORS.white,
+ switchButtonDisabled: secondary.light,
+
+ // sidebar
+ // NOTE: these aren't used because the sidebar is exclusively dark. but for type purposes are listed here
+ sidebarBackground: COLORS.blueGrey[700],
+ sidebarItemActive: COLORS.blueGrey[800],
+ sidebarBorder: COLORS.blueGrey[500],
+ sidebarTextDisabled: COLORS.blueGrey[400],
+ sidebarIcon: COLORS.blueGrey[400],
+ sidebarActiveIcon: COLORS.blueGrey[200],
+
+ //separator-circle
+ separatorCircle: COLORS.blueGrey[200],
+ separatorSlash: COLORS.blueGrey[300],
+
+ // flag message
+ flagMessageBackground: COLORS.white,
+
+ errorBorder: danger.light,
+ errorBackground: danger.lightest,
+ errorText: danger.dark,
+
+ warningBorder: COLORS.yellow[400],
+ warningBackground: COLORS.yellow[50],
+
+ successBorder: COLORS.green[400],
+ successBackground: COLORS.green[50],
+
+ infoBorder: COLORS.blue[400],
+ infoBackground: COLORS.blue[50],
+
+ // banner message
+ bannerMessage: danger.lightest,
+ bannerMessageIcon: danger.darker,
+
+ // toggle buttons
+ toggle: COLORS.white,
+ toggleBorder: secondary.default,
+ toggleHover: secondary.light,
+ toggleFocus: [...secondary.default, 0.2],
+
+ // code snippet
+ codeSnippetBackground: COLORS.blueGrey[25],
+ codeSnippetBorder: COLORS.blueGrey[100],
+ codeSnippetHighlight: secondary.default,
+
+ // code viewer
+ codeLineIssueIndicator: COLORS.blueGrey[400], // Should be blueGrey[300], to be changed once code viewer is reworked
+
+ // checkbox
+ checkboxHover: COLORS.indigo[50],
+ checkboxCheckedHover: primary.light,
+ checkboxDisabled: secondary.light,
+ checkboxDisabledChecked: secondary.default,
+ checkboxLabel: COLORS.blueGrey[500],
+
+ // input search
+ searchHighlight: COLORS.tangerine[50],
+
+ // input field
+ inputBackground: COLORS.white,
+ inputBorder: secondary.default,
+ inputFocus: primary.light,
+ inputDanger: danger.default,
+ inputDangerFocus: danger.light,
+ inputSuccess: COLORS.yellowGreen[500],
+ inputSuccessFocus: COLORS.yellowGreen[400],
+ inputDisabled: secondary.light,
+ inputDisabledBorder: secondary.default,
+ inputPlaceholder: secondary.dark,
+
+ // required input
+ inputRequired: danger.dark,
+
+ // tooltip
+ tooltipBackground: COLORS.blueGrey[600],
+ tooltipSeparator: secondary.dark,
+
+ // avatar
+ avatarBackground: COLORS.white,
+ avatarBorder: COLORS.blueGrey[100],
+
+ // badges
+ badgeNew: COLORS.indigo[100],
+ badgeDefault: COLORS.blueGrey[100],
+ badgeDeleted: COLORS.red[100],
+ badgeCounter: COLORS.blueGrey[100],
+
+ // input select
+ selectOptionSelected: secondary.light,
+
+ // breadcrumbs
+ breadcrumb: 'transparent',
+
+ // tab
+ tabBorder: primary.light,
+
+ //table
+ tableRowHover: COLORS.indigo[25],
+ tableRowSelected: COLORS.indigo[300],
+
+ // links
+ linkDefault: primary.default,
+ linkActive: COLORS.indigo[600],
+ linkDiscreet: 'currentColor',
+ linkTooltipDefault: COLORS.indigo[200],
+ linkTooltipActive: COLORS.indigo[100],
+
+ // discreet select
+ discreetBorder: secondary.default,
+ discreetBackground: COLORS.white,
+ discreetHover: secondary.light,
+ discreetButtonHover: COLORS.indigo[500],
+ discreetFocus: COLORS.indigo[50],
+ discreetFocusBorder: primary.light,
+
+ // interactive icon
+ interactiveIcon: 'transparent',
+ interactiveIconHover: COLORS.indigo[50],
+ interactiveIconFocus: primary.default,
+ bannerIcon: 'transparent',
+ bannerIconHover: [...COLORS.red[600], 0.2],
+ bannerIconFocus: danger.default,
+ discreetInteractiveIcon: secondary.dark,
+ destructiveIcon: 'transparent',
+ destructiveIconHover: danger.lightest,
+ destructiveIconFocus: danger.default,
+
+ // icons
+ iconSeverityMajor: danger.light,
+ iconSeverityMinor: COLORS.yellowGreen[400],
+ iconSeverityInfo: COLORS.blue[400],
+ iconDirectory: COLORS.orange[300],
+ iconFile: COLORS.blueGrey[300],
+ iconProject: COLORS.blueGrey[300],
+ iconUnitTest: COLORS.blueGrey[300],
+ iconFavorite: COLORS.tangerine[400],
+ iconCheck: COLORS.green[500],
+ iconPositiveUpdate: COLORS.green[300],
+ iconNegativeUpdate: COLORS.red[300],
+ iconTrendPositive: COLORS.green[400],
+ iconTrendNegative: COLORS.red[400],
+ iconTrendNeutral: COLORS.blue[400],
+ iconTrendDisabled: COLORS.blueGrey[400],
+ iconError: danger.default,
+ iconWarning: COLORS.yellow[600],
+ iconSuccess: COLORS.green[600],
+ iconInfo: COLORS.blue[600],
+ iconStatus: COLORS.blueGrey[200],
+ iconStatusResolved: secondary.dark,
+ iconNotificationsOn: COLORS.indigo[300],
+ iconHelperHint: COLORS.blueGrey[100],
+ iconRuleInheritanceOverride: danger.light,
+
+ // numbered list
+ numberedList: COLORS.indigo[50],
+
+ // unordered list
+ listMarker: COLORS.blueGrey[300],
+
+ // product news
+ productNews: COLORS.indigo[50],
+ productNewsHover: COLORS.indigo[100],
+
+ // scrollbar
+ scrollbar: COLORS.blueGrey[25],
+
+ // resizer
+ resizer: secondary.default,
+
+ // coverage indicators
+ coverageGreen: COLORS.green[500],
+ coverageRed: danger.dark,
+
+ // duplications indicators
+ 'duplicationsRating.A': COLORS.green[500],
+ 'duplicationsRating.B': COLORS.yellowGreen[500],
+ 'duplicationsRating.C': COLORS.yellow[500],
+ 'duplicationsRating.D': COLORS.orange[500],
+ 'duplicationsRating.E': COLORS.red[500],
+ duplicationsRatingSecondary: secondary.light,
+
+ // size indicators
+ sizeIndicator: COLORS.blue[500],
+
+ // rating colors
+ 'rating.A': COLORS.green[200],
+ 'rating.B': COLORS.yellowGreen[200],
+ 'rating.C': COLORS.yellow[200],
+ 'rating.D': COLORS.orange[200],
+ 'rating.E': COLORS.red[200],
+
+ // date picker
+ datePicker: COLORS.white,
+ datePickerIcon: secondary.default,
+ datePickerDisabled: COLORS.white,
+ datePickerDefault: COLORS.white,
+ datePickerHover: COLORS.blueGrey[100],
+ datePickerSelected: primary.default,
+ datePickerRange: COLORS.indigo[100],
+
+ // tags
+ tag: secondary.light,
+
+ // quality gate indicator
+ qgIndicatorPassed: COLORS.green[200],
+ qgIndicatorFailed: COLORS.red[200],
+ qgIndicatorNotComputed: COLORS.blueGrey[200],
+
+ // main bar
+ mainBar: COLORS.white,
+ mainBarHover: COLORS.blueGrey[600],
+ mainBarLogo: COLORS.white,
+ mainBarDarkLogo: COLORS.blueGrey[800],
+ mainBarNews: COLORS.indigo[50],
+ menuBorder: primary.light,
+
+ // navbar
+ navbar: COLORS.white,
+ navbarTextMeta: secondary.darker,
+
+ // filterbar
+ filterbar: COLORS.white,
+ filterbarBorder: COLORS.blueGrey[100],
+
+ // facets
+ facetHeader: COLORS.blueGrey[600],
+ facetItemSelected: COLORS.indigo[50],
+ facetItemSelectedHover: COLORS.indigo[100],
+ facetItemSelectedBorder: primary.light,
+ facetItemDisabled: COLORS.blueGrey[300],
+ facetItemLight: secondary.dark,
+ facetItemGraph: secondary.default,
+ facetKeyboardHint: COLORS.blueGrey[50],
+ facetToggleActive: COLORS.green[500],
+ facetToggleInactive: COLORS.red[500],
+ facetToggleHover: COLORS.blueGrey[600],
+
+ // subnavigation sidebar
+ subnavigation: COLORS.white,
+ subnavigationHover: COLORS.indigo[50],
+ subnavigationBorder: COLORS.grey[100],
+ subnavigationSeparator: COLORS.grey[50],
+ subnavigationSubheading: COLORS.blueGrey[25],
+
+ // footer
+ footer: COLORS.white,
+ footerBorder: COLORS.grey[100],
+
+ // project
+ projectCardBackground: COLORS.white,
+ projectCardBorder: COLORS.blueGrey[100],
+
+ // overview
+ iconOverviewIssue: COLORS.blueGrey[400],
+
+ // graph - chart
+ graphPointCircleColor: COLORS.white,
+ 'graphLineColor.0': COLORS.blue[500],
+ 'graphLineColor.1': COLORS.blue[700],
+ 'graphLineColor.2': COLORS.blue[300],
+ 'graphLineColor.3': COLORS.blue[900],
+ graphGridColor: COLORS.grey[50],
+ graphCursorLineColor: COLORS.blueGrey[400],
+ newCodeHighlight: COLORS.indigo[300],
+ graphZoomBackgroundColor: COLORS.blueGrey[25],
+ graphZoomBorderColor: COLORS.blueGrey[100],
+ graphZoomHandleColor: COLORS.blueGrey[400],
+
+ // page
+ pageTitle: COLORS.blueGrey[700],
+ pageContentLight: secondary.dark,
+ pageContent: secondary.darker,
+ pageContentDark: COLORS.blueGrey[600],
+ pageBlock: COLORS.white,
+ pageBlockBorder: COLORS.blueGrey[100],
+
+ // core concepts
+ coreConceptsCloseIcon: COLORS.blueGrey[300],
+ coreConceptsTitle: secondary.darker,
+ coreConceptsBody: secondary.darker,
+ coreConceptsHomeBorder: COLORS.blueGrey[100],
+ coreConceptsCompleted: COLORS.green[500],
+ coreConceptsPulse: COLORS.indigo[500],
+ coreConceptsPulseFallback: COLORS.white,
+
+ // progress bar
+ coreConceptsProgressBar: secondary.light,
+
+ // issue box
+ issueBoxBorder: danger.lighter,
+ issueBoxBorderDepracated: secondary.default,
+ issueTypeIcon: COLORS.red[200],
+
+ // separator
+ pipeSeparator: COLORS.blueGrey[100],
+
+ // drilldown link
+ drilldown: secondary.darker,
+ drilldownBorder: secondary.default,
+
+ // selection card
+ selectionCardHeader: secondary.darker,
+ selectionCardDisabled: secondary.light,
+ selectionCardBorder: COLORS.blueGrey[100],
+ selectionCardBorderHover: COLORS.indigo[200],
+ selectionCardBorderSelected: primary.light,
+ selectionCardBorderDisabled: secondary.default,
+
+ // bubble charts
+ bubbleChartLine: COLORS.grey[50],
+ bubbleDefault: [...COLORS.blue[500], 0.3],
+ 'bubble.1': [...COLORS.green[500], 0.3],
+ 'bubble.2': [...COLORS.yellowGreen[500], 0.3],
+ 'bubble.3': [...COLORS.yellow[500], 0.3],
+ 'bubble.4': [...COLORS.orange[500], 0.3],
+ 'bubble.5': [...COLORS.red[500], 0.3],
+
+ // leak legend
+ leakLegend: [...COLORS.indigo[300], 0.15],
+ leakLegendBorder: COLORS.indigo[100],
+
+ // hotspot
+ hotspotStatus: COLORS.blueGrey[25],
+
+ // activity comments
+ activityCommentPipe: COLORS.tangerine[200],
+
+ // illustrations
+ illustrationOutline: COLORS.blueGrey[400],
+ illustrationInlineBorder: COLORS.blueGrey[100],
+ illustrationPrimary: COLORS.indigo[400],
+ illustrationSecondary: COLORS.indigo[200],
+ illustrationShade: COLORS.indigo[25],
+
+ // news bar
+ newsBar: COLORS.white,
+ newsBorder: COLORS.grey[100],
+ newsContent: COLORS.white,
+ newsTag: COLORS.blueGrey[50],
+ roadmap: COLORS.indigo[25],
+ roadmapContent: 'transparent',
+
+ // project analyse page
+ almCardBorder: COLORS.grey[100],
+ },
+
+ // contrast colors to be used for text when using a color background with the same name
+ // must match the color name
+ contrasts: {
+ backgroundPrimary: COLORS.blueGrey[900],
+ backgroundSecondary: COLORS.blueGrey[900],
+ primaryLight: secondary.darker,
+ primary: COLORS.white,
+
+ // switch
+ switchHover: primary.light,
+ switchButton: primary.default,
+ switchButtonDisabled: COLORS.blueGrey[300],
+
+ // sidebar
+ sidebarBackground: COLORS.blueGrey[200],
+ sidebarItemActive: COLORS.blueGrey[25],
+
+ // flag message
+ flagMessageBackground: secondary.darker,
+
+ // banner message
+ bannerMessage: COLORS.red[900],
+
+ // buttons
+ buttonDisabled: COLORS.blueGrey[300],
+ buttonSecondary: secondary.darker,
+
+ // danger buttons
+ dangerButton: COLORS.white,
+ dangerButtonSecondary: danger.dark,
+
+ // third party button
+ thirdPartyButton: secondary.darker,
+
+ // popup
+ popup: secondary.darker,
+
+ // dropdown menu
+ dropdownMenu: secondary.darker,
+ dropdownMenuDisabled: COLORS.blueGrey[300],
+ dropdownMenuHeader: secondary.dark,
+
+ // toggle buttons
+ toggle: secondary.darker,
+ toggleHover: secondary.darker,
+
+ // code snippet
+ codeSnippetHighlight: danger.default,
+
+ // checkbox
+ checkboxDisabled: secondary.default,
+
+ // input search
+ searchHighlight: secondary.darker,
+
+ // input field
+ inputBackground: secondary.darker,
+ inputDisabled: COLORS.blueGrey[300],
+
+ // tooltip
+ tooltipBackground: secondary.light,
+
+ // badges
+ badgeNew: COLORS.indigo[900],
+ badgeDefault: COLORS.blueGrey[700],
+ badgeDeleted: COLORS.red[900],
+ badgeCounter: secondary.darker,
+
+ // breadcrumbs
+ breadcrumb: secondary.dark,
+
+ // discreet select
+ discreetBackground: secondary.darker,
+ discreetHover: secondary.darker,
+
+ // interactive icons
+ interactiveIcon: primary.dark,
+ interactiveIconHover: COLORS.indigo[800],
+ bannerIcon: danger.darker,
+ bannerIconHover: danger.darker,
+ destructiveIcon: danger.default,
+ destructiveIconHover: danger.darker,
+
+ // icons
+ iconSeverityMajor: COLORS.white,
+ iconSeverityMinor: COLORS.white,
+ iconSeverityInfo: COLORS.white,
+ iconStatusResolved: COLORS.white,
+ iconHelperHint: secondary.darker,
+
+ // numbered list
+ numberedList: COLORS.indigo[800],
+
+ // product news
+ productNews: secondary.darker,
+ productNewsHover: secondary.darker,
+
+ // scrollbar
+ scrollbar: COLORS.grey[100],
+
+ // size indicators
+ sizeIndicator: COLORS.white,
+
+ // rating colors
+ 'rating.A': COLORS.green[900],
+ 'rating.B': COLORS.yellowGreen[900],
+ 'rating.C': COLORS.yellow[900],
+ 'rating.D': COLORS.orange[900],
+ 'rating.E': COLORS.red[900],
+
+ // date picker
+ datePicker: COLORS.blueGrey[300],
+ datePickerDisabled: COLORS.blueGrey[300],
+ datePickerDefault: COLORS.blueGrey[600],
+ datePickerHover: COLORS.blueGrey[600],
+ datePickerSelected: COLORS.white,
+ datePickerRange: COLORS.blueGrey[600],
+
+ // tags
+ tag: secondary.darker,
+
+ // quality gate indicator
+ qgIndicatorPassed: COLORS.green[800],
+ qgIndicatorFailed: danger.darker,
+ qgIndicatorNotComputed: COLORS.blueGrey[800],
+
+ // main bar
+ mainBar: secondary.darker,
+ mainBarLogo: COLORS.black,
+ mainBarDarkLogo: COLORS.white,
+ mainBarNews: secondary.darker,
+
+ // navbar
+ navbar: secondary.darker,
+
+ // filterbar
+ filterbar: secondary.darker,
+
+ // facet
+ facetKeyboardHint: secondary.darker,
+ facetToggleActive: COLORS.white,
+ facetToggleInactive: COLORS.white,
+
+ // subnavigation sidebar
+ subnavigation: secondary.darker,
+ subnavigationHover: COLORS.blueGrey[700],
+ subnavigationSubheading: secondary.dark,
+
+ // footer
+ footer: secondary.dark,
+
+ // page
+ pageBlock: secondary.darker,
+
+ // graph - chart
+ graphZoomHandleColor: COLORS.white,
+
+ // progress bar
+ coreConceptsProgressBar: primary.light,
+
+ // issue box
+ issueTypeIcon: COLORS.red[900],
+
+ // selection card
+ selectionCardDisabled: secondary.dark,
+
+ // bubble charts
+ bubbleDefault: COLORS.blue[500],
+ 'bubble.1': COLORS.green[500],
+ 'bubble.2': COLORS.yellowGreen[500],
+ 'bubble.3': COLORS.yellow[500],
+ 'bubble.4': COLORS.orange[500],
+ 'bubble.5': COLORS.red[500],
+
+ // news bar
+ newsBar: COLORS.blueGrey[600],
+ newsContent: COLORS.blueGrey[500],
+ newsTag: COLORS.blueGrey[500],
+ roadmap: COLORS.blueGrey[600],
+ roadmapContent: COLORS.blueGrey[500],
+ },
+
+ // predefined shadows
+ shadows: {
+ xs: [[0, 1, 2, 0, ...COLORS.blueGrey[700], 0.05]],
+ sm: [
+ [0, 1, 3, 0, ...COLORS.blueGrey[700], 0.05],
+ [0, 1, 25, 0, ...COLORS.blueGrey[700], 0.05],
+ ],
+ md: [
+ [0, 4, 8, -2, ...COLORS.blueGrey[700], 0.1],
+ [0, 2, 15, -2, ...COLORS.blueGrey[700], 0.06],
+ ],
+ lg: [
+ [0, 12, 16, -4, ...COLORS.blueGrey[700], 0.1],
+ [0, 4, 6, -2, ...COLORS.blueGrey[700], 0.05],
+ ],
+ xl: [
+ [15, 20, 24, -4, ...COLORS.blueGrey[700], 0.1],
+ [0, 8, 8, -4, ...COLORS.blueGrey[700], 0.06],
+ ],
+ },
+
+ // predefined borders
+ borders: {
+ default: ['1px', 'solid', ...COLORS.grey[50]],
+ active: ['3px', 'solid', ...primary.light],
+ focus: ['4px', 'solid', ...secondary.default, 0.2],
+ },
+
+ avatar: {
+ color: [
+ COLORS.blueGrey[100],
+ COLORS.indigo[100],
+ COLORS.tangerine[100],
+ COLORS.green[100],
+ COLORS.yellowGreen[100],
+ COLORS.yellow[100],
+ COLORS.orange[100],
+ COLORS.red[100],
+ COLORS.blue[100],
+ ],
+ contrast: [
+ COLORS.blueGrey[900],
+ COLORS.indigo[900],
+ COLORS.tangerine[900],
+ COLORS.green[900],
+ COLORS.yellowGreen[900],
+ COLORS.yellow[900],
+ COLORS.orange[900],
+ COLORS.red[900],
+ COLORS.blue[900],
+ ],
+ },
+
+ // Theme specific icons and images
+ images: {
+ azure: 'azure.svg',
+ bitbucket: 'bitbucket.svg',
+ github: 'github.svg',
+ gitlab: 'gitlab.svg',
+ microsoft: 'microsoft.svg',
+ 'cayc-1': 'cayc-1-light.gif',
+ 'cayc-2': 'cayc-2-light.gif',
+ 'cayc-3': 'cayc-3-light.svg',
+ 'cayc-4': 'cayc-4-light.svg',
+ 'new-code-1': 'new-code-1.svg',
+ 'new-code-2': 'new-code-2-light.svg',
+ 'new-code-3': 'new-code-3.gif',
+ 'new-code-4': 'new-code-4.gif',
+ 'new-code-5': 'new-code-5.png',
+ 'pull-requests-1': 'pull-requests-1-light.gif',
+ 'pull-requests-2': 'pull-requests-2-light.svg',
+ 'pull-requests-3': 'pull-requests-3.svg',
+ 'quality-gate-1': 'quality-gate-1.png',
+ 'quality-gate-2a': 'quality-gate-2a.svg',
+ 'quality-gate-2b': 'quality-gate-2b.png',
+ 'quality-gate-2c': 'quality-gate-2c.png',
+ 'quality-gate-3': 'quality-gate-3-light.svg',
+ 'quality-gate-4': 'quality-gate-4.png',
+ 'quality-gate-5': 'quality-gate-5.svg',
+
+ // project configure page
+ AzurePipe: '/images/alms/azure.svg',
+ BitbucketPipe: '/images/alms/bitbucket.svg',
+ BitbucketAzure: '/images/alms/azure.svg',
+ BitbucketCircleCI: '/images/tutorials/circleci.svg',
+ GitHubActions: '/images/alms/github.svg',
+ GitHubCircleCI: '/images/tutorials/circleci.svg',
+ GitHubTravis: '/images/tutorials/TravisCI-Mascot.png',
+ GitLabPipeline: '/images/alms/gitlab.svg',
+ },
+};
+
+export default lightTheme;
diff --git a/server/sonar-web/design-system/src/types/misc.ts b/server/sonar-web/design-system/src/types/misc.ts
new file mode 100644
index 00000000000..ea95b30ffc6
--- /dev/null
+++ b/server/sonar-web/design-system/src/types/misc.ts
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+
+export type FCProps<T extends React.FunctionComponent<any>> = Parameters<T>[0];
diff --git a/server/sonar-web/design-system/src/types/theme.ts b/server/sonar-web/design-system/src/types/theme.ts
new file mode 100644
index 00000000000..7ced6c14012
--- /dev/null
+++ b/server/sonar-web/design-system/src/types/theme.ts
@@ -0,0 +1,45 @@
+/*
+ * 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 { lightTheme } from '../theme';
+
+export type InputSizeKeys = 'small' | 'medium' | 'large' | 'full' | 'auto';
+
+type LightTheme = typeof lightTheme;
+type ThemeColor = string | number[];
+export interface Theme extends Omit<LightTheme, 'colors' | 'contrasts'> {
+ colors: {
+ [key in keyof LightTheme['colors']]: ThemeColor;
+ };
+ contrasts: {
+ [key in keyof LightTheme['colors'] & keyof LightTheme['contrasts']]: ThemeColor;
+ };
+}
+
+export type ThemeColors = keyof Theme['colors'];
+export type ThemeContrasts = keyof Theme['contrasts'];
+
+type RGBColor = `rgb(${number},${number},${number})`;
+type RGBAColor = `rgba(${number},${number},${number},${number})`;
+type CSSCustomProp = `var(--${string})`;
+export type CSSColor = CSSCustomProp | RGBColor | RGBAColor;
+
+export interface ThemedProps {
+ theme: Theme;
+}