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