diff options
Diffstat (limited to 'server/sonar-web/design-system/src')
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'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; +} |