eslint_report_cache: | eslint_report_cache: | ||||
folders: | folders: | ||||
- server/sonar-web/eslint-report/ | - server/sonar-web/eslint-report/ | ||||
- server/sonar-web/design-system/eslint-report/ | |||||
- private/core-extension-securityreport/eslint-report/ | - private/core-extension-securityreport/eslint-report/ | ||||
- private/core-extension-license/eslint-report/ | - private/core-extension-license/eslint-report/ | ||||
- private/core-extension-enterprise-server/eslint-report/ | - private/core-extension-enterprise-server/eslint-report/ | ||||
jest_report_cache: | jest_report_cache: | ||||
folders: | folders: | ||||
- server/sonar-web/coverage/ | - server/sonar-web/coverage/ | ||||
- server/sonar-web/design-system/coverage/ | |||||
- private/core-extension-securityreport/coverage/ | - private/core-extension-securityreport/coverage/ | ||||
- private/core-extension-license/coverage/ | - private/core-extension-license/coverage/ | ||||
- private/core-extension-enterprise-server/coverage/ | - private/core-extension-enterprise-server/coverage/ |
['config', 'public', 'scripts', 'src'].each { | ['config', 'public', 'scripts', 'src'].each { | ||||
inputs.dir(it).withPathSensitivity(PathSensitivity.RELATIVE) | inputs.dir(it).withPathSensitivity(PathSensitivity.RELATIVE) | ||||
} | } | ||||
['package.json', 'tsconfig.json', 'yarn.lock'].each { | |||||
['package.json', 'tsconfig.json', 'yarn.lock', 'tailwind.config.js', 'tailwind.base.config.js'].each { | |||||
inputs.file(it).withPathSensitivity(PathSensitivity.RELATIVE) | inputs.file(it).withPathSensitivity(PathSensitivity.RELATIVE) | ||||
} | } | ||||
outputs.dir(webappDir) | outputs.dir(webappDir) |
/* | |||||
* 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 { ThemeContext } from '@emotion/react'; | |||||
import { lightTheme } from 'design-system'; | |||||
// Hack : override the default value of the context used for theme by emotion | |||||
// This allows tests to get the theme value without specifiying a theme provider | |||||
ThemeContext['_currentValue'] = lightTheme; | |||||
ThemeContext['_currentValue2'] = lightTheme; |
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
*/ | */ | ||||
export default { | |||||
module.exports = { | |||||
plugins: [ | plugins: [ | ||||
'babel-plugin-macros', | 'babel-plugin-macros', | ||||
[ | [ | ||||
}, | }, | ||||
], | ], | ||||
['@babel/plugin-transform-react-jsx', { pragma: '__cssprop' }, 'twin.macro'], | ['@babel/plugin-transform-react-jsx', { pragma: '__cssprop' }, 'twin.macro'], | ||||
'@emotion', | |||||
], | ], | ||||
}; | }; |
sonar { | |||||
properties { | |||||
property 'sonar.projectName', "${projectTitle} :: Web :: Design System" | |||||
property "sonar.sources", "src" | |||||
property "sonar.exclusions", "src/**/__tests__/**,src/types/**,src/@types/**,src/helpers/testUtils.tsx" | |||||
property "sonar.tests", "src" | |||||
property "sonar.test.inclusions", "src/**/__tests__/**" | |||||
property "sonar.javascript.lcov.reportPaths", "./coverage/lcov.info" | |||||
} | |||||
} | |||||
task "yarn_validate-ci"(type: Exec) { | |||||
dependsOn ":server:sonar-web:yarn_design-system" | |||||
inputs.dir('src') | |||||
['package.json', '../yarn.lock', 'jest.config.js'].each { | |||||
inputs.file(it).withPathSensitivity(PathSensitivity.RELATIVE) | |||||
} | |||||
outputs.dir('coverage') | |||||
outputs.cacheIf { true } | |||||
commandLine osAdaptiveCommand(['npm', 'run', 'validate-ci']) | |||||
} | |||||
task "yarn_lint-report-ci"(type: Exec) { | |||||
dependsOn ":server:sonar-web:yarn_design-system" | |||||
['src'].each { | |||||
inputs.dir(it) | |||||
} | |||||
['package.json', '../yarn.lock', 'tsconfig.json', '.eslintrc'].each { | |||||
inputs.file(it) | |||||
} | |||||
outputs.dir('eslint-report') | |||||
outputs.cacheIf { true } | |||||
commandLine osAdaptiveCommand(['npm', 'run', 'lint-report-ci']) | |||||
} |
/* | |||||
* 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 '@testing-library/jest-dom'; | |||||
import { configure } from '@testing-library/react'; | |||||
configure({ | |||||
asyncUtilTimeout: 3000, | |||||
}); |
/* | |||||
* 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 'whatwg-fetch'; | |||||
const content = document.createElement('div'); | |||||
content.id = 'content'; | |||||
document.documentElement.appendChild(content); | |||||
Element.prototype.scrollIntoView = () => {}; | |||||
global.___loader = { | |||||
enqueue: jest.fn(), | |||||
}; | |||||
const MockResizeObserverEntries = [ | |||||
{ | |||||
contentRect: { | |||||
width: 100, | |||||
height: 200, | |||||
}, | |||||
}, | |||||
]; | |||||
const MockResizeObserver = { | |||||
observe: jest.fn(), | |||||
unobserve: jest.fn(), | |||||
disconnect: jest.fn(), | |||||
}; | |||||
global.ResizeObserver = jest.fn().mockImplementation((callback) => { | |||||
callback(MockResizeObserverEntries, MockResizeObserver); | |||||
return MockResizeObserver; | |||||
}); |
/* | |||||
* 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 { ThemeContext } from '@emotion/react'; | |||||
import { lightTheme } from '../../src/theme'; | |||||
// Hack : override the default value of the context used for theme by emotion | |||||
// This allows tests to get the theme value without specifiying a theme provider | |||||
ThemeContext['_currentValue'] = lightTheme; | |||||
ThemeContext['_currentValue2'] = lightTheme; |
/* | |||||
* 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. | |||||
*/ | |||||
const babelConfig = require('./babel.config'); | |||||
babelConfig.presets = [ | |||||
['@babel/preset-env', { targets: { node: 'current' } }], | |||||
'@babel/preset-typescript', | |||||
]; | |||||
module.exports = { | |||||
coverageDirectory: '<rootDir>/coverage', | |||||
collectCoverageFrom: [ | |||||
'src/components/**/*.{ts,tsx,js}', | |||||
'src/helpers/**/*.{ts,tsx,js}', | |||||
'!src/helpers/{keycodes,testUtils}.{ts,tsx}', | |||||
], | |||||
coverageReporters: ['lcovonly', 'text'], | |||||
globals: { | |||||
'ts-jest': { | |||||
diagnostics: false, | |||||
}, | |||||
}, | |||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], | |||||
moduleNameMapper: { | |||||
'^.+\\.(md|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': | |||||
'<rootDir>/config/jest/FileStub.js', | |||||
// '^.+\\.css$': '<rootDir>/config/jest/CSSStub.js', | |||||
}, | |||||
setupFiles: [ | |||||
'<rootDir>/config/jest/SetupTestEnvironment.js', | |||||
'<rootDir>/config/jest/SetupTheme.js', | |||||
], | |||||
setupFilesAfterEnv: ['<rootDir>/config/jest/SetupReactTestingLibrary.ts'], | |||||
snapshotSerializers: ['@emotion/jest/serializer'], | |||||
testEnvironment: 'jsdom', | |||||
testPathIgnorePatterns: ['<rootDir>/config/jest', '<rootDir>/node_modules', '<rootDir>/scripts'], | |||||
testRegex: '(/__tests__/.*|\\-test)\\.(ts|tsx|js)$', | |||||
transform: { | |||||
'^.+\\.(t|j)sx?$': ['babel-jest', babelConfig], | |||||
}, | |||||
transformIgnorePatterns: ['/node_modules/(?!(d3-.+))/'], | |||||
testTimeout: 30000, | |||||
}; |
{ | { | ||||
"name": "design-system", | "name": "design-system", | ||||
"version": "1.0.0", | "version": "1.0.0", | ||||
"main": "./lib/index.js", | |||||
"types": "./lib/index.d.ts", | |||||
"main": "lib/index.js", | |||||
"types": "lib/index.d.ts", | |||||
"scripts": { | "scripts": { | ||||
"build": "yarn lint && vite build", | "build": "yarn lint && vite build", | ||||
"build-release": "yarn install --immutable && yarn build", | "build-release": "yarn install --immutable && yarn build", | ||||
"lint": "npx eslint --ext js,ts,tsx,snap --quiet src" | |||||
"lint": "eslint --ext js,ts,tsx,snap --quiet src", | |||||
"lint-report-ci": "yarn install --immutable && eslint --ext js,ts,tsx -f json -o eslint-report/eslint-report.json src || yarn lint", | |||||
"test": "jest", | |||||
"validate-ci": "yarn install --immutable && yarn test --coverage --ci" | |||||
}, | }, | ||||
"devDependencies": { | "devDependencies": { | ||||
"@babel/core": "7.20.5", | "@babel/core": "7.20.5", | ||||
"@babel/plugin-transform-react-jsx": "7.20.13", | "@babel/plugin-transform-react-jsx": "7.20.13", | ||||
"@babel/preset-env": "7.20.2", | |||||
"@babel/preset-typescript": "7.18.6", | |||||
"@emotion/babel-plugin": "11.10.6", | |||||
"@emotion/babel-plugin-jsx-pragmatic": "0.2.0", | "@emotion/babel-plugin-jsx-pragmatic": "0.2.0", | ||||
"@testing-library/dom": "8.20.0", | |||||
"@testing-library/jest-dom": "5.16.5", | |||||
"@testing-library/react": "12.1.5", | |||||
"@testing-library/user-event": "14.4.3", | |||||
"@types/react": "16.14.34", | |||||
"@typescript-eslint/parser": "5.49.0", | |||||
"@vitejs/plugin-react": "3.1.0", | "@vitejs/plugin-react": "3.1.0", | ||||
"autoprefixer": "10.4.13", | |||||
"eslint": "8.32.0", | |||||
"eslint-plugin-header": "3.1.1", | "eslint-plugin-header": "3.1.1", | ||||
"eslint-plugin-typescript-sort-keys": "2.1.0", | "eslint-plugin-typescript-sort-keys": "2.1.0", | ||||
"twin.macro": "3.1.0", | |||||
"history": "5.3.0", | |||||
"jest": "29.3.1", | |||||
"postcss": "8.4.21", | |||||
"postcss-calc": "8.2.4", | |||||
"postcss-custom-properties": "12.1.11", | |||||
"twin.macro": "2.8.2", | |||||
"typescript": "4.9.4", | |||||
"vite": "4.1.1", | "vite": "4.1.1", | ||||
"vite-plugin-dts": "1.7.2" | |||||
"vite-plugin-dts": "2.0.2", | |||||
"whatwg-fetch": "3.6.2" | |||||
}, | }, | ||||
"peerDependencies": { | "peerDependencies": { | ||||
"@emotion/react": "11.10.5", | "@emotion/react": "11.10.5", | ||||
"@emotion/styled": "11.10.5", | "@emotion/styled": "11.10.5", | ||||
"@typescript-eslint/parser": "5.49.0", | |||||
"eslint": "8.32.0", | |||||
"@primer/octicons-react": "17.11.1", | |||||
"classnames": "2.3.2", | |||||
"clipboard": "2.0.11", | |||||
"lodash": "4.17.21", | |||||
"react": "16.14.0", | "react": "16.14.0", | ||||
"react-dom": "16.14.0", | "react-dom": "16.14.0", | ||||
"tailwindcss": "3.2.6", | |||||
"typescript": "4.9.4" | |||||
"react-helmet-async": "1.3.0", | |||||
"react-intl": "6.2.5", | |||||
"react-router-dom": "6.7.0", | |||||
"tailwindcss": "2.2.19" | |||||
}, | |||||
"babelMacros": { | |||||
"twin": { | |||||
"config": "../tailwind.config.js", | |||||
"preset": "emotion" | |||||
} | |||||
} | } | ||||
} | } |
/* | |||||
* 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; | |||||
} | |||||
} |
/* | |||||
* 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 {} | |||||
} |
/* | |||||
* 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')}; | |||||
`; |
/* | |||||
* 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')}; | |||||
} | |||||
`; |
/* | |||||
* 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); | |||||
} | |||||
}, | |||||
}); | |||||
} |
/* | |||||
* 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`}; | |||||
`; |
/* | |||||
* 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> | |||||
); | |||||
} |
/* | |||||
* 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} | |||||
`; |
/* | |||||
* 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> | |||||
); | |||||
} |
/* | |||||
* 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; | |||||
} | |||||
} |
/* | |||||
* 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; | |||||
`; |
/* | |||||
* 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`} | |||||
`; |
/* | |||||
* 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; | |||||
`; |
/* | |||||
* 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; |
/* | |||||
* 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> | |||||
); | |||||
} |
/* | |||||
* 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>; | |||||
} |
/* | |||||
* 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')}; | |||||
} | |||||
} | |||||
`; |
/* | |||||
* 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; |
/* | |||||
* 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; | |||||
} | |||||
} |
/* | |||||
* 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')}; | |||||
} | |||||
} | |||||
`; |
/* | |||||
* 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> | |||||
); | |||||
} |
/* | |||||
* 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')}; | |||||
`; |
/* | |||||
* 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`}; | |||||
} | |||||
`; |
/* | |||||
* 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} /> | |||||
); | |||||
} |
/* | |||||
* 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} />; | |||||
} |
/* | |||||
* 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> | |||||
); | |||||
} | |||||
}); |
/* | |||||
* 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> | |||||
); | |||||
} |
/* | |||||
* 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'); | |||||
}); |
/* | |||||
* 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} | |||||
/> | |||||
); | |||||
} |
/* | |||||
* 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> | |||||
); | |||||
}; |
/* | |||||
* 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} />); | |||||
} |
/* | |||||
* 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)', | |||||
}); | |||||
}); |
/* | |||||
* 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> | |||||
); | |||||
}; |
/* | |||||
* 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)', | |||||
}); | |||||
}); |
/* | |||||
* 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> | |||||
); | |||||
} | |||||
}); |
/* | |||||
* 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(); | |||||
}); | |||||
}); |
/* | |||||
* 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; | |||||
`; |
/* | |||||
* 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> | |||||
); | |||||
} |
/* | |||||
* 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> | |||||
); | |||||
} |
/* | |||||
* 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); |
/* | |||||
* 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'); |
/* | |||||
* 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); |
/* | |||||
* 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; | |||||
} |
/* | |||||
* 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> | |||||
); | |||||
} |
/* | |||||
* 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; |
/* | |||||
* 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> | |||||
); | |||||
} |
/* | |||||
* 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'); |
/* | |||||
* 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); |
/* | |||||
* 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); |
/* | |||||
* 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(); | |||||
}); | |||||
}); |
/* | |||||
* 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'; |
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * 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'; |
/* | |||||
* 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); | |||||
} | |||||
} |
/* | |||||
* 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)'); | |||||
}); | |||||
}); |
/* | |||||
* 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, | |||||
}); | |||||
}); |
/* | |||||
* 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)' | |||||
); | |||||
}); | |||||
}); |
/* | |||||
* 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; | |||||
} |
/* | |||||
* 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'; |
* along with this program; if not, write to the Free Software Foundation, | * along with this program; if not, write to the Free Software Foundation, | ||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * 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'; |
/* | |||||
* 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); | |||||
} |
/* | |||||
* 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('.')}`; | |||||
} |
/* | |||||
* 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 | |||||
); | |||||
} |
/* | |||||
* 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(); | |||||
} | |||||
}); | |||||
} |
/* | |||||
* 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]); | |||||
} |
/* | |||||
* 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; | |||||
} |
/* | |||||
* 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'; |
/* | |||||
* 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], | |||||
}, | |||||
}; |
/* | |||||
* 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'; |
/* | |||||
* 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; |
/* | |||||
* 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]; |
/* | |||||
* 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; | |||||
} |
"forceConsistentCasingInFileNames": true, | "forceConsistentCasingInFileNames": true, | ||||
"isolatedModules": true, | "isolatedModules": true, | ||||
"lib": ["dom", "dom.iterable", "es2022"], | "lib": ["dom", "dom.iterable", "es2022"], | ||||
"jsx": "react-jsx", | |||||
"module": "commonjs", | "module": "commonjs", | ||||
"noEmit": true, | "noEmit": true, | ||||
"paths": { | "paths": { | ||||
"~helpers/*": ["src/helpers/*"], | "~helpers/*": ["src/helpers/*"], | ||||
"~icons/*": ["src/icons/*"], | "~icons/*": ["src/icons/*"], | ||||
"~types/*": ["src/types/*"], | "~types/*": ["src/types/*"], | ||||
"~utils/*": ["src/utils/*"], | |||||
"~utils/*": ["src/utils/*"] | |||||
}, | }, | ||||
"resolveJsonModule": true, | "resolveJsonModule": true, | ||||
"skipLibCheck": true, | |||||
} | |||||
"skipLibCheck": true | |||||
}, | |||||
"include": ["./src/**/*"] | |||||
} | } |
export default defineConfig({ | export default defineConfig({ | ||||
build: { | build: { | ||||
lib: { | lib: { | ||||
entry: resolve('src', 'components/index.ts'), | |||||
entry: resolve('src', 'index.ts'), | |||||
name: 'MIUI', | name: 'MIUI', | ||||
formats: ['es'], | formats: ['es'], | ||||
fileName: (_format) => `index.js`, | fileName: (_format) => `index.js`, | ||||
babel: babelConfig, | babel: babelConfig, | ||||
}), | }), | ||||
dts({ | dts({ | ||||
include: ['src/components/'], | |||||
entryRoot: 'src', | |||||
}), | }), | ||||
], | ], | ||||
}); | }); |
'<rootDir>/config/polyfills.ts', | '<rootDir>/config/polyfills.ts', | ||||
'<rootDir>/config/jest/SetupEnzyme.ts', | '<rootDir>/config/jest/SetupEnzyme.ts', | ||||
'<rootDir>/config/jest/SetupTestEnvironment.ts', | '<rootDir>/config/jest/SetupTestEnvironment.ts', | ||||
'<rootDir>/config/jest/SetupTheme.js', | |||||
], | ], | ||||
setupFilesAfterEnv: ['<rootDir>/config/jest/SetupReactTestingLibrary.ts'], | setupFilesAfterEnv: ['<rootDir>/config/jest/SetupReactTestingLibrary.ts'], | ||||
snapshotSerializers: ['enzyme-to-json/serializer', '@emotion/jest/serializer'], | snapshotSerializers: ['enzyme-to-json/serializer', '@emotion/jest/serializer'], | ||||
testEnvironment: 'jsdom', | testEnvironment: 'jsdom', | ||||
testPathIgnorePatterns: ['<rootDir>/config', '<rootDir>/node_modules', '<rootDir>/scripts'], | |||||
testPathIgnorePatterns: [ | |||||
'<rootDir>/config', | |||||
'<rootDir>/design-system', | |||||
'<rootDir>/node_modules', | |||||
'<rootDir>/scripts', | |||||
], | |||||
testRegex: '(/__tests__/.*|\\-test)\\.(ts|tsx|js)$', | testRegex: '(/__tests__/.*|\\-test)\\.(ts|tsx|js)$', | ||||
transform: { | transform: { | ||||
'^.+\\.(t|j)sx?$': [ | '^.+\\.(t|j)sx?$': [ |
"dependencies": { | "dependencies": { | ||||
"@emotion/react": "11.10.5", | "@emotion/react": "11.10.5", | ||||
"@emotion/styled": "11.10.5", | "@emotion/styled": "11.10.5", | ||||
"@primer/octicons-react": "17.11.1", | |||||
"classnames": "2.3.2", | "classnames": "2.3.2", | ||||
"clipboard": "2.0.11", | "clipboard": "2.0.11", | ||||
"core-js": "3.27.2", | "core-js": "3.27.2", | ||||
"postcss-custom-properties": "12.1.11", | "postcss-custom-properties": "12.1.11", | ||||
"prettier": "2.8.3", | "prettier": "2.8.3", | ||||
"react-select-event": "5.5.1", | "react-select-event": "5.5.1", | ||||
"tailwindcss": "3.2.6", | |||||
"tailwindcss": "2.2.19", | |||||
"testing-library-selector": "0.2.1", | "testing-library-selector": "0.2.1", | ||||
"turbo": "1.7.4", | "turbo": "1.7.4", | ||||
"typescript": "4.9.4", | "typescript": "4.9.4", |
* along with this program; if not, write to the Free Software Foundation, | * along with this program; if not, write to the Free Software Foundation, | ||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
*/ | */ | ||||
import { ThemeProvider } from '@emotion/react'; | |||||
import { lightTheme } from 'design-system'; | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { Outlet, useLocation } from 'react-router-dom'; | import { Outlet, useLocation } from 'react-router-dom'; | ||||
import A11yProvider from '../../components/a11y/A11yProvider'; | import A11yProvider from '../../components/a11y/A11yProvider'; | ||||
const location = useLocation(); | const location = useLocation(); | ||||
return ( | return ( | ||||
<SuggestionsProvider> | |||||
<A11yProvider> | |||||
<StartupModal> | |||||
<A11ySkipLinks /> | |||||
<div className="global-container"> | |||||
<div className="page-wrapper" id="container"> | |||||
<div className="page-container"> | |||||
<BranchStatusContextProvider> | |||||
<Workspace> | |||||
<IndexationContextProvider> | |||||
<LanguagesContextProvider> | |||||
<MetricsContextProvider> | |||||
<SystemAnnouncement /> | |||||
<IndexationNotification /> | |||||
<UpdateNotification dismissable={true} /> | |||||
<GlobalNav location={location} /> | |||||
<Outlet /> | |||||
</MetricsContextProvider> | |||||
</LanguagesContextProvider> | |||||
</IndexationContextProvider> | |||||
</Workspace> | |||||
</BranchStatusContextProvider> | |||||
<ThemeProvider theme={lightTheme}> | |||||
<SuggestionsProvider> | |||||
<A11yProvider> | |||||
<StartupModal> | |||||
<A11ySkipLinks /> | |||||
<div className="global-container"> | |||||
<div className="page-wrapper" id="container"> | |||||
<div className="page-container"> | |||||
<BranchStatusContextProvider> | |||||
<Workspace> | |||||
<IndexationContextProvider> | |||||
<LanguagesContextProvider> | |||||
<MetricsContextProvider> | |||||
<SystemAnnouncement /> | |||||
<IndexationNotification /> | |||||
<UpdateNotification dismissable={true} /> | |||||
<GlobalNav location={location} /> | |||||
<Outlet /> | |||||
</MetricsContextProvider> | |||||
</LanguagesContextProvider> | |||||
</IndexationContextProvider> | |||||
</Workspace> | |||||
</BranchStatusContextProvider> | |||||
</div> | |||||
<PromotionNotification /> | |||||
</div> | </div> | ||||
<PromotionNotification /> | |||||
<GlobalFooter /> | |||||
</div> | </div> | ||||
<GlobalFooter /> | |||||
</div> | |||||
</StartupModal> | |||||
</A11yProvider> | |||||
</SuggestionsProvider> | |||||
</StartupModal> | |||||
</A11yProvider> | |||||
</SuggestionsProvider> | |||||
</ThemeProvider> | |||||
); | ); | ||||
} | } |
*/ | */ | ||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { Outlet } from 'react-router-dom'; | import { Outlet } from 'react-router-dom'; | ||||
import NavBar from '../../components/ui/NavBar'; | |||||
import { rawSizes } from '../theme'; | |||||
import GlobalFooter from './GlobalFooter'; | import GlobalFooter from './GlobalFooter'; | ||||
import MainSonarQubeBar from './nav/global/MainSonarQubeBar'; | |||||
/* | /* | ||||
* We need to render either children or the Outlet, | * We need to render either children or the Outlet, | ||||
return ( | return ( | ||||
<div className="global-container"> | <div className="global-container"> | ||||
<div className="page-wrapper" id="container"> | <div className="page-wrapper" id="container"> | ||||
<NavBar className="global-navbar" height={rawSizes.globalNavHeightRaw} /> | |||||
<MainSonarQubeBar /> | |||||
{children !== undefined ? children : <Outlet />} | {children !== undefined ? children : <Outlet />} | ||||
</div> | </div> | ||||
<GlobalFooter /> | <GlobalFooter /> |
* along with this program; if not, write to the Free Software Foundation, | * along with this program; if not, write to the Free Software Foundation, | ||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
*/ | */ | ||||
import { | |||||
DropdownMenu, | |||||
InputSearch, | |||||
InteractiveIcon, | |||||
INTERACTIVE_TOOLTIP_DELAY, | |||||
MenuSearchIcon, | |||||
PopupZLevel, | |||||
PortalPopup, | |||||
TextMuted, | |||||
Tooltip, | |||||
} from 'design-system'; | |||||
import { debounce, uniqBy } from 'lodash'; | import { debounce, uniqBy } from 'lodash'; | ||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { FormattedMessage } from 'react-intl'; | |||||
import { getSuggestions } from '../../../api/components'; | import { getSuggestions } from '../../../api/components'; | ||||
import { DropdownOverlay } from '../../../components/controls/Dropdown'; | |||||
import FocusOutHandler from '../../../components/controls/FocusOutHandler'; | |||||
import OutsideClickHandler from '../../../components/controls/OutsideClickHandler'; | import OutsideClickHandler from '../../../components/controls/OutsideClickHandler'; | ||||
import SearchBox from '../../../components/controls/SearchBox'; | |||||
import { Router, withRouter } from '../../../components/hoc/withRouter'; | import { Router, withRouter } from '../../../components/hoc/withRouter'; | ||||
import ClockIcon from '../../../components/icons/ClockIcon'; | |||||
import DeferredSpinner from '../../../components/ui/DeferredSpinner'; | |||||
import { PopupPlacement } from '../../../components/ui/popups'; | |||||
import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers'; | import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers'; | ||||
import { KeyboardKeys } from '../../../helpers/keycodes'; | import { KeyboardKeys } from '../../../helpers/keycodes'; | ||||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | import { translate, translateWithParameters } from '../../../helpers/l10n'; | ||||
import { ComponentQualifier } from '../../../types/component'; | import { ComponentQualifier } from '../../../types/component'; | ||||
import { Dict } from '../../../types/types'; | import { Dict } from '../../../types/types'; | ||||
import RecentHistory from '../RecentHistory'; | import RecentHistory from '../RecentHistory'; | ||||
import './Search.css'; | |||||
import SearchResult from './SearchResult'; | |||||
import SearchResults from './SearchResults'; | |||||
import GlobalSearchResult from './GlobalSearchResult'; | |||||
import GlobalSearchResults from './GlobalSearchResults'; | |||||
import { ComponentResult, More, Results, sortQualifiers } from './utils'; | import { ComponentResult, More, Results, sortQualifiers } from './utils'; | ||||
interface Props { | interface Props { | ||||
query: string; | query: string; | ||||
results: Results; | results: Results; | ||||
selected?: string; | selected?: string; | ||||
shortQuery: boolean; | |||||
} | } | ||||
const MIN_SEARCH_QUERY_LENGTH = 2; | const MIN_SEARCH_QUERY_LENGTH = 2; | ||||
export class Search extends React.PureComponent<Props, State> { | |||||
export class GlobalSearch extends React.PureComponent<Props, State> { | |||||
input?: HTMLInputElement | null; | input?: HTMLInputElement | null; | ||||
node?: HTMLElement | null; | node?: HTMLElement | null; | ||||
nodes: Dict<HTMLElement>; | nodes: Dict<HTMLElement>; | ||||
open: false, | open: false, | ||||
query: '', | query: '', | ||||
results: {}, | results: {}, | ||||
shortQuery: false, | |||||
}; | }; | ||||
} | } | ||||
componentDidMount() { | componentDidMount() { | ||||
this.mounted = true; | this.mounted = true; | ||||
document.addEventListener('keydown', this.handleKeyDown); | |||||
document.addEventListener('keydown', this.handleSKeyDown); | document.addEventListener('keydown', this.handleSKeyDown); | ||||
} | } | ||||
componentWillUnmount() { | componentWillUnmount() { | ||||
this.mounted = false; | this.mounted = false; | ||||
document.removeEventListener('keydown', this.handleSKeyDown); | document.removeEventListener('keydown', this.handleSKeyDown); | ||||
document.removeEventListener('keydown', this.handleKeyDown); | |||||
} | } | ||||
focusInput = () => { | focusInput = () => { | ||||
query: '', | query: '', | ||||
results: {}, | results: {}, | ||||
selected: undefined, | selected: undefined, | ||||
shortQuery: false, | |||||
}); | }); | ||||
} else { | } else { | ||||
this.setState({ open: false }); | this.setState({ open: false }); | ||||
more, | more, | ||||
results, | results, | ||||
selected: list.length > 0 ? list[0] : undefined, | selected: list.length > 0 ? list[0] : undefined, | ||||
shortQuery: | |||||
query.length > MIN_SEARCH_QUERY_LENGTH && response.warning === 'short_input', | |||||
}); | }); | ||||
} | } | ||||
}, this.stopLoading); | }, this.stopLoading); | ||||
}; | }; | ||||
handleQueryChange = (query: string) => { | handleQueryChange = (query: string) => { | ||||
this.setState({ query, shortQuery: query.length === 1 }); | |||||
this.setState({ query }); | |||||
this.search(query); | this.search(query); | ||||
}; | }; | ||||
if (this.state.selected) { | if (this.state.selected) { | ||||
const node = this.nodes[this.state.selected]; | const node = this.nodes[this.state.selected]; | ||||
if (node && this.node) { | if (node && this.node) { | ||||
scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node }); | |||||
scrollToElement(node, { | |||||
topOffset: 30, | |||||
bottomOffset: 60, | |||||
parent: this.node, | |||||
}); | |||||
} | } | ||||
} | } | ||||
}; | }; | ||||
} | } | ||||
}; | }; | ||||
handleKeyDown = (event: KeyboardEvent) => { | |||||
handleKeyDown = (event: React.KeyboardEvent) => { | |||||
if (!this.state.open) { | if (!this.state.open) { | ||||
return; | return; | ||||
} | } | ||||
}; | }; | ||||
renderResult = (component: ComponentResult) => ( | renderResult = (component: ComponentResult) => ( | ||||
<SearchResult | |||||
<GlobalSearchResult | |||||
component={component} | component={component} | ||||
innerRef={this.innerRef} | innerRef={this.innerRef} | ||||
key={component.key} | key={component.key} | ||||
); | ); | ||||
renderNoResults = () => ( | renderNoResults = () => ( | ||||
<div className="navbar-search-no-results" aria-live="assertive"> | |||||
<div className="sw-px-3 sw-py-2" aria-live="assertive"> | |||||
{translateWithParameters('no_results_for_x', this.state.query)} | {translateWithParameters('no_results_for_x', this.state.query)} | ||||
</div> | </div> | ||||
); | ); | ||||
render() { | render() { | ||||
const { open, query, results, more, loadingMore, selected, loading } = this.state; | |||||
if (!open && !query) { | |||||
return ( | |||||
<Tooltip mouseEnterDelay={INTERACTIVE_TOOLTIP_DELAY} overlay={translate('search_verb')}> | |||||
<InteractiveIcon | |||||
className="it__search-icon" | |||||
Icon={MenuSearchIcon} | |||||
aria-label={translate('search_verb')} | |||||
currentColor={true} | |||||
onClick={this.handleFocus} | |||||
size="medium" | |||||
/> | |||||
</Tooltip> | |||||
); | |||||
} | |||||
const list = this.getPlainComponentsList(results, more); | |||||
const search = ( | const search = ( | ||||
<div role="search" className="navbar-search dropdown"> | |||||
<DeferredSpinner className="navbar-search-icon" loading={this.state.loading} /> | |||||
<SearchBox | |||||
autoFocus={this.state.open} | |||||
innerRef={this.searchInputRef} | |||||
minLength={2} | |||||
onChange={this.handleQueryChange} | |||||
onFocus={this.handleFocus} | |||||
placeholder={translate('search.placeholder')} | |||||
value={this.state.query} | |||||
/> | |||||
{this.state.shortQuery && ( | |||||
<span className="navbar-search-input-hint" aria-live="assertive"> | |||||
{translateWithParameters('select2.tooShort', MIN_SEARCH_QUERY_LENGTH)} | |||||
</span> | |||||
)} | |||||
{this.state.open && Object.keys(this.state.results).length > 0 && ( | |||||
<DropdownOverlay noPadding={true}> | |||||
<div className="global-navbar-search-dropdown" ref={(node) => (this.node = node)}> | |||||
<SearchResults | |||||
allowMore={this.state.query.length !== 1} | |||||
loadingMore={this.state.loadingMore} | |||||
more={this.state.more} | |||||
onMoreClick={this.searchMore} | |||||
onSelect={this.handleSelect} | |||||
renderNoResults={this.renderNoResults} | |||||
renderResult={this.renderResult} | |||||
results={this.state.results} | |||||
selected={this.state.selected} | |||||
/> | |||||
<div className="dropdown-bottom-hint"> | |||||
<div className="pull-right" aria-hidden={true}> | |||||
<ClockIcon className="little-spacer-right" size={12} /> | |||||
{translate('recently_browsed')} | |||||
</div> | |||||
<FormattedMessage | |||||
defaultMessage={translate('search.shortcut_hint')} | |||||
id="search.shortcut_hint" | |||||
values={{ | |||||
shortcut: <span className="shortcut-button shortcut-button-small">s</span>, | |||||
}} | |||||
<div role="search" className="sw-min-w-abs-200 sw-max-w-abs-350 sw-w-full"> | |||||
<PortalPopup | |||||
allowResizing={true} | |||||
overlay={ | |||||
open && ( | |||||
<DropdownMenu | |||||
className="it__global-navbar-search-dropdown sw-overflow-y-auto sw-overflow-x-hidden" | |||||
maxHeight="38rem" | |||||
innerRef={(node: HTMLUListElement | null) => (this.node = node)} | |||||
size="auto" | |||||
> | |||||
<GlobalSearchResults | |||||
query={query} | |||||
loadingMore={loadingMore} | |||||
more={more} | |||||
onMoreClick={this.searchMore} | |||||
onSelect={this.handleSelect} | |||||
renderNoResults={this.renderNoResults} | |||||
renderResult={this.renderResult} | |||||
results={results} | |||||
selected={selected} | |||||
/> | /> | ||||
</div> | |||||
</div> | |||||
</DropdownOverlay> | |||||
)} | |||||
{list.length > 0 && ( | |||||
<li className="sw-px-3 sw-pt-1"> | |||||
<TextMuted text={translate('global_search.shortcut_hint')} /> | |||||
</li> | |||||
)} | |||||
</DropdownMenu> | |||||
) | |||||
} | |||||
placement={PopupPlacement.BottomLeft} | |||||
zLevel={PopupZLevel.Global} | |||||
> | |||||
<InputSearch | |||||
className="sw-w-full" | |||||
autoFocus={open} | |||||
innerRef={this.searchInputRef} | |||||
loading={loading} | |||||
minLength={MIN_SEARCH_QUERY_LENGTH} | |||||
onChange={this.handleQueryChange} | |||||
onFocus={this.handleFocus} | |||||
onKeyDown={this.handleKeyDown} | |||||
placeholder={translate('search.search_for_projects')} | |||||
size="auto" | |||||
value={query} | |||||
tooShortText={translateWithParameters('select2.tooShort', MIN_SEARCH_QUERY_LENGTH)} | |||||
searchInputAriaLabel={translate('search_verb')} | |||||
clearIconAriaLabel={translate('clear')} | |||||
/> | |||||
</PortalPopup> | |||||
</div> | </div> | ||||
); | ); | ||||
return this.state.open ? ( | |||||
<FocusOutHandler onFocusOut={this.handleClickOutside}> | |||||
<OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler> | |||||
</FocusOutHandler> | |||||
return open ? ( | |||||
<OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler> | |||||
) : ( | ) : ( | ||||
search | search | ||||
); | ); | ||||
} | } | ||||
} | } | ||||
export default withRouter(Search); | |||||
export default withRouter(GlobalSearch); |
/* | |||||
* 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 { ClockIcon, ItemLink, SearchText, TextMuted } from 'design-system'; | |||||
import * as React from 'react'; | |||||
import FavoriteIcon from '../../../components/icons/FavoriteIcon'; | |||||
import { translate } from '../../../helpers/l10n'; | |||||
import { getComponentOverviewUrl } from '../../../helpers/urls'; | |||||
import { ComponentResult } from './utils'; | |||||
interface Props { | |||||
component: ComponentResult; | |||||
innerRef: (componentKey: string, node: HTMLElement | null) => void; | |||||
onClose: () => void; | |||||
onSelect: (componentKey: string) => void; | |||||
selected: boolean; | |||||
} | |||||
export default class GlobalSearchResult extends React.PureComponent<Props> { | |||||
doSelect = () => { | |||||
this.props.onSelect(this.props.component.key); | |||||
}; | |||||
render() { | |||||
const { component, selected } = this.props; | |||||
const to = getComponentOverviewUrl(component.key, component.qualifier); | |||||
return ( | |||||
<ItemLink | |||||
className={classNames('sw-flex sw-flex-col sw-items-start sw-space-y-1', { | |||||
active: selected, | |||||
})} | |||||
innerRef={(node: HTMLAnchorElement | null) => this.props.innerRef(component.key, node)} | |||||
key={component.key} | |||||
onClick={this.props.onClose} | |||||
onPointerEnter={this.doSelect} | |||||
to={to} | |||||
> | |||||
<div className="sw-flex sw-justify-between sw-items-center sw-w-full"> | |||||
<SearchText match={component.match} name={component.name} /> | |||||
{component.isFavorite && <FavoriteIcon favorite={true} size={16} />} | |||||
{!component.isFavorite && component.isRecentlyBrowsed && ( | |||||
<ClockIcon aria-label={translate('recently_browsed')} /> | |||||
)} | |||||
</div> | |||||
<TextMuted text={component.key} /> | |||||
</ItemLink> | |||||
); | |||||
} | |||||
} |
* along with this program; if not, write to the Free Software Foundation, | * along with this program; if not, write to the Free Software Foundation, | ||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
*/ | */ | ||||
import { ItemDivider, ItemHeader } from 'design-system'; | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { translate } from '../../../helpers/l10n'; | import { translate } from '../../../helpers/l10n'; | ||||
import SearchShowMore from './SearchShowMore'; | |||||
import GlobalSearchShowMore from './GlobalSearchShowMore'; | |||||
import { ComponentResult, More, Results, sortQualifiers } from './utils'; | import { ComponentResult, More, Results, sortQualifiers } from './utils'; | ||||
export interface Props { | export interface Props { | ||||
allowMore: boolean; | |||||
query: string; | |||||
loadingMore?: string; | loadingMore?: string; | ||||
more: More; | more: More; | ||||
onMoreClick: (qualifier: string) => void; | onMoreClick: (qualifier: string) => void; | ||||
selected?: string; | selected?: string; | ||||
} | } | ||||
export default function SearchResults(props: Props): React.ReactElement<Props> { | |||||
export default function GlobalSearchResults(props: Props): React.ReactElement<Props> { | |||||
const qualifiers = Object.keys(props.results); | const qualifiers = Object.keys(props.results); | ||||
const renderedComponents: React.ReactNode[] = []; | const renderedComponents: React.ReactNode[] = []; | ||||
const allowMore = props.query.length !== 1; | |||||
sortQualifiers(qualifiers).forEach((qualifier) => { | sortQualifiers(qualifiers).forEach((qualifier) => { | ||||
const components = props.results[qualifier]; | const components = props.results[qualifier]; | ||||
if (components.length > 0) { | if (components.length > 0) { | ||||
const more = props.more[qualifier]; | const more = props.more[qualifier]; | ||||
renderedComponents.push( | renderedComponents.push( | ||||
<> | |||||
<h2 className="menu-header no-margin" id={translate('qualifiers', qualifier)}> | |||||
{translate('qualifiers', qualifier)} | |||||
</h2> | |||||
<ul | |||||
className="menu" | |||||
key={`header-${qualifier}`} | |||||
aria-labelledby={translate('qualifiers', qualifier)} | |||||
> | |||||
<li key={`group-${qualifier}`}> | |||||
<ul key={`header-${qualifier}`} aria-labelledby={translate('qualifiers', qualifier)}> | |||||
<ItemHeader> | |||||
<p id={translate('qualifiers', qualifier)}>{translate('qualifiers', qualifier)}</p> | |||||
</ItemHeader> | |||||
{components.map((component) => props.renderResult(component))} | {components.map((component) => props.renderResult(component))} | ||||
{more !== undefined && more > 0 && ( | {more !== undefined && more > 0 && ( | ||||
<SearchShowMore | |||||
allowMore={props.allowMore} | |||||
<GlobalSearchShowMore | |||||
allowMore={allowMore} | |||||
key={`more-${qualifier}`} | key={`more-${qualifier}`} | ||||
loadingMore={props.loadingMore} | loadingMore={props.loadingMore} | ||||
onMoreClick={props.onMoreClick} | onMoreClick={props.onMoreClick} | ||||
selected={props.selected === `qualifier###${qualifier}`} | selected={props.selected === `qualifier###${qualifier}`} | ||||
/> | /> | ||||
)} | )} | ||||
<ItemDivider /> | |||||
</ul> | </ul> | ||||
</> | |||||
</li> | |||||
); | ); | ||||
} | } | ||||
}); | }); | ||||
return renderedComponents.length > 0 ? <div>{renderedComponents}</div> : props.renderNoResults(); | |||||
return renderedComponents.length > 0 ? <>{renderedComponents}</> : props.renderNoResults(); | |||||
} | } |
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
*/ | */ | ||||
import classNames from 'classnames'; | import classNames from 'classnames'; | ||||
import { DeferredSpinner, ItemButton } from 'design-system'; | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { FormattedMessage } from 'react-intl'; | |||||
import DeferredSpinner from '../../../components/ui/DeferredSpinner'; | |||||
import { translate } from '../../../helpers/l10n'; | import { translate } from '../../../helpers/l10n'; | ||||
interface Props { | interface Props { | ||||
selected: boolean; | selected: boolean; | ||||
} | } | ||||
export default class SearchShowMore extends React.PureComponent<Props> { | |||||
handleMoreClick = (event: React.MouseEvent<HTMLAnchorElement>) => { | |||||
export default class GlobalSearchShowMore extends React.PureComponent<Props> { | |||||
handleMoreClick = (event: React.MouseEvent<HTMLButtonElement>, qualifier: string) => { | |||||
event.preventDefault(); | event.preventDefault(); | ||||
event.stopPropagation(); | event.stopPropagation(); | ||||
event.currentTarget.blur(); | event.currentTarget.blur(); | ||||
const { qualifier } = event.currentTarget.dataset; | |||||
if (qualifier) { | if (qualifier) { | ||||
this.props.onMoreClick(qualifier); | this.props.onMoreClick(qualifier); | ||||
} | } | ||||
}; | }; | ||||
handleMoreMouseEnter = (event: React.MouseEvent<HTMLAnchorElement>) => { | |||||
const { qualifier } = event.currentTarget.dataset; | |||||
handleMouseEnter = (qualifier: string) => { | |||||
if (qualifier) { | if (qualifier) { | ||||
this.props.onSelect(`qualifier###${qualifier}`); | this.props.onSelect(`qualifier###${qualifier}`); | ||||
} | } | ||||
}; | }; | ||||
render() { | render() { | ||||
const { loadingMore, qualifier, selected } = this.props; | |||||
const { loadingMore, qualifier, selected, allowMore } = this.props; | |||||
return ( | return ( | ||||
<li className={classNames('menu-footer', { active: selected })} key={`more-${qualifier}`}> | |||||
<DeferredSpinner className="navbar-search-icon" loading={loadingMore === qualifier}> | |||||
<a | |||||
className={classNames({ 'cursor-not-allowed': !this.props.allowMore })} | |||||
data-qualifier={qualifier} | |||||
href="#" | |||||
onClick={this.handleMoreClick} | |||||
onMouseEnter={this.handleMoreMouseEnter} | |||||
> | |||||
<div className="pull-right text-muted-2 menu-footer-note"> | |||||
<FormattedMessage | |||||
defaultMessage={translate('search.show_more.hint')} | |||||
id="search.show_more.hint" | |||||
values={{ | |||||
key: <span className="shortcut-button shortcut-button-small">Enter</span>, | |||||
}} | |||||
/> | |||||
</div> | |||||
<span>{translate('show_more')}</span> | |||||
</a> | |||||
<ItemButton | |||||
className={classNames({ active: selected })} | |||||
disabled={!allowMore} | |||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => this.handleMoreClick(e, qualifier)} | |||||
onPointerEnter={() => { | |||||
this.handleMouseEnter(qualifier); | |||||
}} | |||||
> | |||||
<DeferredSpinner loading={loadingMore === qualifier}> | |||||
{translate('show_more')} | |||||
</DeferredSpinner> | </DeferredSpinner> | ||||
</li> | |||||
</ItemButton> | |||||
); | ); | ||||
} | } | ||||
} | } |
/* | |||||
* 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 userEvent from '@testing-library/user-event'; | |||||
import React from 'react'; | |||||
import { byRole, byText } from 'testing-library-selector'; | |||||
import { getSuggestions } from '../../../../api/components'; | |||||
import { mockRouter } from '../../../../helpers/testMocks'; | |||||
import { renderComponent } from '../../../../helpers/testReactTestingUtils'; | |||||
import GlobalSearch, { GlobalSearch as GlobalSearchWithoutRouter } from '../GlobalSearch'; | |||||
jest.mock('../../../../api/components', () => ({ | |||||
getSuggestions: jest.fn().mockResolvedValue({ | |||||
results: [ | |||||
{ | |||||
q: 'TRK', | |||||
more: 1, | |||||
items: [ | |||||
{ | |||||
isFavorite: true, | |||||
isRecentlyBrowsed: true, | |||||
key: 'sonarqube', | |||||
match: 'SonarQube', | |||||
name: 'SonarQube', | |||||
project: '', | |||||
}, | |||||
{ | |||||
isFavorite: false, | |||||
isRecentlyBrowsed: false, | |||||
key: 'sonarcloud', | |||||
match: 'Sonarcloud', | |||||
name: 'Sonarcloud', | |||||
project: '', | |||||
}, | |||||
], | |||||
}, | |||||
], | |||||
}), | |||||
})); | |||||
const ui = { | |||||
searchButton: byRole('button', { name: 'search_verb' }), | |||||
searchInput: byRole('searchbox'), | |||||
searchItemListWrapper: byRole('menu'), | |||||
searchItem: byRole('menuitem'), | |||||
showMoreButton: byRole('menuitem', { name: 'show_more' }), | |||||
tooShortWarning: byText('select2.tooShort.2'), | |||||
noResultTextABCD: byText('no_results_for_x.abcd'), | |||||
}; | |||||
it('should show the input when user click on the search icon', async () => { | |||||
const user = userEvent.setup(); | |||||
renderGlobalSearch(); | |||||
expect(ui.searchButton.get()).toBeInTheDocument(); | |||||
await user.click(ui.searchButton.get()); | |||||
expect(ui.searchInput.get()).toBeVisible(); | |||||
expect(ui.searchItemListWrapper.get()).toBeVisible(); | |||||
await user.click(document.body); | |||||
expect(ui.searchInput.query()).not.toBeInTheDocument(); | |||||
expect(ui.searchItemListWrapper.query()).not.toBeInTheDocument(); | |||||
}); | |||||
it('selects the results', async () => { | |||||
const user = userEvent.setup(); | |||||
renderGlobalSearch(); | |||||
await user.click(ui.searchButton.get()); | |||||
await user.click(ui.searchInput.get()); | |||||
await user.keyboard('son'); | |||||
expect(ui.searchItem.getAll()[1]).toHaveClass('active'); | |||||
expect(ui.searchItem.getAll()[1]).toHaveTextContent('SonarQubesonarqube'); | |||||
await user.keyboard('{arrowdown}'); | |||||
expect(ui.searchItem.getAll()[2]).toHaveClass('active'); | |||||
expect(ui.searchItem.getAll()[2]).toHaveTextContent('Sonarcloudsonarcloud'); | |||||
await user.keyboard('{arrowdown}'); | |||||
expect(ui.searchItem.getAll()[3]).toHaveClass('active'); | |||||
expect(ui.searchItem.getAll()[3]).toHaveTextContent('show_more'); | |||||
await user.keyboard('{arrowup}'); | |||||
expect(ui.searchItem.getAll()[2]).toHaveClass('active'); | |||||
expect(ui.searchItem.getAll()[2]).toHaveTextContent('Sonarcloudsonarcloud'); | |||||
await user.hover(ui.searchItem.getAll()[1]); | |||||
expect(ui.searchItem.getAll()[1]).toHaveClass('active'); | |||||
await user.keyboard('{Escape}'); | |||||
expect(ui.searchInput.query()).not.toBeInTheDocument(); | |||||
}); | |||||
it('load more results', async () => { | |||||
const user = userEvent.setup(); | |||||
renderGlobalSearch(); | |||||
await user.click(ui.searchButton.get()); | |||||
expect(getSuggestions).toHaveBeenCalledWith('', []); | |||||
await user.click(ui.searchInput.get()); | |||||
await user.keyboard('foo'); | |||||
expect(getSuggestions).toHaveBeenLastCalledWith('foo', []); | |||||
(getSuggestions as jest.Mock).mockResolvedValueOnce({ | |||||
results: [ | |||||
{ | |||||
items: [ | |||||
{ | |||||
isFavorite: false, | |||||
isRecentlyBrowsed: false, | |||||
key: 'bar', | |||||
match: '<mark>Bar</mark>', | |||||
name: 'Bar', | |||||
organization: 'org', | |||||
project: 'bar', | |||||
}, | |||||
], | |||||
more: 0, | |||||
q: 'TRK', | |||||
}, | |||||
], | |||||
}); | |||||
await user.click(ui.showMoreButton.get()); | |||||
expect(getSuggestions).toHaveBeenLastCalledWith('foo', [], 'TRK'); | |||||
expect(ui.searchItem.getAll()[3]).toHaveTextContent('Barbar'); | |||||
}); | |||||
it('shows warning about short input', async () => { | |||||
const user = userEvent.setup(); | |||||
renderGlobalSearch(); | |||||
await user.click(ui.searchButton.get()); | |||||
await user.click(ui.searchInput.get()); | |||||
await user.keyboard('s'); | |||||
expect(ui.tooShortWarning.get()).toBeVisible(); | |||||
await user.keyboard('abc'); | |||||
expect(ui.tooShortWarning.query()).not.toBeInTheDocument(); | |||||
}); | |||||
it('should display no results message', async () => { | |||||
const user = userEvent.setup(); | |||||
renderGlobalSearch(); | |||||
(getSuggestions as jest.Mock).mockResolvedValue({ | |||||
results: [ | |||||
{ | |||||
items: [], | |||||
more: 0, | |||||
q: 'TRK', | |||||
}, | |||||
], | |||||
}); | |||||
await user.click(ui.searchButton.get()); | |||||
await user.click(ui.searchInput.get()); | |||||
await user.keyboard('abcd'); | |||||
expect(ui.noResultTextABCD.get()).toBeVisible(); | |||||
}); | |||||
it('should open selected', async () => { | |||||
(getSuggestions as jest.Mock).mockResolvedValueOnce({ | |||||
results: [ | |||||
{ | |||||
items: [ | |||||
{ | |||||
isFavorite: true, | |||||
isRecentlyBrowsed: true, | |||||
key: 'sonarqube', | |||||
match: 'SonarQube', | |||||
name: 'SonarQube', | |||||
project: '', | |||||
}, | |||||
], | |||||
more: 0, | |||||
q: 'TRK', | |||||
}, | |||||
], | |||||
}); | |||||
const user = userEvent.setup(); | |||||
const router = mockRouter(); | |||||
renderComponent(<GlobalSearchWithoutRouter router={router} />); | |||||
await user.click(ui.searchButton.get()); | |||||
await user.click(ui.searchInput.get()); | |||||
await user.keyboard('{arrowdown}'); | |||||
await user.keyboard('{enter}'); | |||||
expect(router.push).toHaveBeenCalledWith({ | |||||
pathname: '/dashboard', | |||||
search: '?id=sonarqube', | |||||
}); | |||||
}); | |||||
function renderGlobalSearch() { | |||||
return renderComponent(<GlobalSearch />); | |||||
} |
/* | |||||
* 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. | |||||
*/ | |||||
.global-navbar, | |||||
.global-navbar .global-navbar-inner { | |||||
background-color: var(--globalNavBarBg); | |||||
z-index: 421; | |||||
} | |||||
.global-navbar .navbar-limited { | |||||
display: flex; | |||||
} | |||||
.global-navbar { | |||||
position: fixed; | |||||
width: 100%; | |||||
} | |||||
.global-navbar .global-navbar-inner { | |||||
position: static; | |||||
display: flex; | |||||
max-width: var(--maxPageWidth); | |||||
min-width: var(--minPageWidth); | |||||
padding-left: var(--pagePadding); | |||||
padding-right: var(--pagePadding); | |||||
margin-left: auto; | |||||
margin-right: auto; | |||||
} | |||||
.navbar-brand { | |||||
display: flex; | |||||
justify-content: center; | |||||
align-items: center; | |||||
height: var(--globalNavHeight); | |||||
margin-left: calc(-1 * (var(--globalNavHeight) - var(--globalNavContentHeight)) / 2); | |||||
padding-top: 4px; | |||||
padding-left: calc((var(--globalNavHeight) - var(--globalNavContentHeight)) / 2); | |||||
padding-right: calc((var(--globalNavHeight) - var(--globalNavContentHeight)) / 2); | |||||
border-bottom: 4px solid transparent; | |||||
} | |||||
.navbar-login { | |||||
margin-right: -10px; | |||||
} | |||||
.navbar-avatar { | |||||
margin-right: calc(-1 * (var(--globalNavHeight) - var(--globalNavContentHeight)) / 2); | |||||
padding: calc((var(--globalNavHeight) - var(--globalNavContentHeight)) / 2) !important; | |||||
border: none !important; | |||||
} | |||||
.navbar-icon { | |||||
display: inline-block; | |||||
height: var(--globalNavHeight); | |||||
padding: calc(var(--globalNavHeight) - var(--globalNavContentHeight)) 12px !important; | |||||
border-bottom: none !important; | |||||
color: #fff !important; | |||||
} | |||||
.navbar-plus { | |||||
margin-right: calc(-1 * var(--gridSize)); | |||||
position: relative; | |||||
z-index: var(--aboveNormalZIndex); | |||||
} | |||||
.global-navbar-menu { | |||||
display: flex; | |||||
align-items: center; | |||||
margin-left: auto; | |||||
height: var(--globalNavHeight); | |||||
} | |||||
.global-navbar-menu > li > a, | |||||
.global-navbar-menu .navbar-login { | |||||
display: block; | |||||
height: var(--globalNavHeight); | |||||
padding: calc((var(--globalNavHeight) - var(--globalNavContentHeight)) / 2) 10px; | |||||
line-height: var(--globalNavContentHeight); | |||||
border-bottom: 4px solid transparent; | |||||
box-sizing: border-box; | |||||
color: #ccc; | |||||
font-size: var(--baseFontSize); | |||||
letter-spacing: 0.05em; | |||||
white-space: nowrap; | |||||
} | |||||
.navbar-brand:hover, | |||||
.navbar-brand:focus, | |||||
.global-navbar-menu > li > a.active, | |||||
.global-navbar-menu > li > a:hover, | |||||
.global-navbar-menu > li > a:focus, | |||||
.navbar-login.active, | |||||
.navbar-login:hover, | |||||
.navbar-login:focus { | |||||
background-color: #020202; | |||||
border-bottom-color: var(--blue); | |||||
} | |||||
.global-navbar-menu-right { | |||||
flex: 1; | |||||
justify-content: flex-end; | |||||
margin-left: calc(5 * var(--gridSize)); | |||||
} | |||||
@media print { | |||||
.global-navbar { | |||||
display: none !important; | |||||
} | |||||
} |
import * as React from 'react'; | import * as React from 'react'; | ||||
import EmbedDocsPopupHelper from '../../../../components/embed-docs-modal/EmbedDocsPopupHelper'; | import EmbedDocsPopupHelper from '../../../../components/embed-docs-modal/EmbedDocsPopupHelper'; | ||||
import { CurrentUser } from '../../../../types/users'; | import { CurrentUser } from '../../../../types/users'; | ||||
import { sizes } from '../../../theme'; | |||||
import withCurrentUserContext from '../../current-user/withCurrentUserContext'; | import withCurrentUserContext from '../../current-user/withCurrentUserContext'; | ||||
import Search from '../../search/Search'; | |||||
import './GlobalNav.css'; | |||||
import GlobalNavBranding from './GlobalNavBranding'; | |||||
import GlobalSearch from '../../global-search/GlobalSearch'; | |||||
import GlobalNavMenu from './GlobalNavMenu'; | import GlobalNavMenu from './GlobalNavMenu'; | ||||
import GlobalNavUser from './GlobalNavUser'; | |||||
import { GlobalNavUser } from './GlobalNavUser'; | |||||
import MainSonarQubeBar from './MainSonarQubeBar'; | |||||
export interface GlobalNavProps { | export interface GlobalNavProps { | ||||
currentUser: CurrentUser; | currentUser: CurrentUser; | ||||
export function GlobalNav(props: GlobalNavProps) { | export function GlobalNav(props: GlobalNavProps) { | ||||
const { currentUser, location } = props; | const { currentUser, location } = props; | ||||
return ( | return ( | ||||
<div style={{ height: sizes.globalNavHeight }}> | |||||
<div className="navbar global-navbar" id="global-navigation"> | |||||
<div className="global-navbar-inner"> | |||||
<GlobalNavBranding /> | |||||
<MainSonarQubeBar> | |||||
<div className="sw-flex" id="global-navigation"> | |||||
<div className="it__global-navbar-menu sw-flex sw-justify-start sw-items-center sw-flex-1"> | |||||
<GlobalNavMenu currentUser={currentUser} location={location} /> | <GlobalNavMenu currentUser={currentUser} location={location} /> | ||||
<div className="sw-px-8 sw-flex-1"> | |||||
<GlobalSearch /> | |||||
</div> | |||||
</div> | |||||
<div className="global-navbar-menu global-navbar-menu-right"> | |||||
<EmbedDocsPopupHelper /> | |||||
<Search /> | |||||
<GlobalNavUser currentUser={currentUser} /> | |||||
<div className="sw-flex sw-items-center sw-ml-2"> | |||||
<EmbedDocsPopupHelper /> | |||||
<div className="sw-ml-4"> | |||||
<GlobalNavUser /> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | |||||
</MainSonarQubeBar> | |||||
); | ); | ||||
} | } | ||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
*/ | */ | ||||
import classNames from 'classnames'; | import classNames from 'classnames'; | ||||
import { MainMenu, MainMenuItem } from 'design-system'; | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { NavLink } from 'react-router-dom'; | import { NavLink } from 'react-router-dom'; | ||||
import { isMySet } from '../../../../apps/issues/utils'; | import { isMySet } from '../../../../apps/issues/utils'; | ||||
import Link from '../../../../components/common/Link'; | import Link from '../../../../components/common/Link'; | ||||
import Dropdown from '../../../../components/controls/Dropdown'; | |||||
import DropdownIcon from '../../../../components/icons/DropdownIcon'; | |||||
import { translate } from '../../../../helpers/l10n'; | import { translate } from '../../../../helpers/l10n'; | ||||
import { getQualityGatesUrl } from '../../../../helpers/urls'; | import { getQualityGatesUrl } from '../../../../helpers/urls'; | ||||
import { AppState } from '../../../../types/appstate'; | import { AppState } from '../../../../types/appstate'; | ||||
import { ComponentQualifier } from '../../../../types/component'; | import { ComponentQualifier } from '../../../../types/component'; | ||||
import { Extension } from '../../../../types/types'; | |||||
import { CurrentUser } from '../../../../types/users'; | import { CurrentUser } from '../../../../types/users'; | ||||
import withAppStateContext from '../../app-state/withAppStateContext'; | import withAppStateContext from '../../app-state/withAppStateContext'; | ||||
import GlobalNavMore from './GlobalNavMore'; | |||||
interface Props { | interface Props { | ||||
appState: AppState; | appState: AppState; | ||||
} | } | ||||
const ACTIVE_CLASS_NAME = 'active'; | const ACTIVE_CLASS_NAME = 'active'; | ||||
export class GlobalNavMenu extends React.PureComponent<Props> { | |||||
class GlobalNavMenu extends React.PureComponent<Props> { | |||||
renderProjects() { | renderProjects() { | ||||
const active = | const active = | ||||
this.props.location.pathname.startsWith('/projects') && | this.props.location.pathname.startsWith('/projects') && | ||||
this.props.location.pathname !== '/projects/create'; | this.props.location.pathname !== '/projects/create'; | ||||
return ( | return ( | ||||
<li> | |||||
<MainMenuItem> | |||||
<Link | <Link | ||||
aria-current={active ? 'page' : undefined} | aria-current={active ? 'page' : undefined} | ||||
className={classNames({ active })} | className={classNames({ active })} | ||||
> | > | ||||
{translate('projects.page')} | {translate('projects.page')} | ||||
</Link> | </Link> | ||||
</li> | |||||
</MainMenuItem> | |||||
); | ); | ||||
} | } | ||||
renderPortfolios() { | renderPortfolios() { | ||||
return ( | return ( | ||||
<li> | |||||
<MainMenuItem> | |||||
<NavLink className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} to="/portfolios"> | <NavLink className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} to="/portfolios"> | ||||
{translate('portfolios.page')} | {translate('portfolios.page')} | ||||
</NavLink> | </NavLink> | ||||
</li> | |||||
</MainMenuItem> | |||||
); | ); | ||||
} | } | ||||
).toString(); | ).toString(); | ||||
return ( | return ( | ||||
<li> | |||||
<MainMenuItem> | |||||
<NavLink | <NavLink | ||||
className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} | className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} | ||||
to={{ pathname: '/issues', search }} | to={{ pathname: '/issues', search }} | ||||
> | > | ||||
{translate('issues.page')} | {translate('issues.page')} | ||||
</NavLink> | </NavLink> | ||||
</li> | |||||
</MainMenuItem> | |||||
); | ); | ||||
} | } | ||||
renderRulesLink() { | renderRulesLink() { | ||||
return ( | return ( | ||||
<li> | |||||
<MainMenuItem> | |||||
<NavLink | <NavLink | ||||
className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} | className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} | ||||
to="/coding_rules" | to="/coding_rules" | ||||
> | > | ||||
{translate('coding_rules.page')} | {translate('coding_rules.page')} | ||||
</NavLink> | </NavLink> | ||||
</li> | |||||
</MainMenuItem> | |||||
); | ); | ||||
} | } | ||||
renderProfilesLink() { | renderProfilesLink() { | ||||
return ( | return ( | ||||
<li> | |||||
<MainMenuItem> | |||||
<NavLink className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} to="/profiles"> | <NavLink className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} to="/profiles"> | ||||
{translate('quality_profiles.page')} | {translate('quality_profiles.page')} | ||||
</NavLink> | </NavLink> | ||||
</li> | |||||
</MainMenuItem> | |||||
); | ); | ||||
} | } | ||||
renderQualityGatesLink() { | renderQualityGatesLink() { | ||||
return ( | return ( | ||||
<li> | |||||
<MainMenuItem> | |||||
<NavLink | <NavLink | ||||
className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} | className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} | ||||
to={getQualityGatesUrl()} | to={getQualityGatesUrl()} | ||||
> | > | ||||
{translate('quality_gates.page')} | {translate('quality_gates.page')} | ||||
</NavLink> | </NavLink> | ||||
</li> | |||||
</MainMenuItem> | |||||
); | ); | ||||
} | } | ||||
} | } | ||||
return ( | return ( | ||||
<li> | |||||
<MainMenuItem> | |||||
<NavLink | <NavLink | ||||
className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} | className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} | ||||
to="/admin/settings" | to="/admin/settings" | ||||
> | > | ||||
{translate('layout.settings')} | {translate('layout.settings')} | ||||
</NavLink> | </NavLink> | ||||
</li> | |||||
); | |||||
} | |||||
renderGlobalPageLink = ({ key, name }: Extension) => { | |||||
return ( | |||||
<li key={key}> | |||||
<Link to={`/extension/${key}`}>{name}</Link> | |||||
</li> | |||||
); | |||||
}; | |||||
renderMore() { | |||||
const { globalPages = [] } = this.props.appState; | |||||
const withoutPortfolios = globalPages.filter((page) => page.key !== 'governance/portfolios'); | |||||
if (withoutPortfolios.length === 0) { | |||||
return null; | |||||
} | |||||
return ( | |||||
<Dropdown | |||||
overlay={<ul className="menu">{withoutPortfolios.map(this.renderGlobalPageLink)}</ul>} | |||||
tagName="li" | |||||
> | |||||
{({ onToggleClick, open }) => ( | |||||
<a | |||||
aria-expanded={open} | |||||
aria-haspopup="menu" | |||||
role="button" | |||||
className={classNames('dropdown-toggle', { active: open })} | |||||
href="#" | |||||
id="global-navigation-more" | |||||
onClick={onToggleClick} | |||||
> | |||||
{translate('more')} | |||||
<DropdownIcon className="little-spacer-left text-middle" /> | |||||
</a> | |||||
)} | |||||
</Dropdown> | |||||
</MainMenuItem> | |||||
); | ); | ||||
} | } | ||||
return ( | return ( | ||||
<nav aria-label={translate('global')}> | <nav aria-label={translate('global')}> | ||||
<ul className="global-navbar-menu"> | |||||
<MainMenu> | |||||
{this.renderProjects()} | {this.renderProjects()} | ||||
{governanceInstalled && this.renderPortfolios()} | {governanceInstalled && this.renderPortfolios()} | ||||
{this.renderIssuesLink()} | {this.renderIssuesLink()} | ||||
{this.renderProfilesLink()} | {this.renderProfilesLink()} | ||||
{this.renderQualityGatesLink()} | {this.renderQualityGatesLink()} | ||||
{this.renderAdministrationLink()} | {this.renderAdministrationLink()} | ||||
{this.renderMore()} | |||||
</ul> | |||||
<GlobalNavMore /> | |||||
</MainMenu> | |||||
</nav> | </nav> | ||||
); | ); | ||||
} | } |
/* | |||||
* 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 { Dropdown, ItemNavLink, MainMenuItem, PopupPlacement } from 'design-system'; | |||||
import * as React from 'react'; | |||||
import { translate } from '../../../../helpers/l10n'; | |||||
import { AppState } from '../../../../types/appstate'; | |||||
import { Extension } from '../../../../types/types'; | |||||
import withAppStateContext from '../../app-state/withAppStateContext'; | |||||
const renderGlobalPageLink = ({ key, name }: Extension) => { | |||||
return ( | |||||
<ItemNavLink key={key} to={`/extension/${key}`}> | |||||
{name} | |||||
</ItemNavLink> | |||||
); | |||||
}; | |||||
function GlobalNavMore({ appState: { globalPages = [] } }: { appState: AppState }) { | |||||
const withoutPortfolios = globalPages.filter((page) => page.key !== 'governance/portfolios'); | |||||
if (withoutPortfolios.length === 0) { | |||||
return null; | |||||
} | |||||
return ( | |||||
<Dropdown | |||||
id="moreMenuDropdown" | |||||
overlay={<ul>{withoutPortfolios.map(renderGlobalPageLink)}</ul>} | |||||
placement={PopupPlacement.BottomLeft} | |||||
> | |||||
{({ onToggleClick, open }) => ( | |||||
<ul> | |||||
<MainMenuItem> | |||||
<a | |||||
aria-expanded={open} | |||||
aria-haspopup="menu" | |||||
href="#" | |||||
id="global-navigation-more" | |||||
onClick={onToggleClick} | |||||
role="button" | |||||
> | |||||
{translate('more')} | |||||
</a> | |||||
</MainMenuItem> | |||||
</ul> | |||||
)} | |||||
</Dropdown> | |||||
); | |||||
} | |||||
export default withAppStateContext(GlobalNavMore); |
* along with this program; if not, write to the Free Software Foundation, | * along with this program; if not, write to the Free Software Foundation, | ||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
*/ | */ | ||||
import { | |||||
Avatar, | |||||
BareButton, | |||||
ButtonSecondary, | |||||
Dropdown, | |||||
PopupPlacement, | |||||
PopupZLevel, | |||||
Tooltip, | |||||
} from 'design-system'; | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import Link from '../../../../components/common/Link'; | |||||
import Dropdown from '../../../../components/controls/Dropdown'; | |||||
import { Router, withRouter } from '../../../../components/hoc/withRouter'; | |||||
import Avatar from '../../../../components/ui/Avatar'; | |||||
import { translate } from '../../../../helpers/l10n'; | import { translate } from '../../../../helpers/l10n'; | ||||
import { getBaseUrl } from '../../../../helpers/system'; | import { getBaseUrl } from '../../../../helpers/system'; | ||||
import { CurrentUser, isLoggedIn, LoggedInUser } from '../../../../types/users'; | |||||
import { rawSizes } from '../../../theme'; | |||||
import { GlobalSettingKeys } from '../../../../types/settings'; | |||||
import { isLoggedIn } from '../../../../types/users'; | |||||
import { AppStateContext } from '../../app-state/AppStateContext'; | |||||
import { CurrentUserContext } from '../../current-user/CurrentUserContext'; | |||||
import { GlobalNavUserMenu } from './GlobalNavUserMenu'; | |||||
interface Props { | |||||
currentUser: CurrentUser; | |||||
router: Router; | |||||
} | |||||
export function GlobalNavUser() { | |||||
const userContext = React.useContext(CurrentUserContext); | |||||
const currentUser = userContext?.currentUser; | |||||
export class GlobalNavUser extends React.PureComponent<Props> { | |||||
focusNode = (node: HTMLAnchorElement | null) => { | |||||
if (node) { | |||||
node.focus(); | |||||
} | |||||
}; | |||||
const { settings } = React.useContext(AppStateContext); | |||||
handleLogin = (event: React.SyntheticEvent<HTMLAnchorElement>) => { | |||||
event.preventDefault(); | |||||
const handleLogin = React.useCallback(() => { | |||||
const returnTo = encodeURIComponent(window.location.pathname + window.location.search); | const returnTo = encodeURIComponent(window.location.pathname + window.location.search); | ||||
window.location.href = `${getBaseUrl()}/sessions/new?return_to=${returnTo}${ | window.location.href = `${getBaseUrl()}/sessions/new?return_to=${returnTo}${ | ||||
window.location.hash | window.location.hash | ||||
}`; | }`; | ||||
}; | |||||
handleLogout = (event: React.SyntheticEvent<HTMLAnchorElement>) => { | |||||
event.preventDefault(); | |||||
this.props.router.push('/sessions/logout'); | |||||
}; | |||||
}, []); | |||||
renderAuthenticated() { | |||||
const currentUser = this.props.currentUser as LoggedInUser; | |||||
return ( | |||||
<Dropdown | |||||
className="js-user-authenticated" | |||||
overlay={ | |||||
<ul className="menu"> | |||||
<li className="menu-item"> | |||||
<div className="text-ellipsis text-muted" title={currentUser.name}> | |||||
<strong>{currentUser.name}</strong> | |||||
</div> | |||||
{currentUser.email != null && ( | |||||
<div | |||||
className="little-spacer-top text-ellipsis text-muted" | |||||
title={currentUser.email} | |||||
> | |||||
{currentUser.email} | |||||
</div> | |||||
)} | |||||
</li> | |||||
<li className="divider" /> | |||||
<li> | |||||
<Link ref={this.focusNode} to="/account"> | |||||
{translate('my_account.page')} | |||||
</Link> | |||||
</li> | |||||
<li> | |||||
<a href="#" onClick={this.handleLogout}> | |||||
{translate('layout.logout')} | |||||
</a> | |||||
</li> | |||||
</ul> | |||||
} | |||||
> | |||||
<a className="dropdown-toggle navbar-avatar" href="#" title={currentUser.name}> | |||||
<Avatar | |||||
hash={currentUser.avatar} | |||||
name={currentUser.name} | |||||
size={rawSizes.globalNavContentHeightRaw} | |||||
/> | |||||
</a> | |||||
</Dropdown> | |||||
); | |||||
} | |||||
renderAnonymous() { | |||||
if (!currentUser || !isLoggedIn(currentUser)) { | |||||
return ( | return ( | ||||
<div> | <div> | ||||
<Link className="navbar-login" to="/sessions/new" onClick={this.handleLogin}> | |||||
{translate('layout.login')} | |||||
</Link> | |||||
<ButtonSecondary onClick={handleLogin}>{translate('layout.login')}</ButtonSecondary> | |||||
</div> | </div> | ||||
); | ); | ||||
} | } | ||||
render() { | |||||
return isLoggedIn(this.props.currentUser) ? this.renderAuthenticated() : this.renderAnonymous(); | |||||
} | |||||
} | |||||
const enableGravatar = settings[GlobalSettingKeys.EnableGravatar] === 'true'; | |||||
const gravatarServerUrl = settings[GlobalSettingKeys.GravatarServerUrl] ?? ''; | |||||
export default withRouter(GlobalNavUser); | |||||
return ( | |||||
<Dropdown | |||||
id="userAccountMenuDropdown" | |||||
placement={PopupPlacement.BottomRight} | |||||
zLevel={PopupZLevel.Global} | |||||
overlay={<GlobalNavUserMenu currentUser={currentUser} />} | |||||
> | |||||
{({ a11yAttrs: { role, ...a11yAttrs }, onToggleClick, open }) => ( | |||||
<Tooltip | |||||
mouseEnterDelay={0.2} | |||||
overlay={translate('global_nav.account.tooltip')} | |||||
visible={open ? false : undefined} | |||||
> | |||||
<BareButton | |||||
aria-label={translate('global_nav.account.tooltip')} | |||||
onClick={onToggleClick} | |||||
{...a11yAttrs} | |||||
> | |||||
<Avatar | |||||
enableGravatar={enableGravatar} | |||||
gravatarServerUrl={gravatarServerUrl} | |||||
hash={currentUser.avatar} | |||||
name={currentUser.name} | |||||
/> | |||||
</BareButton> | |||||
</Tooltip> | |||||
)} | |||||
</Dropdown> | |||||
); | |||||
} |
/* | |||||
* 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 { | |||||
ItemButton, | |||||
ItemDivider, | |||||
ItemHeader, | |||||
ItemHeaderHighlight, | |||||
ItemNavLink, | |||||
} from 'design-system'; | |||||
import * as React from 'react'; | |||||
import { useNavigate } from 'react-router-dom'; | |||||
import { translate } from '../../../../helpers/l10n'; | |||||
import { LoggedInUser } from '../../../../types/users'; | |||||
interface UserAccountMenuProps { | |||||
currentUser: LoggedInUser; | |||||
} | |||||
export function GlobalNavUserMenu({ currentUser }: UserAccountMenuProps) { | |||||
const navigateTo = useNavigate(); | |||||
const firstItemRef = React.useRef<HTMLAnchorElement>(null); | |||||
const handleLogout = React.useCallback(() => { | |||||
navigateTo('/sessions/logout'); | |||||
}, [navigateTo]); | |||||
React.useEffect(() => { | |||||
firstItemRef.current?.focus(); | |||||
}, [firstItemRef]); | |||||
return ( | |||||
<> | |||||
<ItemHeader> | |||||
<ItemHeaderHighlight title={currentUser.name}>{currentUser.name}</ItemHeaderHighlight> | |||||
{currentUser.email != null && ( | |||||
<div className="sw-mt-1" title={currentUser.email}> | |||||
{currentUser.email} | |||||
</div> | |||||
)} | |||||
</ItemHeader> | |||||
<ItemDivider /> | |||||
<ItemNavLink end={true} to="/account" innerRef={firstItemRef}> | |||||
{translate('my_account.page')} | |||||
</ItemNavLink> | |||||
<ItemDivider /> | |||||
<ItemButton onClick={handleLogout}>{translate('layout.logout')}</ItemButton> | |||||
</> | |||||
); | |||||
} |