]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-18524 New Main App bar
authorJeremy Davis <jeremy.davis@sonarsource.com>
Wed, 22 Feb 2023 15:18:48 +0000 (16:18 +0100)
committersonartech <sonartech@sonarsource.com>
Mon, 13 Mar 2023 20:02:44 +0000 (20:02 +0000)
133 files changed:
.cirrus.yml
server/sonar-web/build.gradle
server/sonar-web/config/jest/SetupTheme.js [new file with mode: 0644]
server/sonar-web/design-system/babel.config.js
server/sonar-web/design-system/build.gradle [new file with mode: 0644]
server/sonar-web/design-system/config/jest/SetupReactTestingLibrary.ts [new file with mode: 0644]
server/sonar-web/design-system/config/jest/SetupTestEnvironment.js [new file with mode: 0644]
server/sonar-web/design-system/config/jest/SetupTheme.js [new file with mode: 0644]
server/sonar-web/design-system/jest.config.js [new file with mode: 0644]
server/sonar-web/design-system/package.json
server/sonar-web/design-system/src/@types/css.d.ts [new file with mode: 0644]
server/sonar-web/design-system/src/@types/emotion.d.ts [new file with mode: 0644]
server/sonar-web/design-system/src/components/Avatar.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/Checkbox.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/ClickEventBoundary.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/DeferredSpinner.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/Dropdown.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/DropdownMenu.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/DropdownToggler.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/DummyComponent.tsx [deleted file]
server/sonar-web/design-system/src/components/EscKeydownHandler.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/GenericAvatar.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/InputSearch.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/InteractiveIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/Link.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/MainAppBar.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/MainMenu.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/MainMenuItem.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/NavLink.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/OutsideClickHandler.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/RadioButton.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/SonarQubeLogo.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/Text.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/Tooltip.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/Link-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/Text-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/clipboard.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/CheckIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/ClockIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/CloseIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/CopyIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/Icon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/MenuIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/SearchIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/StarIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/index.ts [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/design-system/src/components/popups.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/colors.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/constants.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/index.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/keyboard.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/l10n.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/positioning.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/testUtils.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/theme.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/types.ts [new file with mode: 0644]
server/sonar-web/design-system/src/index.ts [new file with mode: 0644]
server/sonar-web/design-system/src/theme/colors.ts [new file with mode: 0644]
server/sonar-web/design-system/src/theme/index.ts [new file with mode: 0644]
server/sonar-web/design-system/src/theme/light.ts [new file with mode: 0644]
server/sonar-web/design-system/src/types/misc.ts [new file with mode: 0644]
server/sonar-web/design-system/src/types/theme.ts [new file with mode: 0644]
server/sonar-web/design-system/tsconfig.json
server/sonar-web/design-system/vite.config.js
server/sonar-web/jest.config.js
server/sonar-web/package.json
server/sonar-web/src/main/js/app/components/GlobalContainer.tsx
server/sonar-web/src/main/js/app/components/SimpleContainer.tsx
server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResult.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResults.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/global-search/GlobalSearchShowMore.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/global-search/__tests__/GlobalSearch-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/global-search/utils.ts [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css [deleted file]
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMore.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.tsx
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUserMenu.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/global/MainSonarQubeBar.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavBranding-test.tsx [deleted file]
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavMenu-test.tsx
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavUser-test.tsx
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavBranding-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/app/components/search/Search.css [deleted file]
server/sonar-web/src/main/js/app/components/search/Search.tsx [deleted file]
server/sonar-web/src/main/js/app/components/search/SearchResult.tsx [deleted file]
server/sonar-web/src/main/js/app/components/search/SearchResults.tsx [deleted file]
server/sonar-web/src/main/js/app/components/search/SearchShowMore.tsx [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.tsx [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.tsx [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.tsx [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/SearchShowMore-test.tsx [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchShowMore-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/app/components/search/utils.ts [deleted file]
server/sonar-web/src/main/js/app/utils/startReactApp.tsx
server/sonar-web/src/main/js/components/embed-docs-modal/DocItemLink.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx
server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopupHelper.tsx
server/sonar-web/src/main/js/components/embed-docs-modal/__tests__/EmbedDocsPopup-test.tsx
server/sonar-web/src/main/js/components/embed-docs-modal/__tests__/SuggestionsProvider-test.tsx [deleted file]
server/sonar-web/tailwind-utilities.js [new file with mode: 0644]
server/sonar-web/tailwind.base.config.js [new file with mode: 0644]
server/sonar-web/tailwind.config.js
server/sonar-web/yarn.lock
settings.gradle
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 0c5d418fe8941ab9e90dae79baf95309726ef8d5..9f0cddeecba0949d9b52b5733614dc38931601a9 100644 (file)
@@ -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/
index 77062307f76488e114503c85e8985d46c31cd1ec..c588b6e7bc15032eaf2a4747eca217a95f2ad905 100644 (file)
@@ -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)
diff --git a/server/sonar-web/config/jest/SetupTheme.js b/server/sonar-web/config/jest/SetupTheme.js
new file mode 100644 (file)
index 0000000..55c4548
--- /dev/null
@@ -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;
index 039a97f749dbd5bbf74586b7b06f663b12b0595c..1066ebdbecc4abf3105e962e3979105e318dcd87 100644 (file)
@@ -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',
   ],
 };
diff --git a/server/sonar-web/design-system/build.gradle b/server/sonar-web/design-system/build.gradle
new file mode 100644 (file)
index 0000000..24e1bbd
--- /dev/null
@@ -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'])
+}
\ No newline at end of file
diff --git a/server/sonar-web/design-system/config/jest/SetupReactTestingLibrary.ts b/server/sonar-web/design-system/config/jest/SetupReactTestingLibrary.ts
new file mode 100644 (file)
index 0000000..afaa0a4
--- /dev/null
@@ -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,
+});
diff --git a/server/sonar-web/design-system/config/jest/SetupTestEnvironment.js b/server/sonar-web/design-system/config/jest/SetupTestEnvironment.js
new file mode 100644 (file)
index 0000000..3c7139c
--- /dev/null
@@ -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;
+});
diff --git a/server/sonar-web/design-system/config/jest/SetupTheme.js b/server/sonar-web/design-system/config/jest/SetupTheme.js
new file mode 100644 (file)
index 0000000..ac30c5a
--- /dev/null
@@ -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;
diff --git a/server/sonar-web/design-system/jest.config.js b/server/sonar-web/design-system/jest.config.js
new file mode 100644 (file)
index 0000000..7da6e0a
--- /dev/null
@@ -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,
+};
index 11fbab61a09fcc80f4818a2ff9fef390aafe6bdf..ff2d3f8d8778e15c8ae7502d49f7d6124fb1c165 100644 (file)
@@ -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"
+    }
   }
 }
diff --git a/server/sonar-web/design-system/src/@types/css.d.ts b/server/sonar-web/design-system/src/@types/css.d.ts
new file mode 100644 (file)
index 0000000..446d5d0
--- /dev/null
@@ -0,0 +1,27 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as CSS from 'csstype';
+
+declare module 'csstype' {
+  interface Properties extends CSS.Properties {
+    // Support any CSS Custom Property in style prop of components
+    [index: `--${string}`]: string | number;
+  }
+}
diff --git a/server/sonar-web/design-system/src/@types/emotion.d.ts b/server/sonar-web/design-system/src/@types/emotion.d.ts
new file mode 100644 (file)
index 0000000..6ab3a1a
--- /dev/null
@@ -0,0 +1,25 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import '@emotion/react';
+import { Theme as SQTheme } from '../types/theme';
+
+declare module '@emotion/react' {
+  export interface Theme extends SQTheme {}
+}
diff --git a/server/sonar-web/design-system/src/components/Avatar.tsx b/server/sonar-web/design-system/src/components/Avatar.tsx
new file mode 100644 (file)
index 0000000..8b45429
--- /dev/null
@@ -0,0 +1,117 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import { ReactEventHandler, useState } from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor } from '../helpers/theme';
+import { GenericAvatar } from './GenericAvatar';
+
+type Size = 'xs' | 'sm' | 'md' | 'lg';
+
+const sizeMap: Record<Size, number> = {
+  xs: 16,
+  sm: 24,
+  md: 40,
+  lg: 64,
+};
+
+interface AvatarProps {
+  border?: boolean;
+  className?: string;
+  enableGravatar?: boolean;
+  gravatarServerUrl?: string;
+  hash?: string;
+  name?: string;
+  organizationAvatar?: string;
+  organizationName?: string;
+  size?: Size;
+}
+
+export function Avatar({
+  className,
+  enableGravatar,
+  gravatarServerUrl,
+  hash,
+  name,
+  organizationAvatar,
+  organizationName,
+  size = 'sm',
+  border,
+}: AvatarProps) {
+  const [imgError, setImgError] = useState(false);
+  const numberSize = sizeMap[size];
+  const resolvedName = organizationName ?? name;
+
+  const handleImgError: ReactEventHandler<HTMLImageElement> = () => {
+    setImgError(true);
+  };
+
+  if (!imgError) {
+    if (enableGravatar && gravatarServerUrl && hash) {
+      const url = gravatarServerUrl
+        .replace('{EMAIL_MD5}', hash)
+        .replace('{SIZE}', String(numberSize * 2));
+
+      return (
+        <StyledAvatar
+          alt={resolvedName}
+          border={border}
+          className={className}
+          height={numberSize}
+          onError={handleImgError}
+          role="img"
+          src={url}
+          width={numberSize}
+        />
+      );
+    }
+
+    if (resolvedName && organizationAvatar) {
+      return (
+        <StyledAvatar
+          alt={resolvedName}
+          border={border}
+          className={className}
+          height={numberSize}
+          onError={handleImgError}
+          role="img"
+          src={organizationAvatar}
+          width={numberSize}
+        />
+      );
+    }
+  }
+
+  if (!resolvedName) {
+    return <input className="sw-appearance-none" />;
+  }
+
+  return <GenericAvatar className={className} name={resolvedName} size={numberSize} />;
+}
+
+const StyledAvatar = styled.img<{ border?: boolean }>`
+  ${tw`sw-inline-flex`};
+  ${tw`sw-items-center`};
+  ${tw`sw-justify-center`};
+  ${tw`sw-align-top`};
+  ${tw`sw-rounded-1`};
+  border: ${({ border }) => (border ? themeBorder('default', 'avatarBorder') : '')};
+  background: ${themeColor('avatarBackground')};
+`;
diff --git a/server/sonar-web/design-system/src/components/Checkbox.tsx b/server/sonar-web/design-system/src/components/Checkbox.tsx
new file mode 100644 (file)
index 0000000..7e352d0
--- /dev/null
@@ -0,0 +1,174 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import DeferredSpinner from './DeferredSpinner';
+import CheckIcon from './icons/CheckIcon';
+import { CustomIcon } from './icons/Icon';
+
+interface Props {
+  checked: boolean;
+  children?: React.ReactNode;
+  className?: string;
+  disabled?: boolean;
+  id?: string;
+  loading?: boolean;
+  onCheck: (checked: boolean, id?: string) => void;
+  onClick?: (event: React.MouseEvent<HTMLInputElement>) => void;
+  onFocus?: VoidFunction;
+  right?: boolean;
+  thirdState?: boolean;
+  title?: string;
+}
+
+export default function Checkbox({
+  checked,
+  disabled,
+  children,
+  className,
+  id,
+  loading = false,
+  onCheck,
+  onFocus,
+  onClick,
+  right,
+  thirdState = false,
+  title,
+}: Props) {
+  const handleChange = () => {
+    if (!disabled) {
+      onCheck(!checked, id);
+    }
+  };
+
+  return (
+    <CheckboxContainer className={className} disabled={disabled}>
+      {right && children}
+      <AccessibleCheckbox
+        aria-label={title}
+        checked={checked}
+        disabled={disabled || loading}
+        id={id}
+        onChange={handleChange}
+        onClick={onClick}
+        onFocus={onFocus}
+        type="checkbox"
+      />
+      <DeferredSpinner loading={loading}>
+        <StyledCheckbox aria-hidden={true} data-clickable="true" title={title}>
+          <CheckboxIcon checked={checked} thirdState={thirdState} />
+        </StyledCheckbox>
+      </DeferredSpinner>
+      {!right && children}
+    </CheckboxContainer>
+  );
+}
+
+interface CheckIconProps {
+  checked?: boolean;
+  thirdState?: boolean;
+}
+
+function CheckboxIcon({ checked, thirdState }: CheckIconProps) {
+  if (checked && thirdState) {
+    return (
+      <CustomIcon>
+        <rect fill="currentColor" height="2" rx="1" width="50%" x="4" y="7" />
+      </CustomIcon>
+    );
+  } else if (checked) {
+    return <CheckIcon fill="currentColor" />;
+  }
+  return null;
+}
+
+const CheckboxContainer = styled.label<{ disabled?: boolean }>`
+  color: ${themeContrast('backgroundSecondary')};
+  user-select: none;
+
+  ${tw`sw-inline-flex sw-items-center`};
+
+  &:hover {
+    ${tw`sw-cursor-pointer`}
+  }
+
+  &:disabled {
+    color: ${themeContrast('checkboxDisabled')};
+    ${tw`sw-cursor-not-allowed`}
+  }
+`;
+
+export const StyledCheckbox = styled.span`
+  border: ${themeBorder('default', 'primary')};
+  color: ${themeContrast('primary')};
+
+  ${tw`sw-w-4 sw-h-4`};
+  ${tw`sw-rounded-1/2`};
+  ${tw`sw-box-border`}
+  ${tw`sw-inline-flex sw-items-center sw-justify-center`};
+`;
+
+export const AccessibleCheckbox = styled.input`
+  // Following css makes the checkbox accessible and invisible
+  border: 0;
+  clip: rect(0 0 0 0);
+  clip-path: inset(50%);
+  height: 1px;
+  overflow: hidden;
+  padding: 0;
+  white-space: nowrap;
+  width: 1px;
+
+  &:focus,
+  &:active {
+    &:not(:disabled) + ${StyledCheckbox} {
+      outline: ${themeBorder('focus', 'primary')};
+    }
+  }
+
+  &:checked {
+    & + ${StyledCheckbox} {
+      background: ${themeColor('primary')};
+    }
+    &:disabled + ${StyledCheckbox} {
+      background: ${themeColor('checkboxDisabledChecked')};
+    }
+  }
+
+  &:hover {
+    &:not(:disabled) + ${StyledCheckbox} {
+      background: ${themeColor('checkboxHover')};
+      border: ${themeBorder('default', 'primary')};
+    }
+
+    &:checked:not(:disabled) + ${StyledCheckbox} {
+      background: ${themeColor('checkboxCheckedHover')};
+      border: ${themeBorder('default', 'checkboxCheckedHover')};
+    }
+  }
+
+  &:disabled + ${StyledCheckbox} {
+    background: ${themeColor('checkboxDisabled')};
+    color: ${themeColor('checkboxDisabled')};
+    border: ${themeBorder('default', 'checkboxDisabledChecked')};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/ClickEventBoundary.tsx b/server/sonar-web/design-system/src/components/ClickEventBoundary.tsx
new file mode 100644 (file)
index 0000000..d2c5b85
--- /dev/null
@@ -0,0 +1,35 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+
+export interface ClickEventBoundaryProps {
+  children: React.ReactElement;
+}
+
+export default function ClickEventBoundary({ children }: ClickEventBoundaryProps) {
+  return React.cloneElement(children, {
+    onClick: (e: React.SyntheticEvent<MouseEvent>) => {
+      e.stopPropagation();
+      if (typeof children.props.onClick === 'function') {
+        children.props.onClick(e);
+      }
+    },
+  });
+}
diff --git a/server/sonar-web/design-system/src/components/DeferredSpinner.tsx b/server/sonar-web/design-system/src/components/DeferredSpinner.tsx
new file mode 100644 (file)
index 0000000..50df8bc
--- /dev/null
@@ -0,0 +1,138 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { keyframes } from '@emotion/react';
+import styled from '@emotion/styled';
+import React from 'react';
+import tw, { theme } from 'twin.macro';
+import { translate } from '../helpers/l10n';
+import { themeColor } from '../helpers/theme';
+import { InputSearchWrapper } from './InputSearch';
+
+interface Props {
+  children?: React.ReactNode;
+  className?: string;
+  customSpinner?: JSX.Element;
+  loading?: boolean;
+  placeholder?: boolean;
+  timeout?: number;
+}
+
+interface State {
+  showSpinner: boolean;
+}
+
+const DEFAULT_TIMEOUT = 100;
+
+export default class DeferredSpinner extends React.PureComponent<Props, State> {
+  timer?: number;
+
+  state: State = { showSpinner: false };
+
+  componentDidMount() {
+    if (this.props.loading == null || this.props.loading === true) {
+      this.startTimer();
+    }
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (prevProps.loading === false && this.props.loading === true) {
+      this.stopTimer();
+      this.startTimer();
+    }
+    if (prevProps.loading === true && this.props.loading === false) {
+      this.stopTimer();
+      this.setState({ showSpinner: false });
+    }
+  }
+
+  componentWillUnmount() {
+    this.stopTimer();
+  }
+
+  startTimer = () => {
+    this.timer = window.setTimeout(
+      () => this.setState({ showSpinner: true }),
+      this.props.timeout || DEFAULT_TIMEOUT
+    );
+  };
+
+  stopTimer = () => {
+    window.clearTimeout(this.timer);
+  };
+
+  render() {
+    const { showSpinner } = this.state;
+    const { customSpinner, className, children, placeholder } = this.props;
+    if (showSpinner) {
+      if (customSpinner) {
+        return customSpinner;
+      }
+      return <Spinner className={className} role="status" />;
+    }
+    if (children) {
+      return children;
+    }
+    if (placeholder) {
+      return <Placeholder className={className} />;
+    }
+    return null;
+  }
+}
+
+const spinAnimation = keyframes`
+  from {
+    transform: rotate(0deg);
+  }
+
+  to {
+    transform: rotate(-360deg);
+  }
+`;
+
+const Spinner = styled.div`
+  border: 2px solid transparent;
+  background: linear-gradient(0deg, ${themeColor('primary')} 50%, transparent 50% 100%) border-box,
+    linear-gradient(90deg, ${themeColor('primary')} 25%, transparent 75% 100%) border-box;
+  mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
+  -webkit-mask-composite: xor;
+  mask-composite: exclude;
+  animation: ${spinAnimation} 1s infinite linear;
+
+  ${tw`sw-h-4 sw-w-4`};
+  ${tw`sw-inline-block`};
+  ${tw`sw-box-border`};
+  ${tw`sw-rounded-pill`}
+
+  ${InputSearchWrapper}  & {
+    top: calc((2.25rem - ${theme('spacing.4')}) / 2);
+    ${tw`sw-left-3`};
+    ${tw`sw-absolute`};
+  }
+`;
+
+Spinner.defaultProps = { 'aria-label': translate('loading'), role: 'status' };
+
+const Placeholder = styled.div`
+  position: relative;
+  visibility: hidden;
+
+  ${tw`sw-inline-flex sw-items-center sw-justify-center`};
+  ${tw`sw-h-4 sw-w-4`};
+`;
diff --git a/server/sonar-web/design-system/src/components/Dropdown.tsx b/server/sonar-web/design-system/src/components/Dropdown.tsx
new file mode 100644 (file)
index 0000000..f04b595
--- /dev/null
@@ -0,0 +1,140 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import { translate } from '../helpers/l10n';
+import { PopupPlacement, PopupZLevel } from '../helpers/positioning';
+import { InputSizeKeys } from '../types/theme';
+import { DropdownMenu } from './DropdownMenu';
+import DropdownToggler from './DropdownToggler';
+import MenuIcon from './icons/MenuIcon';
+import { InteractiveIcon } from './InteractiveIcon';
+
+type OnClickCallback = (event?: React.MouseEvent<HTMLElement>) => void;
+type A11yAttrs = Pick<React.AriaAttributes, 'aria-controls' | 'aria-expanded' | 'aria-haspopup'> & {
+  id: string;
+  role: React.AriaRole;
+};
+interface RenderProps {
+  a11yAttrs: A11yAttrs;
+  closeDropdown: VoidFunction;
+  onToggleClick: OnClickCallback;
+  open: boolean;
+}
+
+interface Props {
+  allowResizing?: boolean;
+  children:
+    | ((renderProps: RenderProps) => JSX.Element)
+    | React.ReactElement<{ onClick: OnClickCallback }>;
+  className?: string;
+  closeOnClick?: boolean;
+  id: string;
+  onOpen?: VoidFunction;
+  overlay: React.ReactNode;
+  placement?: PopupPlacement;
+  size?: InputSizeKeys;
+  zLevel?: PopupZLevel;
+}
+
+interface State {
+  open: boolean;
+}
+
+export default class Dropdown extends React.PureComponent<Props, State> {
+  state: State = { open: false };
+
+  componentDidUpdate(_: Props, prevState: State) {
+    if (!prevState.open && this.state.open && this.props.onOpen) {
+      this.props.onOpen();
+    }
+  }
+
+  handleClose = () => {
+    this.setState({ open: false });
+  };
+
+  handleToggleClick: OnClickCallback = (event) => {
+    if (event) {
+      event.preventDefault();
+      event.currentTarget.blur();
+    }
+    this.setState((state) => ({ open: !state.open }));
+  };
+
+  render() {
+    const { open } = this.state;
+    const { allowResizing, className, closeOnClick = true, id, size = 'full', zLevel } = this.props;
+    const a11yAttrs: A11yAttrs = {
+      'aria-controls': `${id}-dropdown`,
+      'aria-expanded': open,
+      'aria-haspopup': 'menu',
+      id: `${id}-trigger`,
+      role: 'button',
+    };
+
+    const children = React.isValidElement(this.props.children)
+      ? React.cloneElement(this.props.children, { onClick: this.handleToggleClick, ...a11yAttrs })
+      : this.props.children({
+          a11yAttrs,
+          closeDropdown: this.handleClose,
+          onToggleClick: this.handleToggleClick,
+          open,
+        });
+
+    return (
+      <DropdownToggler
+        allowResizing={allowResizing}
+        aria-labelledby={`${id}-trigger`}
+        className={className}
+        id={`${id}-dropdown`}
+        onRequestClose={this.handleClose}
+        open={open}
+        overlay={
+          <DropdownMenu onClick={closeOnClick ? this.handleClose : undefined} size={size}>
+            {this.props.overlay}
+          </DropdownMenu>
+        }
+        placement={this.props.placement}
+        zLevel={zLevel}
+      >
+        {children}
+      </DropdownToggler>
+    );
+  }
+}
+
+interface ActionsDropdownProps extends Omit<Props, 'children' | 'overlay'> {
+  buttonSize?: 'small' | 'medium';
+  children: React.ReactNode;
+}
+
+export function ActionsDropdown(props: ActionsDropdownProps) {
+  const { children, buttonSize, ...dropdownProps } = props;
+  return (
+    <Dropdown overlay={children} {...dropdownProps}>
+      <InteractiveIcon
+        Icon={MenuIcon}
+        aria-label={translate('menu')}
+        size={buttonSize}
+        stopPropagation={false}
+      />
+    </Dropdown>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/DropdownMenu.tsx b/server/sonar-web/design-system/src/components/DropdownMenu.tsx
new file mode 100644 (file)
index 0000000..d160117
--- /dev/null
@@ -0,0 +1,370 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import React from 'react';
+import tw from 'twin.macro';
+import { INPUT_SIZES } from '../helpers/constants';
+import { translate } from '../helpers/l10n';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import { InputSizeKeys, ThemedProps } from '../types/theme';
+import Checkbox from './Checkbox';
+import { ClipboardBase } from './clipboard';
+import { BaseLink, LinkProps } from './Link';
+import NavLink from './NavLink';
+import RadioButton from './RadioButton';
+import Tooltip from './Tooltip';
+
+interface Props extends React.HtmlHTMLAttributes<HTMLMenuElement> {
+  children?: React.ReactNode;
+  className?: string;
+  innerRef?: React.Ref<HTMLUListElement>;
+  maxHeight?: string;
+  size?: InputSizeKeys;
+}
+
+export function DropdownMenu({
+  children,
+  className,
+  innerRef,
+  maxHeight = 'inherit',
+  size = 'small',
+  ...menuProps
+}: Props) {
+  return (
+    <DropdownMenuWrapper
+      className={classNames('dropdown-menu', className)}
+      ref={innerRef}
+      role="menu"
+      style={{ '--inputSize': INPUT_SIZES[size], maxHeight }}
+      {...menuProps}
+    >
+      {children}
+    </DropdownMenuWrapper>
+  );
+}
+
+interface ListItemProps {
+  children?: React.ReactNode;
+  className?: string;
+  innerRef?: React.Ref<HTMLLIElement>;
+  onFocus?: VoidFunction;
+  onPointerEnter?: VoidFunction;
+  onPointerLeave?: VoidFunction;
+}
+
+type ItemLinkProps = Omit<ListItemProps, 'innerRef'> &
+  Pick<LinkProps, 'disabled' | 'icon' | 'onClick' | 'to'> & {
+    innerRef?: React.Ref<HTMLAnchorElement>;
+  };
+
+export function ItemLink(props: ItemLinkProps) {
+  const { children, className, disabled, icon, onClick, innerRef, to, ...liProps } = props;
+  return (
+    <li {...liProps}>
+      <ItemLinkStyled
+        className={classNames(className, { disabled })}
+        disabled={disabled}
+        icon={icon}
+        onClick={onClick}
+        ref={innerRef}
+        role="menuitem"
+        showExternalIcon={false}
+        to={to}
+      >
+        {children}
+      </ItemLinkStyled>
+    </li>
+  );
+}
+
+interface ItemNavLinkProps extends ItemLinkProps {
+  end?: boolean;
+}
+
+export function ItemNavLink(props: ItemNavLinkProps) {
+  const { children, className, disabled, end, icon, onClick, innerRef, to, ...liProps } = props;
+  return (
+    <li {...liProps}>
+      <ItemNavLinkStyled
+        className={classNames(className, { disabled })}
+        disabled={disabled}
+        end={end}
+        onClick={onClick}
+        ref={innerRef}
+        role="menuitem"
+        to={to}
+      >
+        {icon}
+        {children}
+      </ItemNavLinkStyled>
+    </li>
+  );
+}
+
+interface ItemButtonProps extends ListItemProps {
+  disabled?: boolean;
+  icon?: React.ReactNode;
+  onClick: React.MouseEventHandler<HTMLButtonElement>;
+}
+
+export function ItemButton(props: ItemButtonProps) {
+  const { children, className, disabled, icon, innerRef, onClick, ...liProps } = props;
+  return (
+    <li ref={innerRef} role="none" {...liProps}>
+      <ItemButtonStyled className={className} disabled={disabled} onClick={onClick} role="menuitem">
+        {icon}
+        {children}
+      </ItemButtonStyled>
+    </li>
+  );
+}
+
+export const ItemDangerButton = styled(ItemButton)`
+  --color: ${themeContrast('dropdownMenuDanger')};
+`;
+
+interface ItemCheckboxProps extends ListItemProps {
+  checked: boolean;
+  disabled?: boolean;
+  id?: string;
+  onCheck: (checked: boolean, id?: string) => void;
+}
+
+export function ItemCheckbox(props: ItemCheckboxProps) {
+  const { checked, children, className, disabled, id, innerRef, onCheck, onFocus, ...liProps } =
+    props;
+  return (
+    <li ref={innerRef} role="none" {...liProps}>
+      <ItemCheckboxStyled
+        checked={checked}
+        className={classNames(className, { disabled })}
+        disabled={disabled}
+        id={id}
+        onCheck={onCheck}
+        onFocus={onFocus}
+      >
+        {children}
+      </ItemCheckboxStyled>
+    </li>
+  );
+}
+
+interface ItemRadioButtonProps extends ListItemProps {
+  checked: boolean;
+  disabled?: boolean;
+  onCheck: (value: string) => void;
+  value: string;
+}
+
+export function ItemRadioButton(props: ItemRadioButtonProps) {
+  const { checked, children, className, disabled, innerRef, onCheck, value, ...liProps } = props;
+  return (
+    <li ref={innerRef} role="none" {...liProps}>
+      <ItemRadioButtonStyled
+        checked={checked}
+        className={classNames(className, { disabled })}
+        disabled={disabled}
+        onCheck={onCheck}
+        value={value}
+      >
+        {children}
+      </ItemRadioButtonStyled>
+    </li>
+  );
+}
+
+interface ItemCopyProps {
+  children?: React.ReactNode;
+  className?: string;
+  copyValue: string;
+}
+
+export function ItemCopy(props: ItemCopyProps) {
+  const { children, className, copyValue } = props;
+  return (
+    <ClipboardBase>
+      {({ setCopyButton, copySuccess }) => (
+        <Tooltip overlay={translate('copied_action')} visible={copySuccess}>
+          <li role="none">
+            <ItemButtonStyled
+              className={className}
+              data-clipboard-text={copyValue}
+              ref={setCopyButton}
+              role="menuitem"
+            >
+              {children}
+            </ItemButtonStyled>
+          </li>
+        </Tooltip>
+      )}
+    </ClipboardBase>
+  );
+}
+
+interface ItemDownloadProps extends ListItemProps {
+  download: string;
+  href: string;
+}
+
+export function ItemDownload(props: ItemDownloadProps) {
+  const { children, className, download, href, innerRef, ...liProps } = props;
+  return (
+    <li ref={innerRef} role="none" {...liProps}>
+      <ItemDownloadStyled
+        className={className}
+        download={download}
+        href={href}
+        rel="noopener noreferrer"
+        role="menuitem"
+        target="_blank"
+      >
+        {children}
+      </ItemDownloadStyled>
+    </li>
+  );
+}
+
+export const ItemHeaderHighlight = styled.span`
+  color: ${themeContrast('searchHighlight')};
+  font-weight: 600;
+`;
+
+export const ItemHeader = styled.li`
+  background-color: ${themeColor('dropdownMenuHeader')};
+  color: ${themeContrast('dropdownMenuHeader')};
+
+  ${tw`sw-py-2 sw-px-3`}
+`;
+ItemHeader.defaultProps = { className: 'dropdown-menu-header', role: 'menuitem' };
+
+export const ItemDivider = styled.li`
+  height: 1px;
+  background-color: ${themeColor('popupBorder')};
+
+  ${tw`sw-my-1 sw--mx-2`}
+  ${tw`sw-overflow-hidden`};
+`;
+ItemDivider.defaultProps = { role: 'separator' };
+
+const DropdownMenuWrapper = styled.ul`
+  background-color: ${themeColor('dropdownMenu')};
+  color: ${themeContrast('dropdownMenu')};
+  width: var(--inputSize);
+  list-style: none;
+
+  ${tw`sw-flex sw-flex-col`}
+  ${tw`sw-box-border`};
+  ${tw`sw-min-w-input-small`}
+  ${tw`sw-py-2`}
+  ${tw`sw-body-sm`}
+
+  &:focus {
+    outline: none;
+  }
+`;
+
+const itemStyle = (props: ThemedProps) => css`
+  color: var(--color);
+  background-color: ${themeColor('dropdownMenu')(props)};
+  border: none;
+  border-bottom: none;
+  text-decoration: none;
+  transition: none;
+
+  ${tw`sw-flex sw-items-center`}
+  ${tw`sw-body-sm`}
+  ${tw`sw-box-border`}
+  ${tw`sw-w-full`}
+  ${tw`sw-text-left`}
+  ${tw`sw-py-2 sw-px-3`}
+  ${tw`sw-truncate`};
+  ${tw`sw-cursor-pointer`}
+
+  &.active,
+  &:active,
+  &.active:active,
+  &:hover,
+  &.active:hover {
+    color: var(--color);
+    background-color: ${themeColor('dropdownMenuHover')(props)};
+    text-decoration: none;
+    outline: none;
+    border: none;
+    border-bottom: none;
+  }
+
+  &:focus,
+  &:focus-within,
+  &.active:focus,
+  &.active:focus-within {
+    color: var(--color);
+    background-color: ${themeColor('dropdownMenuFocus')(props)};
+    text-decoration: none;
+    outline: ${themeBorder('focus', 'dropdownMenuFocusBorder')(props)};
+    outline-offset: -4px;
+    border: none;
+    border-bottom: none;
+  }
+
+  &:disabled,
+  &.disabled {
+    color: ${themeContrast('dropdownMenuDisabled')(props)};
+    background-color: ${themeColor('dropdownMenuDisabled')(props)};
+    pointer-events: none !important;
+
+    ${tw`sw-cursor-not-allowed`};
+  }
+
+  & > svg {
+    ${tw`sw-mr-2`}
+  }
+`;
+
+const ItemNavLinkStyled = styled(NavLink)`
+  --color: ${themeContrast('dropdownMenu')};
+  ${itemStyle};
+`;
+
+const ItemLinkStyled = styled(BaseLink)`
+  --color: ${themeContrast('dropdownMenu')};
+  ${itemStyle}
+`;
+
+const ItemButtonStyled = styled.button`
+  --color: ${themeContrast('dropdownMenu')};
+  ${itemStyle}
+`;
+
+const ItemDownloadStyled = styled.a`
+  --color: ${themeContrast('dropdownMenu')};
+  ${itemStyle}
+`;
+
+const ItemCheckboxStyled = styled(Checkbox)`
+  --color: ${themeContrast('dropdownMenu')};
+  ${itemStyle}
+`;
+
+const ItemRadioButtonStyled = styled(RadioButton)`
+  --color: ${themeContrast('dropdownMenu')};
+  ${itemStyle}
+`;
diff --git a/server/sonar-web/design-system/src/components/DropdownToggler.tsx b/server/sonar-web/design-system/src/components/DropdownToggler.tsx
new file mode 100644 (file)
index 0000000..f46f3dc
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import EscKeydownHandler from './EscKeydownHandler';
+import OutsideClickHandler from './OutsideClickHandler';
+import { PortalPopup } from './popups';
+
+type PopupProps = PortalPopup['props'];
+
+interface Props extends PopupProps {
+  onRequestClose: VoidFunction;
+  open: boolean;
+}
+
+export default function DropdownToggler(props: Props) {
+  const { children, open, onRequestClose, overlay, ...popupProps } = props;
+
+  return (
+    <PortalPopup
+      overlay={
+        open ? (
+          <OutsideClickHandler onClickOutside={onRequestClose}>
+            <EscKeydownHandler onKeydown={onRequestClose}>{overlay}</EscKeydownHandler>
+          </OutsideClickHandler>
+        ) : undefined
+      }
+      {...popupProps}
+    >
+      {children}
+    </PortalPopup>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/DummyComponent.tsx b/server/sonar-web/design-system/src/components/DummyComponent.tsx
deleted file mode 100644 (file)
index 8470a13..0000000
+++ /dev/null
@@ -1,23 +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.
- */
-
-export function DummyComponent() {
-  return <div>I&apos;m a dummy</div>;
-}
diff --git a/server/sonar-web/design-system/src/components/EscKeydownHandler.tsx b/server/sonar-web/design-system/src/components/EscKeydownHandler.tsx
new file mode 100644 (file)
index 0000000..9b0155a
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import { Key } from '../helpers/keyboard';
+
+interface Props {
+  children: React.ReactNode;
+  onKeydown: () => void;
+}
+
+export default class EscKeydownHandler extends React.Component<Props> {
+  componentDidMount() {
+    setTimeout(() => {
+      document.addEventListener('keydown', this.handleKeyDown, false);
+    }, 0);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('keydown', this.handleKeyDown, false);
+  }
+
+  handleKeyDown = (event: KeyboardEvent) => {
+    if (event.code === Key.Escape) {
+      this.props.onKeydown();
+    }
+  };
+
+  render() {
+    return this.props.children;
+  }
+}
diff --git a/server/sonar-web/design-system/src/components/GenericAvatar.tsx b/server/sonar-web/design-system/src/components/GenericAvatar.tsx
new file mode 100644 (file)
index 0000000..4d8fa69
--- /dev/null
@@ -0,0 +1,60 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeAvatarColor } from '../helpers/theme';
+import { IconProps } from './icons/Icon';
+
+export interface GenericAvatarProps {
+  Icon?: React.ComponentType<IconProps>;
+  className?: string;
+  name: string;
+  size?: number;
+}
+
+export function GenericAvatar({ className, Icon, name, size = 24 }: GenericAvatarProps) {
+  const theme = useTheme();
+  const text = name.length > 0 ? name[0].toUpperCase() : '';
+
+  return (
+    <StyledGenericAvatar aria-label={name} className={className} name={name} role="img" size={size}>
+      {Icon ? <Icon fill={themeAvatarColor(name, true)({ theme })} /> : text}
+    </StyledGenericAvatar>
+  );
+}
+
+export const StyledGenericAvatar = styled.div<{ name: string; size: number }>`
+  ${tw`sw-text-center`};
+  ${tw`sw-align-top`};
+  ${tw`sw-select-none`};
+  ${tw`sw-font-regular`};
+  ${tw`sw-rounded-1`};
+  ${tw`sw-inline-flex`};
+  ${tw`sw-items-center`};
+  ${tw`sw-justify-center`};
+  height: ${({ size }) => size}px;
+  width: ${({ size }) => size}px;
+  background-color: ${({ name, theme }) => themeAvatarColor(name)({ theme })};
+  color: ${({ name, theme }) => themeAvatarColor(name, true)({ theme })};
+  font-size: ${({ size }) => Math.max(Math.floor(size / 2), 8)}px;
+  line-height: ${({ size }) => size}px;
+`;
diff --git a/server/sonar-web/design-system/src/components/InputSearch.tsx b/server/sonar-web/design-system/src/components/InputSearch.tsx
new file mode 100644 (file)
index 0000000..5e5e9c0
--- /dev/null
@@ -0,0 +1,243 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import { debounce } from 'lodash';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import tw, { theme } from 'twin.macro';
+import { DEBOUNCE_DELAY, INPUT_SIZES } from '../helpers/constants';
+import { Key } from '../helpers/keyboard';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import { isDefined } from '../helpers/types';
+import { InputSizeKeys } from '../types/theme';
+import DeferredSpinner from './DeferredSpinner';
+import CloseIcon from './icons/CloseIcon';
+import SearchIcon from './icons/SearchIcon';
+import { InteractiveIcon } from './InteractiveIcon';
+
+interface Props {
+  autoFocus?: boolean;
+  className?: string;
+  clearIconAriaLabel: string;
+  id?: string;
+  innerRef?: React.RefCallback<HTMLInputElement>;
+  loading?: boolean;
+  maxLength?: number;
+  minLength?: number;
+  onBlur?: React.FocusEventHandler<HTMLInputElement>;
+  onChange: (value: string) => void;
+  onFocus?: React.FocusEventHandler<HTMLInputElement>;
+  onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
+  onMouseDown?: React.MouseEventHandler<HTMLInputElement>;
+  placeholder: string;
+  searchInputAriaLabel: string;
+  size?: InputSizeKeys;
+  tooShortText: string;
+  value?: string;
+}
+
+const DEFAULT_MAX_LENGTH = 100;
+
+export default function InputSearch({
+  autoFocus,
+  id,
+  className,
+  innerRef,
+  onBlur,
+  onChange,
+  onFocus,
+  onKeyDown,
+  onMouseDown,
+  placeholder,
+  loading,
+  minLength,
+  maxLength = DEFAULT_MAX_LENGTH,
+  size = 'medium',
+  value: parentValue,
+  tooShortText,
+  searchInputAriaLabel,
+  clearIconAriaLabel,
+}: Props) {
+  const input = useRef<null | HTMLElement>(null);
+  const [value, setValue] = useState(parentValue ?? '');
+  const debouncedOnChange = useMemo(() => debounce(onChange, DEBOUNCE_DELAY), [onChange]);
+
+  const tooShort = isDefined(minLength) && value.length > 0 && value.length < minLength;
+  const inputClassName = classNames('js-input-search', {
+    touched: value.length > 0 && (!minLength || minLength > value.length),
+    'sw-pr-10': value.length > 0,
+  });
+
+  useEffect(() => {
+    if (parentValue !== undefined) {
+      setValue(parentValue);
+    }
+  }, [parentValue]);
+
+  const changeValue = (newValue: string) => {
+    if (newValue.length === 0 || !minLength || minLength <= newValue.length) {
+      debouncedOnChange(newValue);
+    }
+  };
+
+  const handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
+    const eventValue = event.currentTarget.value;
+    setValue(eventValue);
+    changeValue(eventValue);
+  };
+
+  const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+    if (event.key === Key.Escape) {
+      event.preventDefault();
+      handleClearClick();
+    }
+    onKeyDown?.(event);
+  };
+
+  const handleClearClick = () => {
+    onChange('');
+    if (parentValue === undefined || parentValue === '') {
+      setValue('');
+    }
+    input.current?.focus();
+  };
+  const ref = (node: HTMLInputElement | null) => {
+    input.current = node;
+    innerRef?.(node);
+  };
+
+  return (
+    <InputSearchWrapper
+      className={className}
+      id={id}
+      onMouseDown={onMouseDown}
+      style={{ '--inputSize': INPUT_SIZES[size] }}
+      title={tooShort && isDefined(minLength) ? tooShortText : ''}
+    >
+      <StyledInputWrapper className="sw-flex sw-items-center">
+        <input
+          aria-label={searchInputAriaLabel}
+          autoComplete="off"
+          autoFocus={autoFocus}
+          className={inputClassName}
+          maxLength={maxLength}
+          onBlur={onBlur}
+          onChange={handleInputChange}
+          onFocus={onFocus}
+          onKeyDown={handleInputKeyDown}
+          placeholder={placeholder}
+          ref={ref}
+          role="searchbox"
+          type="search"
+          value={value}
+        />
+        <DeferredSpinner loading={loading !== undefined ? loading : false}>
+          <StyledSearchIcon />
+        </DeferredSpinner>
+        {value && (
+          <StyledInteractiveIcon
+            Icon={CloseIcon}
+            aria-label={clearIconAriaLabel}
+            className="js-input-search-clear"
+            onClick={handleClearClick}
+            size="small"
+          />
+        )}
+
+        {tooShort && isDefined(minLength) && (
+          <StyledNote className="sw-ml-1" role="note">
+            {tooShortText}
+          </StyledNote>
+        )}
+      </StyledInputWrapper>
+    </InputSearchWrapper>
+  );
+}
+
+export const InputSearchWrapper = styled.div`
+  width: var(--inputSize);
+
+  ${tw`sw-relative sw-inline-block`}
+  ${tw`sw-whitespace-nowrap`}
+  ${tw`sw-align-middle`}
+  ${tw`sw-h-control`}
+`;
+
+export const StyledInputWrapper = styled.div`
+  input {
+    background: ${themeColor('inputBackground')};
+    color: ${themeContrast('inputBackground')};
+    border: ${themeBorder('default', 'inputBorder')};
+
+    ${tw`sw-rounded-2`}
+    ${tw`sw-box-border`}
+  ${tw`sw-pl-10`}
+  ${tw`sw-body-sm`}
+  ${tw`sw-w-full sw-h-control`}
+
+  &::placeholder {
+      color: ${themeColor('inputPlaceholder')};
+
+      ${tw`sw-truncate`}
+    }
+
+    &:hover {
+      border: ${themeBorder('default', 'inputFocus')};
+    }
+
+    &:focus,
+    &:active {
+      border: ${themeBorder('default', 'inputFocus')};
+      outline: ${themeBorder('focus', 'inputFocus')};
+    }
+
+    &::-webkit-search-decoration,
+    &::-webkit-search-cancel-button,
+    &::-webkit-search-results-button,
+    &::-webkit-search-results-decoration {
+      ${tw`sw-hidden sw-appearance-none`}
+    }
+  }
+`;
+
+const StyledSearchIcon = styled(SearchIcon)`
+  color: ${themeColor('inputBorder')};
+  top: calc((${theme('height.control')} - ${theme('spacing.4')}) / 2);
+
+  ${tw`sw-left-3`}
+  ${tw`sw-absolute`}
+`;
+
+export const StyledInteractiveIcon = styled(InteractiveIcon)`
+  ${tw`sw-absolute`}
+  ${tw`sw-right-2`}
+`;
+
+const StyledNote = styled.span`
+  color: ${themeColor('inputPlaceholder')};
+  top: calc(1px + ${theme('inset.2')});
+
+  ${tw`sw-absolute`}
+  ${tw`sw-left-12 sw-right-10`}
+  ${tw`sw-body-sm`}
+  ${tw`sw-text-right`}
+  ${tw`sw-truncate`}
+  ${tw`sw-pointer-events-none`}
+`;
diff --git a/server/sonar-web/design-system/src/components/InteractiveIcon.tsx b/server/sonar-web/design-system/src/components/InteractiveIcon.tsx
new file mode 100644 (file)
index 0000000..ebd9cb7
--- /dev/null
@@ -0,0 +1,182 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import { isDefined } from '../helpers/types';
+import { ThemedProps } from '../types/theme';
+import { IconProps } from './icons/Icon';
+import { BaseLink, LinkProps } from './Link';
+
+export type InteractiveIconSize = 'small' | 'medium';
+
+export interface InteractiveIconProps {
+  Icon: React.ComponentType<IconProps>;
+  'aria-label': string;
+  children?: React.ReactNode;
+  className?: string;
+  currentColor?: boolean;
+  disabled?: boolean;
+  id?: string;
+  innerRef?: React.Ref<HTMLButtonElement>;
+  onClick?: VoidFunction;
+  size?: InteractiveIconSize;
+  stopPropagation?: boolean;
+  to?: LinkProps['to'];
+}
+
+export class InteractiveIconBase extends React.PureComponent<InteractiveIconProps> {
+  handleClick = (event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
+    const { disabled, onClick, stopPropagation = true } = this.props;
+    event.currentTarget.blur();
+
+    if (stopPropagation) {
+      event.stopPropagation();
+    }
+
+    if (onClick && !disabled) {
+      onClick();
+    }
+  };
+
+  render() {
+    const {
+      Icon,
+      children,
+      disabled,
+      innerRef,
+      onClick,
+      size = 'medium',
+      to,
+      ...htmlProps
+    } = this.props;
+
+    const props = {
+      ...htmlProps,
+      'aria-disabled': disabled,
+      disabled,
+      size,
+      type: 'button' as const,
+    };
+
+    if (to) {
+      return (
+        <IconLink
+          {...props}
+          onClick={onClick}
+          showExternalIcon={false}
+          stopPropagation={true}
+          to={to}
+        >
+          <Icon className={classNames({ 'sw-mr-1': isDefined(children) })} />
+          {children}
+        </IconLink>
+      );
+    }
+
+    return (
+      <IconButton {...props} onClick={this.handleClick} ref={innerRef}>
+        <Icon className={classNames({ 'sw-mr-1': isDefined(children) })} />
+        {children}
+      </IconButton>
+    );
+  }
+}
+
+const buttonIconStyle = (props: ThemedProps & { size: InteractiveIconSize }) => css`
+  box-sizing: border-box;
+  border: none;
+  outline: none;
+  text-decoration: none;
+  color: var(--color);
+  background-color: var(--background);
+  transition: background-color 0.2s ease, outline 0.2s ease, color 0.2s ease;
+
+  ${tw`sw-inline-flex sw-items-center sw-justify-center`}
+  ${tw`sw-cursor-pointer`}
+
+  ${{
+    small: tw`sw-h-6 sw-px-1 sw-rounded-1/2`,
+    medium: tw`sw-h-control sw-px-[0.625rem] sw-rounded-2`,
+  }[props.size]}
+
+
+  &:hover,
+  &:focus,
+  &:active {
+    color: var(--colorHover);
+    background-color: var(--backgroundHover);
+  }
+
+  &:focus,
+  &:active {
+    outline: ${themeBorder('focus', 'var(--focus)')(props)};
+  }
+
+  &:disabled,
+  &:disabled:hover {
+    color: ${themeContrast('buttonDisabled')(props)};
+    background-color: var(--background);
+
+    ${tw`sw-cursor-not-allowed`}
+  }
+`;
+
+const IconLink = styled(BaseLink)`
+  ${buttonIconStyle}
+`;
+
+const IconButton = styled.button`
+  ${buttonIconStyle}
+`;
+
+export const InteractiveIcon: React.FC<InteractiveIconProps> = styled(InteractiveIconBase)`
+  --background: ${themeColor('interactiveIcon')};
+  --backgroundHover: ${themeColor('interactiveIconHover')};
+  --color: ${({ currentColor, theme }) =>
+    currentColor ? 'currentColor' : themeContrast('interactiveIcon')({ theme })};
+  --colorHover: ${themeContrast('interactiveIconHover')};
+  --focus: ${themeColor('interactiveIconFocus', 0.2)};
+`;
+
+export const DiscreetInteractiveIcon: React.FC<InteractiveIconProps> = styled(InteractiveIcon)`
+  --color: ${themeColor('discreetInteractiveIcon')};
+`;
+
+export const DestructiveIcon: React.FC<InteractiveIconProps> = styled(InteractiveIconBase)`
+  --background: ${themeColor('destructiveIcon')};
+  --backgroundHover: ${themeColor('destructiveIconHover')};
+  --color: ${themeContrast('destructiveIcon')};
+  --colorHover: ${themeContrast('destructiveIconHover')};
+  --focus: ${themeColor('destructiveIconFocus', 0.2)};
+`;
+
+export const DismissProductNewsIcon: React.FC<InteractiveIconProps> = styled(InteractiveIcon)`
+  --background: ${themeColor('productNews')};
+  --backgroundHover: ${themeColor('productNewsHover')};
+  --color: ${themeContrast('productNews')};
+  --colorHover: ${themeContrast('productNewsHover')};
+  --focus: ${themeColor('interactiveIconFocus', 0.2)};
+
+  height: 28px;
+`;
diff --git a/server/sonar-web/design-system/src/components/Link.tsx b/server/sonar-web/design-system/src/components/Link.tsx
new file mode 100644 (file)
index 0000000..5f427ec
--- /dev/null
@@ -0,0 +1,173 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import React, { HTMLAttributeAnchorTarget } from 'react';
+import { Link as RouterLink, LinkProps as RouterLinkProps } from 'react-router-dom';
+import tw, { theme as twTheme } from 'twin.macro';
+import { themeBorder, themeColor } from '../helpers/theme';
+import OpenNewTabIcon from './icons/OpenNewTabIcon';
+import { TooltipWrapperInner } from './Tooltip';
+
+export interface LinkProps extends RouterLinkProps {
+  blurAfterClick?: boolean;
+  disabled?: boolean;
+  forceExternal?: boolean;
+  icon?: React.ReactNode;
+  onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
+  preventDefault?: boolean;
+  showExternalIcon?: boolean;
+  stopPropagation?: boolean;
+  target?: HTMLAttributeAnchorTarget;
+}
+
+function BaseLinkWithRef(props: LinkProps, ref: React.ForwardedRef<HTMLAnchorElement>) {
+  const {
+    children,
+    blurAfterClick,
+    disabled,
+    icon,
+    onClick,
+    preventDefault,
+    showExternalIcon = !icon,
+    stopPropagation,
+    target = '_blank',
+    to,
+    ...rest
+  } = props;
+  const isExternal = typeof to === 'string' && to.startsWith('http');
+  const handleClick = React.useCallback(
+    (event: React.MouseEvent<HTMLAnchorElement>) => {
+      if (blurAfterClick) {
+        event.currentTarget.blur();
+      }
+
+      if (preventDefault || disabled) {
+        event.preventDefault();
+      }
+
+      if (stopPropagation) {
+        event.stopPropagation();
+      }
+
+      if (onClick && !disabled) {
+        onClick(event);
+      }
+    },
+    [onClick, blurAfterClick, preventDefault, stopPropagation, disabled]
+  );
+
+  return isExternal ? (
+    <a
+      {...rest}
+      href={to}
+      onClick={handleClick}
+      ref={ref}
+      rel="noopener noreferrer"
+      target={target}
+    >
+      {icon}
+      {children}
+      {showExternalIcon && <OpenNewTabIcon className="sw-ml-1" />}
+    </a>
+  ) : (
+    <RouterLink ref={ref} {...rest} onClick={handleClick} to={to}>
+      {icon}
+      {children}
+    </RouterLink>
+  );
+}
+
+export const BaseLink = React.forwardRef(BaseLinkWithRef);
+
+const StyledBaseLink = styled(BaseLink)`
+  color: var(--color);
+  border-bottom: ${({ children, icon, theme }) =>
+    icon && !children ? themeBorder('default', 'transparent')({ theme }) : 'var(--border)'};
+
+  &:visited {
+    color: var(--color);
+  }
+
+  &:hover,
+  &:focus,
+  &:active {
+    color: var(--active);
+    border-bottom: ${({ children, icon, theme }) =>
+      icon && !children ? themeBorder('default', 'transparent')({ theme }) : 'var(--borderActive)'};
+  }
+
+  & > svg {
+    ${tw`sw-align-text-bottom!`}
+  }
+
+  ${({ icon }) =>
+    icon &&
+    css`
+      margin-left: calc(${twTheme('width.icon')} + ${twTheme('spacing.1')});
+
+      & > svg,
+      & > img {
+        ${tw`sw-mr-1`}
+
+        margin-left: calc(-1 * (${twTheme('width.icon')} + ${twTheme('spacing.1')}));
+      }
+    `};
+`;
+
+export const HoverLink = styled(StyledBaseLink)`
+  text-decoration: none;
+
+  --color: ${themeColor('linkDiscreet')};
+  --active: ${themeColor('linkActive')};
+  --border: ${themeBorder('default', 'transparent')};
+  --borderActive: ${themeBorder('default', 'linkActive')};
+
+  ${TooltipWrapperInner} & {
+    --active: ${themeColor('linkTooltipActive')};
+    --borderActive: ${themeBorder('default', 'linkTooltipActive')};
+  }
+`;
+HoverLink.displayName = 'HoverLink';
+
+export const DiscreetLink = styled(HoverLink)`
+  --border: ${themeBorder('default', 'linkDiscreet')};
+`;
+DiscreetLink.displayName = 'DiscreetLink';
+
+const StandoutLink = styled(StyledBaseLink)`
+  ${tw`sw-font-semibold`}
+  ${tw`sw-no-underline`}
+
+  --color: ${themeColor('linkDefault')};
+  --active: ${themeColor('linkActive')};
+  --border: ${themeBorder('default', 'linkDefault')};
+  --borderActive: ${themeBorder('default', 'linkDefault')};
+
+  ${TooltipWrapperInner} & {
+    --color: ${themeColor('linkTooltipDefault')};
+    --active: ${themeColor('linkTooltipActive')};
+    --border: ${themeBorder('default', 'linkTooltipDefault')};
+    --borderActive: ${themeBorder('default', 'linkTooltipActive')};
+  }
+`;
+StandoutLink.displayName = 'StandoutLink';
+
+export default StandoutLink;
diff --git a/server/sonar-web/design-system/src/components/MainAppBar.tsx b/server/sonar-web/design-system/src/components/MainAppBar.tsx
new file mode 100644 (file)
index 0000000..97303a0
--- /dev/null
@@ -0,0 +1,89 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import tw from 'twin.macro';
+import {
+  LAYOUT_GLOBAL_NAV_HEIGHT,
+  LAYOUT_LOGO_MARGIN_RIGHT,
+  LAYOUT_LOGO_MAX_HEIGHT,
+  LAYOUT_LOGO_MAX_WIDTH,
+} from '../helpers/constants';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+
+const MainAppBarContainerDiv = styled.div`
+  height: ${LAYOUT_GLOBAL_NAV_HEIGHT}px;
+`;
+
+const MainAppBarDiv = styled.div`
+  ${tw`sw-fixed`}
+  ${tw`sw-flex`};
+  ${tw`sw-items-center`};
+  ${tw`sw-left-0`};
+  ${tw`sw-px-6`};
+  ${tw`sw-right-0`};
+  ${tw`sw-w-full`};
+  ${tw`sw-box-border`};
+  ${tw`sw-z-global-navbar`};
+
+  background: ${themeColor('mainBar')};
+  border-bottom: ${themeBorder('default')};
+  color: ${themeContrast('mainBar')};
+  height: ${LAYOUT_GLOBAL_NAV_HEIGHT}px;
+`;
+
+const MainAppBarNavLogoDiv = styled.div`
+  margin-right: ${LAYOUT_LOGO_MARGIN_RIGHT}px;
+
+  img,
+  svg {
+    ${tw`sw-object-contain`};
+
+    max-height: ${LAYOUT_LOGO_MAX_HEIGHT}px;
+    max-width: ${LAYOUT_LOGO_MAX_WIDTH}px;
+  }
+`;
+
+const MainAppBarNavLogoLink = styled.a`
+  border: none;
+`;
+
+const MainAppBarNavRightDiv = styled.div`
+  flex-grow: 2;
+  height: 100%;
+`;
+
+export function MainAppBar({
+  children,
+  Logo,
+}: React.PropsWithChildren<{ Logo: React.ElementType }>) {
+  return (
+    <MainAppBarContainerDiv>
+      <MainAppBarDiv>
+        <MainAppBarNavLogoDiv>
+          <MainAppBarNavLogoLink href="/">
+            <Logo />
+          </MainAppBarNavLogoLink>
+        </MainAppBarNavLogoDiv>
+        <MainAppBarNavRightDiv>{children}</MainAppBarNavRightDiv>
+      </MainAppBarDiv>
+    </MainAppBarContainerDiv>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/MainMenu.tsx b/server/sonar-web/design-system/src/components/MainMenu.tsx
new file mode 100644 (file)
index 0000000..e61964a
--- /dev/null
@@ -0,0 +1,30 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import tw from 'twin.macro';
+
+const MainMenuUl = styled.ul`
+  ${tw`sw-flex sw-gap-8 sw-items-center`}
+`;
+
+export function MainMenu({ children }: React.PropsWithChildren<{}>) {
+  return <MainMenuUl>{children}</MainMenuUl>;
+}
diff --git a/server/sonar-web/design-system/src/components/MainMenuItem.tsx b/server/sonar-web/design-system/src/components/MainMenuItem.tsx
new file mode 100644 (file)
index 0000000..9749ba9
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import tw from 'twin.macro';
+import { LAYOUT_GLOBAL_NAV_HEIGHT } from '../helpers/constants';
+import { themeBorder, themeContrast } from '../helpers/theme';
+
+export const MainMenuItem = styled.li`
+  & a {
+    ${tw`sw-block sw-box-border`};
+    ${tw`sw-text-sm sw-font-semibold`};
+    ${tw`sw-whitespace-nowrap`};
+    ${tw`sw-no-underline`};
+    ${tw`sw-select-none`};
+    ${tw`sw-font-sans`};
+
+    color: ${themeContrast('mainBar')};
+    letter-spacing: 0.03em;
+    line-height: calc(${LAYOUT_GLOBAL_NAV_HEIGHT}px - 3px); // - 3px border bottom
+    border-bottom: ${themeBorder('active', 'transparent', 1)};
+
+    &:visited {
+      border-bottom: ${themeBorder('active', 'transparent', 1)};
+      color: ${themeContrast('mainBar')};
+    }
+
+    &:active,
+    &.active,
+    &:focus {
+      border-bottom: ${themeBorder('active', 'menuBorder', 1)};
+      color: ${themeContrast('mainBar')};
+    }
+
+    &:hover,
+    &.hover,
+    &[aria-expanded='true'] {
+      border-bottom: ${themeBorder('active', 'menuBorder', 1)};
+      color: ${themeContrast('mainBarHover')};
+    }
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/NavLink.tsx b/server/sonar-web/design-system/src/components/NavLink.tsx
new file mode 100644 (file)
index 0000000..8075c5e
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import { NavLink as RouterNavLink, NavLinkProps as RouterNavLinkProps } from 'react-router-dom';
+
+export interface NavLinkProps extends RouterNavLinkProps {
+  blurAfterClick?: boolean;
+  disabled?: boolean;
+  onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
+  preventDefault?: boolean;
+  stopPropagation?: boolean;
+}
+
+// Styling this component directly with Emotion should be avoided due to conflicts with react-router's classname.
+// Use NavBarTabs as an example of this exception.
+function NavLinkWithRef(props: NavLinkProps, ref: React.ForwardedRef<HTMLAnchorElement>) {
+  const {
+    blurAfterClick,
+    children,
+    disabled,
+    onClick,
+    preventDefault,
+    stopPropagation,
+    ...otherProps
+  } = props;
+
+  const handleClick = React.useCallback(
+    (event: React.MouseEvent<HTMLAnchorElement>) => {
+      if (blurAfterClick) {
+        // explicitly lose focus after click
+        event.currentTarget.blur();
+      }
+
+      if (preventDefault || disabled) {
+        event.preventDefault();
+      }
+
+      if (stopPropagation) {
+        event.stopPropagation();
+      }
+
+      if (onClick && !disabled) {
+        onClick(event);
+      }
+    },
+    [onClick, blurAfterClick, preventDefault, stopPropagation, disabled]
+  );
+
+  return (
+    <RouterNavLink onClick={handleClick} ref={ref} {...otherProps}>
+      {children}
+    </RouterNavLink>
+  );
+}
+
+const NavLink = React.forwardRef(NavLinkWithRef);
+export default NavLink;
diff --git a/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx b/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx
new file mode 100644 (file)
index 0000000..07de4f5
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import { findDOMNode } from 'react-dom';
+
+export type MouseEventListener = 'click' | 'mousedown';
+interface Props {
+  children: React.ReactNode;
+  listenerType?: MouseEventListener;
+  onClickOutside: () => void;
+}
+
+export default class OutsideClickHandler extends React.Component<Props> {
+  mounted = false;
+
+  componentDidMount() {
+    this.mounted = true;
+    setTimeout(() => {
+      this.addClickHandler();
+    }, 0);
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+    this.removeClickHandler();
+  }
+
+  addClickHandler = () => {
+    const { listenerType = 'click' } = this.props;
+    window.addEventListener(listenerType, this.handleWindowClick);
+  };
+
+  removeClickHandler = () => {
+    const { listenerType = 'click' } = this.props;
+    window.removeEventListener(listenerType, this.handleWindowClick);
+  };
+
+  handleWindowClick = (event: MouseEvent) => {
+    if (this.mounted) {
+      // eslint-disable-next-line react/no-find-dom-node
+      const node = findDOMNode(this);
+      if (!node || !node.contains(event.target as Node)) {
+        this.props.onClickOutside();
+      }
+    }
+  };
+
+  render() {
+    return this.props.children;
+  }
+}
diff --git a/server/sonar-web/design-system/src/components/RadioButton.tsx b/server/sonar-web/design-system/src/components/RadioButton.tsx
new file mode 100644 (file)
index 0000000..89858e7
--- /dev/null
@@ -0,0 +1,125 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor } from '../helpers/theme';
+
+type AllowedRadioButtonAttributes = Pick<
+  React.InputHTMLAttributes<HTMLInputElement>,
+  'aria-label' | 'autoFocus' | 'id' | 'name' | 'style' | 'title' | 'type'
+>;
+
+interface Props extends AllowedRadioButtonAttributes {
+  checked: boolean;
+  children?: React.ReactNode;
+  className?: string;
+  disabled?: boolean;
+  onCheck: (value: string) => void;
+  value: string;
+}
+
+export default function RadioButton({
+  checked,
+  children,
+  className,
+  disabled,
+  onCheck,
+  value,
+  ...htmlProps
+}: Props) {
+  const handleChange = () => {
+    if (!disabled) {
+      onCheck(value);
+    }
+  };
+
+  return (
+    <label className={classNames('sw-flex sw-items-center', className)}>
+      <RadioButtonStyled
+        aria-disabled={disabled}
+        checked={checked}
+        disabled={disabled}
+        onChange={handleChange}
+        type="radio"
+        value={value}
+        {...htmlProps}
+      />
+      {children}
+    </label>
+  );
+}
+
+export const RadioButtonStyled = styled.input`
+  appearance: none; //disables native style
+  border: ${themeBorder('default', 'radioBorder')};
+
+  ${tw`sw-w-4 sw-min-w-4 sw-h-4 sw-min-h-4`}
+  ${tw`sw-p-1 sw-mr-2`}
+  ${tw`sw-inline-block`}
+  ${tw`sw-box-border`}
+  ${tw`sw-rounded-pill`}
+
+  &:hover {
+    background: ${themeColor('radioHover')};
+  }
+
+  &:focus,
+  &:focus-visible {
+    background: ${themeColor('radioHover')};
+    border: ${themeBorder('default', 'radioFocusBorder')};
+    outline: ${themeBorder('focus', 'radioFocusOutline')};
+  }
+
+  &:focus:checked,
+  &:focus-visible:checked,
+  &:hover:checked,
+  &:checked {
+    // Color cannot be used with multiple backgrounds, only image is allowed
+    background-image: linear-gradient(to right, ${themeColor('radio')}, ${themeColor('radio')}),
+      linear-gradient(to right, ${themeColor('radioChecked')}, ${themeColor('radioChecked')});
+    background-clip: content-box, padding-box;
+    border: ${themeBorder('default', 'radioBorder')};
+  }
+
+  &:disabled {
+    background: ${themeColor('radioDisabledBackground')};
+    border: ${themeBorder('default', 'radioDisabledBorder')};
+    background-clip: unset;
+
+    ${tw`sw-cursor-not-allowed`}
+
+    &:checked {
+      background-image: linear-gradient(
+          to right,
+          ${themeColor('radioDisabled')},
+          ${themeColor('radioDisabled')}
+        ),
+        linear-gradient(
+          to right,
+          ${themeColor('radioDisabledBackground')},
+          ${themeColor('radioDisabledBackground')}
+        );
+      background-clip: content-box, padding-box;
+      border: ${themeBorder('default', 'radioDisabledBorder')};
+    }
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/SonarQubeLogo.tsx b/server/sonar-web/design-system/src/components/SonarQubeLogo.tsx
new file mode 100644 (file)
index 0000000..fcbe6b2
--- /dev/null
@@ -0,0 +1,50 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+
+const SonarQubeLogoSvg = styled.svg`
+  height: 40px;
+  width: 132px;
+`;
+
+export function SonarQubeLogo() {
+  return (
+    <SonarQubeLogoSvg viewBox="0 0 540.33 156.33" xmlns="http://www.w3.org/2000/svg">
+      <path
+        d="M11.89 101.92a29.92 29.92 0 0 0 13.23 3.74c4.65 0 6.57-1.62 6.57-4.14s-1.51-3.74-7.27-5.66c-10.21-3.44-14.15-9-14-14.85 0-9.2 7.89-16.17 20.11-16.17a33.07 33.07 0 0 1 13.95 2.83l-2.78 10.6A24.24 24.24 0 0 0 31 75.44c-3.74 0-5.87 1.51-5.87 4 0 2.33 1.93 3.54 8 5.66 9.4 3.23 13.34 8 13.44 15.26 0 9.19-7.27 16-21.42 16-6.47 0-12.22-1.42-16-3.44zM100.63 90.09c0 18.09-12.83 26.38-26.08 26.38C60.11 116.48 49 107 49 91s10.5-26.17 26.37-26.17c15.16 0 25.26 10.41 25.26 25.26zm-35.78.51c0 8.49 3.54 14.85 10.11 14.85 6 0 9.8-6 9.8-14.85 0-7.38-2.83-14.87-9.8-14.87-7.37.01-10.11 7.59-10.11 14.87zM106.11 81.71c0-6.16-.2-11.42-.41-15.76H119l.7 6.76h.31a18.08 18.08 0 0 1 15.25-7.88c10.11 0 17.69 6.66 17.69 21.22v29.31h-15.31V88c0-6.37-2.22-10.71-7.78-10.71a8.18 8.18 0 0 0-7.78 5.71 10.41 10.41 0 0 0-.61 3.84v28.51h-15.36zM189.39 115.36l-.91-5h-.3c-3.23 3.95-8.3 6.07-14.15 6.07-10 0-16-7.29-16-15.16 0-12.83 11.52-19 29-18.91v-.7c0-2.63-1.42-6.37-9-6.37a27.8 27.8 0 0 0-13.64 3.73l-2.84-9.9c3.44-1.93 10.21-4.35 19.2-4.35 16.48 0 21.73 9.7 21.73 21.32v17.18a75.92 75.92 0 0 0 .71 12zM187.58 92c-8.08-.1-14.35 1.83-14.35 7.78 0 3.95 2.63 5.87 6.07 5.87a8.39 8.39 0 0 0 8-5.66 10.87 10.87 0 0 0 .31-2.63zM210.63 82.21c0-7.27-.2-12-.41-16.26h13.24L224 75h.4c2.53-7.17 8.59-10.2 13.34-10.2a16.56 16.56 0 0 1 3.26.2v14.48a21.82 21.82 0 0 0-4.14-.41c-5.66 0-9.5 3-10.52 7.78a18.94 18.94 0 0 0-.3 3.44v25.07h-15.41zM342.35 102c0 5 .1 9.5.41 13.34h-7.89l-.51-8h-.19a18.43 18.43 0 0 1-16.17 9.1c-7.68 0-16.89-4.24-16.89-21.42V66.44H310v27.09c0 9.29 2.83 15.57 10.92 15.57a12.88 12.88 0 0 0 11.72-8.1 13.15 13.15 0 0 0 .81-4.55v-30h8.9zM352.67 115.36c.2-3.34.4-8.3.4-12.64V43.6h8.79v30.73h.2c3.13-5.46 8.79-9 16.68-9 12.12 0 20.71 10.11 20.61 25 0 17.49-11 26.18-21.92 26.18-7.08 0-12.73-2.73-16.37-9.2h-.31l-.4 8.09zm9.19-19.61a16.48 16.48 0 0 0 .41 3.23 13.71 13.71 0 0 0 13.33 10.41c9.31 0 14.85-7.58 14.85-18.79 0-9.8-5-18.19-14.55-18.19a14.17 14.17 0 0 0-13.54 10.91 17.47 17.47 0 0 0-.51 3.64zM411.5 92.52c.19 12 7.88 17 16.77 17a32.24 32.24 0 0 0 13.54-2.52l1.52 6.37c-3.13 1.41-8.49 3-16.27 3-15.06 0-24.06-9.9-24.06-24.65s8.69-26.38 22.94-26.38c16 0 20.21 14 20.21 23a33.67 33.67 0 0 1-.3 4.14zm26.07-6.37c.1-5.66-2.31-14.46-12.32-14.46-9 0-12.94 8.3-13.65 14.46z"
+        fill="#1b171b"
+      />
+      <path
+        d="M290.55 75.25a26.41 26.41 0 1 0-11.31 39.07l10.22 16.6 8.11-5.51-10.22-16.6a26.42 26.42 0 0 0 3.2-33.56M279.1 105.4a18.5 18.5 0 1 1 4.9-25.7 18.52 18.52 0 0 1-4.9 25.7"
+        fill="#1b171b"
+        fillRule="evenodd"
+      />
+      <path
+        d="M506.94 115.57h-6.27c0-50.44-41.62-91.48-92.78-91.48v-6.26c54.62 0 99.05 43.84 99.05 97.74z"
+        fill="#4e9bcd"
+      />
+      <path
+        d="M511.27 81.93c-7.52-31.65-33.16-58.06-65.27-67.29l1.44-5c33.93 9.74 61 37.65 68.95 71.1zM516.09 52.23a96 96 0 0 0-37.17-41.49l2.17-3.57a100.24 100.24 0 0 1 38.8 43.31z"
+        fill="#4e9bcd"
+      />
+    </SonarQubeLogoSvg>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/Text.tsx b/server/sonar-web/design-system/src/components/Text.tsx
new file mode 100644 (file)
index 0000000..277f5ec
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import tw from 'twin.macro';
+import { themeColor, themeContrast } from '../helpers/theme';
+
+interface MainTextProps {
+  match?: string;
+  name: string;
+}
+
+export function SearchText({ match, name }: MainTextProps) {
+  return match ? (
+    <StyledText
+      // Safe: comes from the search engine, that injects bold tags into component names
+      // eslint-disable-next-line react/no-danger
+      dangerouslySetInnerHTML={{ __html: match }}
+    />
+  ) : (
+    <StyledText title={name}>{name}</StyledText>
+  );
+}
+
+export function TextMuted({ text }: { text: string }) {
+  return <StyledMutedText title={text}>{text}</StyledMutedText>;
+}
+
+export const StyledText = styled.span`
+  ${tw`sw-inline-block`};
+  ${tw`sw-truncate`};
+  ${tw`sw-font-semibold`};
+  ${tw`sw-max-w-abs-600`}
+
+  mark {
+    ${tw`sw-inline-block`};
+
+    background: ${themeColor('searchHighlight')};
+    color: ${themeContrast('searchHighlight')};
+  }
+`;
+
+const StyledMutedText = styled(StyledText)`
+  ${tw`sw-font-regular`};
+  color: ${themeColor('dropdownMenuSubTitle')};
+`;
diff --git a/server/sonar-web/design-system/src/components/Tooltip.tsx b/server/sonar-web/design-system/src/components/Tooltip.tsx
new file mode 100644 (file)
index 0000000..a298b7f
--- /dev/null
@@ -0,0 +1,504 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { keyframes, ThemeContext } from '@emotion/react';
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import { throttle } from 'lodash';
+import React from 'react';
+import { createPortal, findDOMNode } from 'react-dom';
+import tw from 'twin.macro';
+import { THROTTLE_SCROLL_DELAY } from '../helpers/constants';
+import {
+  BasePlacement,
+  PLACEMENT_FLIP_MAP,
+  PopupPlacement,
+  popupPositioning,
+} from '../helpers/positioning';
+import { themeColor, themeContrast } from '../helpers/theme';
+
+const MILLISECONDS_IN_A_SECOND = 1000;
+
+export interface TooltipProps {
+  children: React.ReactElement<{}>;
+  mouseEnterDelay?: number;
+  mouseLeaveDelay?: number;
+  onHide?: VoidFunction;
+  onShow?: VoidFunction;
+  overlay: React.ReactNode;
+  placement?: BasePlacement;
+  visible?: boolean;
+}
+
+interface Measurements {
+  height: number;
+  left: number;
+  leftFix: number;
+  top: number;
+  topFix: number;
+  width: number;
+}
+
+interface OwnState {
+  flipped: boolean;
+  placement?: PopupPlacement;
+  visible: boolean;
+}
+
+type State = OwnState & Partial<Measurements>;
+
+function isMeasured(state: State): state is OwnState & Measurements {
+  return state.height !== undefined;
+}
+
+export default function Tooltip(props: TooltipProps) {
+  // overlay is a ReactNode, so it can be a boolean, `undefined` or `null`
+  // this allows to easily render a tooltip conditionally
+  // more generaly we avoid rendering empty tooltips
+  return props.overlay ? <TooltipInner {...props}>{props.children}</TooltipInner> : props.children;
+}
+
+export class TooltipInner extends React.Component<TooltipProps, State> {
+  throttledPositionTooltip: VoidFunction;
+  mouseEnterTimeout?: number;
+  mouseLeaveTimeout?: number;
+  tooltipNode?: HTMLElement | null;
+  mounted = false;
+  mouseIn = false;
+
+  static defaultProps = {
+    mouseEnterDelay: 0.1,
+  };
+
+  constructor(props: TooltipProps) {
+    super(props);
+    this.state = {
+      flipped: false,
+      placement: props.placement,
+      visible: props.visible !== undefined ? props.visible : false,
+    };
+    this.throttledPositionTooltip = throttle(this.positionTooltip, THROTTLE_SCROLL_DELAY);
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    if (this.props.visible === true) {
+      this.positionTooltip();
+      this.addEventListeners();
+    }
+  }
+
+  componentDidUpdate(prevProps: TooltipProps, prevState: State) {
+    if (this.props.placement !== prevProps.placement) {
+      this.setState({ placement: this.props.placement }, () =>
+        this.onUpdatePlacement(this.hasVisibleChanged(prevState.visible, prevProps.visible))
+      );
+    } else if (this.hasVisibleChanged(prevState.visible, prevProps.visible)) {
+      this.onUpdateVisible();
+    } else if (!this.state.flipped && this.needsFlipping(this.state)) {
+      this.setState(
+        ({ placement = PopupPlacement.Bottom }) => ({
+          flipped: true,
+          placement: PLACEMENT_FLIP_MAP[placement],
+        }),
+        () => {
+          if (this.state.visible) {
+            // Force a re-positioning, as "only" updating the state doesn't
+            // recompute the position, only re-renders with the previous
+            // position (which is no longer correct).
+            this.positionTooltip();
+          }
+        }
+      );
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+    this.removeEventListeners();
+    this.clearTimeouts();
+  }
+
+  static contextType = ThemeContext;
+
+  onUpdatePlacement = (visibleHasChanged: boolean) => {
+    this.setState({ placement: this.props.placement }, () => {
+      if (this.isVisible()) {
+        this.positionTooltip();
+        if (visibleHasChanged) {
+          this.addEventListeners();
+        }
+      }
+    });
+  };
+
+  onUpdateVisible = () => {
+    if (this.isVisible()) {
+      this.positionTooltip();
+      this.addEventListeners();
+    } else {
+      this.clearPosition();
+      this.removeEventListeners();
+    }
+  };
+
+  addEventListeners = () => {
+    window.addEventListener('resize', this.throttledPositionTooltip);
+    window.addEventListener('scroll', this.throttledPositionTooltip);
+  };
+
+  removeEventListeners = () => {
+    window.removeEventListener('resize', this.throttledPositionTooltip);
+    window.removeEventListener('scroll', this.throttledPositionTooltip);
+  };
+
+  clearTimeouts = () => {
+    window.clearTimeout(this.mouseEnterTimeout);
+    window.clearTimeout(this.mouseLeaveTimeout);
+  };
+
+  hasVisibleChanged = (prevStateVisible: boolean, prevPropsVisible?: boolean) => {
+    if (this.props.visible === undefined) {
+      return prevPropsVisible || this.state.visible !== prevStateVisible;
+    }
+    return this.props.visible !== prevPropsVisible;
+  };
+
+  isVisible = () => {
+    return this.props.visible ?? this.state.visible;
+  };
+
+  getPlacement = (): PopupPlacement => {
+    return this.state.placement || PopupPlacement.Bottom;
+  };
+
+  tooltipNodeRef = (node: HTMLElement | null) => {
+    this.tooltipNode = node;
+  };
+
+  adjustArrowPosition = (
+    placement: PopupPlacement,
+    { leftFix, topFix, height, width }: Measurements
+  ) => {
+    switch (placement) {
+      case PopupPlacement.Left:
+      case PopupPlacement.Right:
+        return {
+          marginTop: Math.max(0, Math.min(-topFix, height / 2 - ARROW_WIDTH * 2)),
+        };
+      default:
+        return {
+          marginLeft: Math.max(0, Math.min(-leftFix, width / 2 - ARROW_WIDTH * 2)),
+        };
+    }
+  };
+
+  positionTooltip = () => {
+    // `findDOMNode(this)` will search for the DOM node for the current component
+    // first it will find a React.Fragment (see `render`),
+    // so it will get the DOM node of the first child, i.e. DOM node of `this.props.children`
+    // docs: https://reactjs.org/docs/refs-and-the-dom.html#exposing-dom-refs-to-parent-components
+
+    // eslint-disable-next-line react/no-find-dom-node
+    const toggleNode = findDOMNode(this);
+    if (toggleNode && toggleNode instanceof Element && this.tooltipNode) {
+      const { height, left, leftFix, top, topFix, width } = popupPositioning(
+        toggleNode,
+        this.tooltipNode,
+        this.getPlacement()
+      );
+
+      // save width and height (and later set in `render`) to avoid resizing the popup element,
+      // when it's placed close to the window edge
+      this.setState({
+        left: window.scrollX + left,
+        leftFix,
+        top: window.scrollY + top,
+        topFix,
+        width,
+        height,
+      });
+    }
+  };
+
+  clearPosition = () => {
+    this.setState({
+      flipped: false,
+      left: undefined,
+      leftFix: undefined,
+      top: undefined,
+      topFix: undefined,
+      width: undefined,
+      height: undefined,
+      placement: this.props.placement,
+    });
+  };
+
+  handlePointerEnter = () => {
+    this.mouseEnterTimeout = window.setTimeout(() => {
+      // for some reason even after the `this.mouseEnterTimeout` is cleared, it still triggers
+      // to workaround this issue, check that its value is not `undefined`
+      // (if it's `undefined`, it means the timer has been reset)
+      if (
+        this.mounted &&
+        this.props.visible === undefined &&
+        this.mouseEnterTimeout !== undefined
+      ) {
+        this.setState({ visible: true });
+      }
+    }, (this.props.mouseEnterDelay || 0) * MILLISECONDS_IN_A_SECOND);
+
+    if (this.props.onShow) {
+      this.props.onShow();
+    }
+  };
+
+  handlePointerLeave = () => {
+    if (this.mouseEnterTimeout !== undefined) {
+      window.clearTimeout(this.mouseEnterTimeout);
+      this.mouseEnterTimeout = undefined;
+    }
+
+    if (!this.mouseIn) {
+      this.mouseLeaveTimeout = window.setTimeout(() => {
+        if (this.mounted && this.props.visible === undefined && !this.mouseIn) {
+          this.setState({ visible: false });
+        }
+      }, (this.props.mouseLeaveDelay || 0) * MILLISECONDS_IN_A_SECOND);
+
+      if (this.props.onHide) {
+        this.props.onHide();
+      }
+    }
+  };
+
+  handleOverlayPointerEnter = () => {
+    this.mouseIn = true;
+  };
+
+  handleOverlayPointerLeave = () => {
+    this.mouseIn = false;
+    this.handlePointerLeave();
+  };
+
+  handleChildPointerEnter = () => {
+    this.handlePointerEnter();
+
+    const { children } = this.props;
+    if (typeof children.props.onPointerEnter === 'function') {
+      children.props.onPointerEnter();
+    }
+  };
+
+  handleChildPointerLeave = () => {
+    this.handlePointerLeave();
+
+    const { children } = this.props;
+    if (typeof children.props.onPointerLeave === 'function') {
+      children.props.onPointerLeave();
+    }
+  };
+
+  needsFlipping = ({ leftFix, topFix }: State) => {
+    // We can live with a tooltip that's slightly positioned over the toggle
+    // node. Only trigger if it really starts overlapping, as the re-positioning
+    // is quite expensive, needing 2 re-renders.
+    const repositioningThreshold = 8;
+    switch (this.getPlacement()) {
+      case PopupPlacement.Left:
+      case PopupPlacement.Right:
+        return Boolean(leftFix && Math.abs(leftFix) > repositioningThreshold);
+      case PopupPlacement.Top:
+      case PopupPlacement.Bottom:
+        return Boolean(topFix && Math.abs(topFix) > repositioningThreshold);
+      default:
+        return false;
+    }
+  };
+
+  render() {
+    const placement = this.getPlacement();
+    const style = isMeasured(this.state)
+      ? {
+          left: this.state.left,
+          top: this.state.top,
+          width: this.state.width,
+          height: this.state.height,
+        }
+      : undefined;
+
+    return (
+      <>
+        {React.cloneElement(this.props.children, {
+          onPointerEnter: this.handleChildPointerEnter,
+          onPointerLeave: this.handleChildPointerLeave,
+        })}
+        {this.isVisible() && (
+          <TooltipPortal>
+            <TooltipWrapper
+              className={classNames(placement)}
+              onPointerEnter={this.handleOverlayPointerEnter}
+              onPointerLeave={this.handleOverlayPointerLeave}
+              ref={this.tooltipNodeRef}
+              role="tooltip"
+              style={style}
+            >
+              <TooltipWrapperInner>{this.props.overlay}</TooltipWrapperInner>
+              <TooltipWrapperArrow
+                style={
+                  isMeasured(this.state)
+                    ? this.adjustArrowPosition(placement, this.state)
+                    : undefined
+                }
+              />
+            </TooltipWrapper>
+          </TooltipPortal>
+        )}
+      </>
+    );
+  }
+}
+
+class TooltipPortal extends React.Component {
+  el: HTMLElement;
+
+  constructor(props: {}) {
+    super(props);
+    this.el = document.createElement('div');
+  }
+
+  componentDidMount() {
+    document.body.appendChild(this.el);
+  }
+
+  componentWillUnmount() {
+    document.body.removeChild(this.el);
+  }
+
+  render() {
+    return createPortal(this.props.children, this.el);
+  }
+}
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
+`;
+
+const ARROW_WIDTH = 6;
+const ARROW_HEIGHT = 7;
+const ARROW_MARGIN = 3;
+
+export const TooltipWrapper = styled.div`
+  animation: ${fadeIn} 0.3s forwards;
+
+  ${tw`sw-absolute`}
+  ${tw`sw-z-tooltip`};
+  ${tw`sw-block`};
+  ${tw`sw-box-border`};
+  ${tw`sw-h-auto`};
+  ${tw`sw-body-sm`};
+
+  &.top {
+    margin-top: -${ARROW_MARGIN}px;
+    padding: ${ARROW_HEIGHT}px 0;
+  }
+
+  &.right {
+    margin-left: ${ARROW_MARGIN}px;
+    padding: 0 ${ARROW_HEIGHT}px;
+  }
+
+  &.bottom {
+    margin-top: ${ARROW_MARGIN}px;
+    padding: ${ARROW_HEIGHT}px 0;
+  }
+
+  &.left {
+    margin-left: -${ARROW_MARGIN}px;
+    padding: 0 ${ARROW_HEIGHT}px;
+  }
+`;
+
+const TooltipWrapperArrow = styled.div`
+  ${tw`sw-absolute`};
+  ${tw`sw-w-0`};
+  ${tw`sw-h-0`};
+  ${tw`sw-border-solid`};
+  ${tw`sw-border-transparent`};
+  ${TooltipWrapper}.top & {
+    border-width: ${ARROW_HEIGHT}px ${ARROW_WIDTH}px 0;
+    border-top-color: ${themeColor('tooltipBackground')};
+    transform: translateX(-${ARROW_WIDTH}px);
+
+    ${tw`sw-bottom-0`};
+    ${tw`sw-left-1/2`};
+  }
+
+  ${TooltipWrapper}.right & {
+    border-width: ${ARROW_WIDTH}px ${ARROW_HEIGHT}px ${ARROW_WIDTH}px 0;
+    border-right-color: ${themeColor('tooltipBackground')};
+    transform: translateY(-${ARROW_WIDTH}px);
+
+    ${tw`sw-top-1/2`};
+    ${tw`sw-left-0`};
+  }
+
+  ${TooltipWrapper}.left & {
+    border-width: ${ARROW_WIDTH}px 0 ${ARROW_WIDTH}px ${ARROW_HEIGHT}px;
+    border-left-color: ${themeColor('tooltipBackground')};
+    transform: translateY(-${ARROW_WIDTH}px);
+
+    ${tw`sw-top-1/2`};
+    ${tw`sw-right-0`};
+  }
+
+  ${TooltipWrapper}.bottom & {
+    border-width: 0 ${ARROW_WIDTH}px ${ARROW_HEIGHT}px;
+    border-bottom-color: ${themeColor('tooltipBackground')};
+    transform: translateX(-${ARROW_WIDTH}px);
+
+    ${tw`sw-top-0`};
+    ${tw`sw-left-1/2`};
+  }
+`;
+
+export const TooltipWrapperInner = styled.div`
+  color: ${themeContrast('tooltipBackground')};
+  background-color: ${themeColor('tooltipBackground')};
+
+  ${tw`sw-max-w-[22rem]`}
+  ${tw`sw-py-3 sw-px-4`};
+  ${tw`sw-overflow-hidden`};
+  ${tw`sw-text-left`};
+  ${tw`sw-no-underline`};
+  ${tw`sw-break-words`};
+  ${tw`sw-rounded-2`};
+
+  hr {
+    background-color: ${themeColor('tooltipSeparator')};
+
+    ${tw`sw-mx-4`};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx
new file mode 100644 (file)
index 0000000..d0aa180
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+
+import { fireEvent, screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { Avatar } from '../Avatar';
+
+const gravatarServerUrl = 'http://example.com/{EMAIL_MD5}.jpg?s={SIZE}';
+
+it('should render avatar with border', () => {
+  setupWithProps({ border: true, hash: '7daf6c79d4802916d83f6266e24850af' });
+  expect(screen.getByRole('img')).toHaveStyle('border: 1px solid rgb(225,230,243)');
+});
+
+it('should be able to render with hash only', () => {
+  setupWithProps({ hash: '7daf6c79d4802916d83f6266e24850af' });
+  expect(screen.getByRole('img')).toHaveAttribute(
+    'src',
+    'http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=48'
+  );
+});
+
+it('should fall back to generated on error', () => {
+  setupWithProps({ hash: '7daf6c79d4802916d83f6266e24850af' });
+  fireEvent(screen.getByRole('img'), new Event('error'));
+  expect(screen.getByRole('img')).not.toHaveAttribute('src');
+});
+
+it('should fall back to dummy avatar', () => {
+  setupWithProps({ enableGravatar: false });
+  expect(screen.getByRole('img')).not.toHaveAttribute('src');
+});
+
+it('should return null if no name is set', () => {
+  setupWithProps({ name: undefined });
+  expect(screen.queryByRole('img')).not.toBeInTheDocument();
+});
+
+it('should display organization avatar correctly', () => {
+  const avatar = 'http://example.com/avatar.png';
+  setupWithProps({ organizationAvatar: avatar, organizationName: 'my-org' });
+  expect(screen.getByRole('img')).toHaveAttribute('src', avatar);
+});
+
+function setupWithProps(props: Partial<FCProps<typeof Avatar>> = {}) {
+  return render(
+    <Avatar enableGravatar={true} gravatarServerUrl={gravatarServerUrl} name="foo" {...props} />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx
new file mode 100644 (file)
index 0000000..d6b7c43
--- /dev/null
@@ -0,0 +1,73 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { render, screen } from '@testing-library/react';
+import * as React from 'react';
+import DeferredSpinner from '../DeferredSpinner';
+
+beforeAll(() => {
+  jest.useFakeTimers();
+});
+
+afterEach(() => {
+  jest.runOnlyPendingTimers();
+});
+
+afterAll(() => {
+  jest.useRealTimers();
+});
+
+it('renders children before timeout', () => {
+  renderDeferredSpinner({ children: <a href="#">foo</a> });
+  expect(screen.getByRole('link')).toBeInTheDocument();
+  jest.runAllTimers();
+  expect(screen.queryByRole('link')).not.toBeInTheDocument();
+});
+
+it('renders spinner after timeout', () => {
+  renderDeferredSpinner();
+  expect(screen.queryByLabelText('loading')).not.toBeInTheDocument();
+  jest.runAllTimers();
+  expect(screen.getByLabelText('loading')).toBeInTheDocument();
+});
+
+it('allows setting a custom class name', () => {
+  renderDeferredSpinner({ className: 'foo' });
+  jest.runAllTimers();
+  expect(screen.getByLabelText('loading')).toHaveClass('foo');
+});
+
+it('can be controlled by the loading prop', () => {
+  const { rerender } = renderDeferredSpinner({ loading: true });
+  jest.runAllTimers();
+  expect(screen.getByLabelText('loading')).toBeInTheDocument();
+
+  rerender(prepareDeferredSpinner({ loading: false }));
+  expect(screen.queryByLabelText('loading')).not.toBeInTheDocument();
+});
+
+function renderDeferredSpinner(props: Partial<DeferredSpinner['props']> = {}) {
+  // We don't use our renderComponent() helper here, as we have some tests that
+  // require changes in props.
+  return render(prepareDeferredSpinner(props));
+}
+
+function prepareDeferredSpinner(props: Partial<DeferredSpinner['props']> = {}) {
+  return <DeferredSpinner {...props} />;
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx
new file mode 100644 (file)
index 0000000..52139a0
--- /dev/null
@@ -0,0 +1,65 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import { renderWithRouter } from '../../helpers/testUtils';
+import { ButtonSecondary } from '../buttons';
+import Dropdown, { ActionsDropdown } from '../Dropdown';
+
+describe('Dropdown', () => {
+  it('renders', async () => {
+    const { user } = setupWithChildren();
+    expect(screen.getByRole('button')).toBeInTheDocument();
+
+    await user.click(screen.getByRole('button'));
+    expect(screen.getByRole('menu')).toBeInTheDocument();
+  });
+
+  it('toggles with render prop', async () => {
+    const { user } = setupWithChildren(({ onToggleClick }) => (
+      <ButtonSecondary onClick={onToggleClick} />
+    ));
+
+    await user.click(screen.getByRole('button'));
+    expect(screen.getByRole('menu')).toBeVisible();
+  });
+
+  function setupWithChildren(children?: Dropdown['props']['children']) {
+    return renderWithRouter(
+      <Dropdown id="test-menu" overlay={<div id="overlay" />}>
+        {children ?? <ButtonSecondary />}
+      </Dropdown>
+    );
+  }
+});
+
+describe('ActionsDropdown', () => {
+  it('renders', () => {
+    setup();
+    expect(screen.getByRole('button')).toHaveAccessibleName('menu');
+  });
+
+  function setup() {
+    return renderWithRouter(
+      <ActionsDropdown id="test-menu">
+        <div id="overlay" />
+      </ActionsDropdown>
+    );
+  }
+});
diff --git a/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx
new file mode 100644 (file)
index 0000000..350c687
--- /dev/null
@@ -0,0 +1,100 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import { noop } from 'lodash';
+import { render, renderWithRouter } from '../../helpers/testUtils';
+import {
+  DropdownMenu,
+  ItemButton,
+  ItemCheckbox,
+  ItemCopy,
+  ItemDangerButton,
+  ItemDivider,
+  ItemHeader,
+  ItemLink,
+  ItemNavLink,
+  ItemRadioButton,
+} from '../DropdownMenu';
+import MenuIcon from '../icons/MenuIcon';
+import Tooltip from '../Tooltip';
+
+beforeEach(() => {
+  jest.useFakeTimers();
+});
+
+afterEach(() => {
+  jest.runOnlyPendingTimers();
+  jest.useRealTimers();
+});
+
+it('should render a full menu correctly', () => {
+  renderDropdownMenu();
+  expect(screen.getByRole('menuitem', { name: 'My header' })).toBeInTheDocument();
+  expect(screen.getByRole('menuitem', { name: 'Test menu item' })).toBeInTheDocument();
+  expect(screen.getByRole('menuitem', { name: 'Test disabled item' })).toHaveClass('disabled');
+});
+
+it('menu items should work with tooltips', async () => {
+  const { user } = render(
+    <Tooltip overlay="test tooltip">
+      <ItemButton onClick={jest.fn()}>button</ItemButton>
+    </Tooltip>,
+    {},
+    { delay: null }
+  );
+
+  expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+  await user.hover(screen.getByRole('menuitem'));
+  expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+  jest.runAllTimers();
+  expect(screen.getByRole('tooltip')).toBeVisible();
+
+  await user.unhover(screen.getByRole('menuitem'));
+  expect(screen.getByRole('tooltip')).toBeVisible();
+
+  jest.runAllTimers();
+  expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+});
+
+function renderDropdownMenu() {
+  return renderWithRouter(
+    <DropdownMenu>
+      <ItemHeader>My header</ItemHeader>
+      <ItemNavLink to="/test">Test menu item</ItemNavLink>
+      <ItemDivider />
+      <ItemLink disabled={true} to="/test-disabled">
+        Test disabled item
+      </ItemLink>
+      <ItemButton icon={<MenuIcon />} onClick={noop}>
+        Button
+      </ItemButton>
+      <ItemDangerButton onClick={noop}>DangerButton</ItemDangerButton>
+      <ItemCopy copyValue="copy">Copy</ItemCopy>
+      <ItemCheckbox checked={true} onCheck={noop}>
+        Checkbox item
+      </ItemCheckbox>
+      <ItemRadioButton checked={false} onCheck={noop} value="radios">
+        Radio item
+      </ItemRadioButton>
+    </DropdownMenu>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx b/server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx
new file mode 100644 (file)
index 0000000..83b7fdf
--- /dev/null
@@ -0,0 +1,51 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { GenericAvatar } from '../GenericAvatar';
+import { CustomIcon, IconProps } from '../icons/Icon';
+
+function TestIcon(props: IconProps) {
+  return (
+    <CustomIcon {...props}>
+      <path d="l10 10" />
+    </CustomIcon>
+  );
+}
+
+it('should render single word and size', () => {
+  render(<GenericAvatar name="foo" size={15} />);
+  const image = screen.getByRole('img');
+  expect(image).toHaveAttribute('size', '15');
+  expect(screen.getByText('F')).toBeInTheDocument();
+});
+
+it('should render multiple word with default size', () => {
+  render(<GenericAvatar name="foo bar" />);
+  const image = screen.getByRole('img');
+  expect(image).toHaveAttribute('size', '24');
+  expect(screen.getByText('F')).toBeInTheDocument();
+});
+
+it('should render without name', () => {
+  render(<GenericAvatar Icon={TestIcon} name="" size={32} />);
+  const image = screen.getByRole('img');
+  expect(image).toHaveAttribute('size', '32');
+});
diff --git a/server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx b/server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx
new file mode 100644 (file)
index 0000000..1d9f606
--- /dev/null
@@ -0,0 +1,90 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import InputSearch from '../InputSearch';
+
+it('should warn when input is too short', async () => {
+  const { user } = setupWithProps({ value: 'f' });
+  expect(screen.getByRole('note')).toBeInTheDocument();
+  await user.type(screen.getByRole('searchbox'), 'oo');
+  expect(screen.queryByRole('note')).not.toBeInTheDocument();
+});
+
+it('should show clear button only when there is a value', async () => {
+  const { user } = setupWithProps({ value: 'f' });
+  expect(screen.getByRole('button')).toBeInTheDocument();
+  await user.clear(screen.getByRole('searchbox'));
+  expect(screen.queryByRole('button')).not.toBeInTheDocument();
+});
+
+it('should attach ref', () => {
+  const ref = jest.fn();
+  setupWithProps({ innerRef: ref });
+  expect(ref).toHaveBeenCalled();
+  expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLInputElement);
+});
+
+it('should trigger reset correctly with clear button', async () => {
+  const onChange = jest.fn();
+  const { user } = setupWithProps({ onChange });
+  await user.click(screen.getByRole('button'));
+  expect(onChange).toHaveBeenCalledWith('');
+});
+
+it('should trigger change correctly', async () => {
+  const onChange = jest.fn();
+  const { user } = setupWithProps({ onChange, value: 'f' });
+  await user.type(screen.getByRole('searchbox'), 'oo');
+  await waitFor(() => {
+    expect(onChange).toHaveBeenCalledWith('foo');
+  });
+});
+
+it('should not change when value is too short', async () => {
+  const onChange = jest.fn();
+  const { user } = setupWithProps({ onChange, value: '', minLength: 3 });
+  await user.type(screen.getByRole('searchbox'), 'fo');
+  expect(onChange).not.toHaveBeenCalled();
+});
+
+it('should clear input using escape', async () => {
+  const onChange = jest.fn();
+  const { user } = setupWithProps({ onChange, value: 'foo' });
+  await user.type(screen.getByRole('searchbox'), '{Escape}');
+  expect(onChange).toHaveBeenCalledWith('');
+});
+
+function setupWithProps(props: Partial<FCProps<typeof InputSearch>> = {}) {
+  return render(
+    <InputSearch
+      clearIconAriaLabel=""
+      maxLength={150}
+      minLength={2}
+      onChange={jest.fn()}
+      placeholder="placeholder"
+      searchInputAriaLabel=""
+      tooShortText=""
+      value="foo"
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/Link-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Link-test.tsx
new file mode 100644 (file)
index 0000000..2954697
--- /dev/null
@@ -0,0 +1,129 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import React from 'react';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { render } from '../../helpers/testUtils';
+import Link, { DiscreetLink } from '../Link';
+
+beforeAll(() => {
+  const { location } = window;
+  delete (window as any).location;
+  window.location = { ...location, href: '' };
+});
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+// This functionality won't be needed once we update the breadcrumbs
+it('should remove focus after link is clicked', async () => {
+  const { user } = setupWithMemoryRouter(
+    <Link blurAfterClick={true} icon={<div>Icon</div>} to="/initial" />
+  );
+
+  await user.click(screen.getByRole('link'));
+
+  expect(screen.getByRole('link')).not.toHaveFocus();
+});
+
+it('should prevent default when preventDefault is true', async () => {
+  const { user } = setupWithMemoryRouter(<Link preventDefault={true} to="/second" />);
+
+  expect(screen.getByText('/initial')).toBeVisible();
+
+  await user.click(screen.getByRole('link'));
+
+  // prevent default behavior of page navigation
+  expect(screen.getByText('/initial')).toBeVisible();
+  expect(screen.queryByText('/second')).not.toBeInTheDocument();
+});
+
+it('should stop propagation when stopPropagation is true', async () => {
+  const buttonOnClick = jest.fn();
+
+  const { user } = setupWithMemoryRouter(
+    <button onClick={buttonOnClick} type="button">
+      <Link stopPropagation={true} to="/second" />
+    </button>
+  );
+
+  await user.click(screen.getByRole('link'));
+
+  expect(buttonOnClick).not.toHaveBeenCalled();
+});
+
+it('should call onClick when one is passed', async () => {
+  const onClick = jest.fn();
+  const { user } = setupWithMemoryRouter(
+    <Link onClick={onClick} stopPropagation={true} to="/second" />
+  );
+
+  await user.click(screen.getByRole('link'));
+
+  expect(onClick).toHaveBeenCalled();
+});
+
+it('internal link should be clickable', async () => {
+  const { user } = setupWithMemoryRouter(<Link to="/second">internal link</Link>);
+  expect(screen.getByRole('link')).toBeVisible();
+
+  await user.click(screen.getByRole('link'));
+
+  expect(screen.getByText('/second')).toBeVisible();
+});
+
+it('external links are indicated by OpenNewTabIcon', () => {
+  setupWithMemoryRouter(<Link to="https://google.com">external link</Link>);
+  expect(screen.getByRole('link')).toBeVisible();
+
+  expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
+});
+
+it('discreet links also can be external indicated by the OpenNewTabIcon', () => {
+  setupWithMemoryRouter(<DiscreetLink to="https://google.com">external link</DiscreetLink>);
+  expect(screen.getByRole('link')).toBeVisible();
+
+  expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
+});
+
+function ShowPath() {
+  const { pathname } = useLocation();
+  return <pre>{pathname}</pre>;
+}
+
+const setupWithMemoryRouter = (component: JSX.Element, initialEntries = ['/initial']) => {
+  return render(
+    <MemoryRouter initialEntries={initialEntries}>
+      <Routes>
+        <Route
+          element={
+            <>
+              {component}
+              <ShowPath />
+            </>
+          }
+          path="/initial"
+        />
+        <Route element={<ShowPath />} path="/second" />
+      </Routes>
+    </MemoryRouter>
+  );
+};
diff --git a/server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx b/server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx
new file mode 100644 (file)
index 0000000..fdc66f2
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+
+import { screen } from '@testing-library/react';
+import { LAYOUT_LOGO_MAX_HEIGHT, LAYOUT_LOGO_MAX_WIDTH } from '../../helpers/constants';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { MainAppBar } from '../MainAppBar';
+import { SonarQubeLogo } from '../SonarQubeLogo';
+
+it('should render the main app bar with max-height and max-width constraints on the logo', () => {
+  setupWithProps();
+
+  expect(screen.getByRole('img')).toHaveStyle({
+    border: 'none',
+    'max-height': `${LAYOUT_LOGO_MAX_HEIGHT}px`,
+    'max-width': `${LAYOUT_LOGO_MAX_WIDTH}px`,
+    'object-fit': 'contain',
+  });
+});
+
+it('should render the logo', () => {
+  const element = setupWithProps({ Logo: SonarQubeLogo });
+
+  // eslint-disable-next-line testing-library/no-node-access
+  expect(element.container.querySelector('svg')).toHaveStyle({ height: '40px', width: '132px' });
+});
+
+function setupWithProps(
+  props: FCProps<typeof MainAppBar> = {
+    Logo: () => <img alt="logo" src="http://example.com/logo.png" />,
+  }
+) {
+  return render(<MainAppBar {...props} />);
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx b/server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx
new file mode 100644 (file)
index 0000000..b3120af
--- /dev/null
@@ -0,0 +1,64 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+
+import { screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { MainMenuItem } from '../MainMenuItem';
+
+it('should render default', () => {
+  render(
+    <MainMenuItem>
+      <a>Hi</a>
+    </MainMenuItem>
+  );
+
+  expect(screen.getByText('Hi')).toHaveStyle({
+    color: 'rgb(62, 67, 87)',
+    'border-bottom': '3px solid transparent',
+  });
+});
+
+it('should render active link', () => {
+  render(
+    <MainMenuItem>
+      <a className="active">Hi</a>
+    </MainMenuItem>
+  );
+
+  expect(screen.getByText('Hi')).toHaveStyle({
+    color: 'rgb(62, 67, 87)',
+    'border-bottom': '3px solid rgba(123,135,217,1)',
+  });
+});
+
+it('should render hovered link', () => {
+  render(
+    <MainMenuItem>
+      <a className="hover">Hi</a>
+    </MainMenuItem>
+  );
+
+  expect(screen.getByText('Hi')).toHaveStyle({
+    color: 'rgb(42, 47, 64)',
+    'border-bottom': '3px solid rgba(123,135,217,1)',
+  });
+});
diff --git a/server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx b/server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx
new file mode 100644 (file)
index 0000000..548cfb6
--- /dev/null
@@ -0,0 +1,112 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import React from 'react';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { render } from '../../helpers/testUtils';
+import NavLink from '../NavLink';
+
+beforeAll(() => {
+  const { location } = window;
+  delete (window as any).location;
+  window.location = { ...location, href: '' };
+});
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+it('should remove focus after link is clicked', async () => {
+  const { user } = setupWithMemoryRouter(<NavLink blurAfterClick={true} to="/initial" />);
+
+  await user.click(screen.getByRole('link'));
+
+  expect(screen.getByRole('link')).not.toHaveFocus();
+});
+
+it('should prevent default when preventDefault is true', async () => {
+  const { user } = setupWithMemoryRouter(<NavLink preventDefault={true} to="/second" />);
+
+  expect(screen.getByText('/initial')).toBeVisible();
+
+  await user.click(screen.getByRole('link'));
+
+  // prevent default behavior of page navigation
+  expect(screen.getByText('/initial')).toBeVisible();
+  expect(screen.queryByText('/second')).not.toBeInTheDocument();
+});
+
+it('should stop propagation when stopPropagation is true', async () => {
+  const buttonOnClick = jest.fn();
+
+  const { user } = setupWithMemoryRouter(
+    <button onClick={buttonOnClick} type="button">
+      <NavLink stopPropagation={true} to="/second" />
+    </button>
+  );
+
+  await user.click(screen.getByRole('link'));
+
+  expect(buttonOnClick).not.toHaveBeenCalled();
+});
+
+it('should call onClick when one is passed', async () => {
+  const onClick = jest.fn();
+  const { user } = setupWithMemoryRouter(
+    <NavLink onClick={onClick} stopPropagation={true} to="/second" />
+  );
+
+  await user.click(screen.getByRole('link'));
+
+  expect(onClick).toHaveBeenCalled();
+});
+
+it('NavLink should be clickable', async () => {
+  const { user } = setupWithMemoryRouter(<NavLink to="/second">internal link</NavLink>);
+  expect(screen.getByRole('link')).toBeVisible();
+
+  await user.click(screen.getByRole('link'));
+
+  expect(screen.getByText('/second')).toBeVisible();
+});
+
+function ShowPath() {
+  const { pathname } = useLocation();
+  return <pre>{pathname}</pre>;
+}
+
+const setupWithMemoryRouter = (component: JSX.Element, initialEntries = ['/initial']) => {
+  return render(
+    <MemoryRouter initialEntries={initialEntries}>
+      <Routes>
+        <Route
+          element={
+            <>
+              {component}
+              <ShowPath />
+            </>
+          }
+          path="/initial"
+        />
+        <Route element={<ShowPath />} path="/second" />
+      </Routes>
+    </MemoryRouter>
+  );
+};
diff --git a/server/sonar-web/design-system/src/components/__tests__/Text-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Text-test.tsx
new file mode 100644 (file)
index 0000000..5743a92
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+
+import { screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { SearchText, TextMuted } from '../Text';
+
+it('should render SearchText', () => {
+  render(<SearchText match="hi" name="hiya" />);
+
+  expect(screen.getByText('hi')).toHaveStyle({
+    'font-weight': '600',
+  });
+});
+
+it('should render TextMuted', () => {
+  render(<TextMuted text="Hi" />);
+
+  expect(screen.getByText('Hi')).toHaveStyle({
+    color: 'rgb(106, 117, 144)',
+  });
+});
diff --git a/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx
new file mode 100644 (file)
index 0000000..8b448d1
--- /dev/null
@@ -0,0 +1,126 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import Tooltip, { TooltipInner } from '../Tooltip';
+
+jest.mock('react-dom', () => {
+  const reactDom = jest.requireActual('react-dom');
+  return { ...reactDom, findDOMNode: jest.fn().mockReturnValue(undefined) };
+});
+
+describe('TooltipInner', () => {
+  it('should open & close', async () => {
+    const onShow = jest.fn();
+    const onHide = jest.fn();
+    const { user } = setupWithProps({ onHide, onShow });
+
+    await user.hover(screen.getByRole('note'));
+    expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+    expect(onShow).toHaveBeenCalled();
+
+    await user.unhover(screen.getByRole('note'));
+    expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+    expect(onHide).toHaveBeenCalled();
+  });
+
+  it('should not shadow children pointer events', async () => {
+    const onShow = jest.fn();
+    const onHide = jest.fn();
+    const onPointerEnter = jest.fn();
+    const onPointerLeave = jest.fn();
+    const { user } = setupWithProps(
+      { onHide, onShow },
+      <div onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave} role="note" />
+    );
+
+    await user.hover(screen.getByRole('note'));
+    expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+    expect(onShow).toHaveBeenCalled();
+    expect(onPointerEnter).toHaveBeenCalled();
+
+    await user.unhover(screen.getByRole('note'));
+    expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+    expect(onHide).toHaveBeenCalled();
+    expect(onPointerLeave).toHaveBeenCalled();
+  });
+
+  it('should not open when mouse goes away quickly', async () => {
+    const { user } = setupWithProps();
+
+    await user.hover(screen.getByRole('note'));
+    await user.unhover(screen.getByRole('note'));
+
+    expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+  });
+
+  it('should position the tooltip correctly', async () => {
+    const onShow = jest.fn();
+    const onHide = jest.fn();
+    const { user } = setupWithProps({ onHide, onShow });
+
+    await user.hover(screen.getByRole('note'));
+    expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+    expect(screen.getByRole('tooltip')).toHaveClass('bottom');
+  });
+
+  function setupWithProps(
+    props: Partial<TooltipInner['props']> = {},
+    children = <div role="note" />
+  ) {
+    return render(
+      <TooltipInner mouseLeaveDelay={0} overlay={<span id="overlay" />} {...props}>
+        {children}
+      </TooltipInner>
+    );
+  }
+});
+
+describe('Tooltip', () => {
+  it('should not render tooltip without overlay', async () => {
+    const { user } = setupWithProps({ overlay: undefined });
+    await user.hover(screen.getByRole('note'));
+    expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+  });
+
+  it('should not render undefined tooltips', async () => {
+    const { user } = setupWithProps({ overlay: undefined, visible: true });
+    await user.hover(screen.getByRole('note'));
+    expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+  });
+
+  it('should not render empty tooltips', async () => {
+    const { user } = setupWithProps({ overlay: '', visible: true });
+    await user.hover(screen.getByRole('note'));
+    expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+  });
+
+  function setupWithProps(
+    props: Partial<FCProps<typeof Tooltip>> = {},
+    children = <div role="note" />
+  ) {
+    return render(
+      <Tooltip overlay={<span id="overlay" />} {...props}>
+        {children}
+      </Tooltip>
+    );
+  }
+});
diff --git a/server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx b/server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx
new file mode 100644 (file)
index 0000000..84a44f1
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { screen, waitForElementToBeRemoved } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithContext } from '../../helpers/testUtils';
+import { ClipboardButton, ClipboardIconButton } from '../clipboard';
+
+beforeEach(() => {
+  jest.useFakeTimers();
+});
+
+afterEach(() => {
+  jest.runOnlyPendingTimers();
+  jest.useRealTimers();
+});
+
+describe('ClipboardButton', () => {
+  it('should display correctly', async () => {
+    /* Delay: null is necessary to play well with fake timers
+     * https://github.com/testing-library/user-event/issues/833
+     */
+    const user = userEvent.setup({ delay: null });
+    renderClipboardButton();
+
+    expect(screen.getByRole('button', { name: 'copy' })).toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: 'copy' }));
+
+    expect(await screen.findByText('copied_action')).toBeVisible();
+
+    await waitForElementToBeRemoved(() => screen.queryByText('copied_action'));
+    jest.runAllTimers();
+  });
+
+  it('should render a custom label if provided', () => {
+    renderClipboardButton('Foo Bar');
+    expect(screen.getByRole('button', { name: 'Foo Bar' })).toBeInTheDocument();
+  });
+
+  function renderClipboardButton(children?: React.ReactNode) {
+    renderWithContext(<ClipboardButton copyValue="foo">{children}</ClipboardButton>);
+  }
+});
+
+describe('ClipboardIconButton', () => {
+  it('should display correctly', () => {
+    renderWithContext(<ClipboardIconButton copyValue="foo" />);
+
+    const copyButton = screen.getByRole('button', { name: 'copy_to_clipboard' });
+    expect(copyButton).toBeInTheDocument();
+  });
+});
diff --git a/server/sonar-web/design-system/src/components/buttons.tsx b/server/sonar-web/design-system/src/components/buttons.tsx
new file mode 100644 (file)
index 0000000..4420263
--- /dev/null
@@ -0,0 +1,219 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import { ThemedProps } from '../types/theme';
+import { BaseLink, LinkProps } from './Link';
+
+type AllowedButtonAttributes = Pick<
+  React.ButtonHTMLAttributes<HTMLButtonElement>,
+  'aria-label' | 'autoFocus' | 'id' | 'name' | 'style' | 'title' | 'type'
+>;
+
+export interface ButtonProps extends AllowedButtonAttributes {
+  children?: React.ReactNode;
+  className?: string;
+  disabled?: boolean;
+  icon?: React.ReactNode;
+  innerRef?: React.Ref<HTMLButtonElement>;
+  onClick?: VoidFunction;
+
+  preventDefault?: boolean;
+  reloadDocument?: LinkProps['reloadDocument'];
+  stopPropagation?: boolean;
+  target?: LinkProps['target'];
+  to?: LinkProps['to'];
+}
+
+class Button extends React.PureComponent<ButtonProps> {
+  handleClick = (event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
+    const { disabled, onClick, stopPropagation = false, type } = this.props;
+    const { preventDefault = type !== 'submit' } = this.props;
+
+    event.currentTarget.blur();
+
+    if (preventDefault || disabled) {
+      event.preventDefault();
+    }
+
+    if (stopPropagation) {
+      event.stopPropagation();
+    }
+
+    if (onClick && !disabled) {
+      onClick();
+    }
+  };
+
+  render() {
+    const {
+      children,
+      disabled,
+      icon,
+      innerRef,
+      onClick,
+      preventDefault,
+      stopPropagation,
+      to,
+      type = 'button',
+      ...htmlProps
+    } = this.props;
+
+    const props = {
+      ...htmlProps,
+      'aria-disabled': disabled,
+      disabled,
+      type,
+    };
+
+    if (to) {
+      return (
+        <BaseButtonLink {...props} onClick={onClick} to={to}>
+          {icon}
+          {children}
+        </BaseButtonLink>
+      );
+    }
+
+    return (
+      <BaseButton {...props} onClick={this.handleClick} ref={innerRef}>
+        {icon}
+        {children}
+      </BaseButton>
+    );
+  }
+}
+
+const buttonStyle = (props: ThemedProps) => css`
+  box-sizing: border-box;
+  text-decoration: none;
+  outline: none;
+  border: var(--border);
+  color: var(--color);
+  background-color: var(--background);
+  transition: background-color 0.2s ease, outline 0.2s ease;
+
+  ${tw`sw-inline-flex sw-items-center`}
+  ${tw`sw-h-control`}
+  ${tw`sw-body-sm-highlight`}
+  ${tw`sw-py-2 sw-px-4`}
+  ${tw`sw-rounded-2`}
+  ${tw`sw-cursor-pointer`}
+
+  &:hover {
+    color: var(--color);
+    background-color: var(--backgroundHover);
+  }
+
+  &:focus,
+  &:active {
+    color: var(--color);
+    outline: ${themeBorder('focus', 'var(--focus)')(props)};
+  }
+
+  &:disabled,
+  &:disabled:hover {
+    color: ${themeContrast('buttonDisabled')(props)};
+    background-color: ${themeColor('buttonDisabled')(props)};
+    border: ${themeBorder('default', 'buttonDisabledBorder')(props)};
+
+    ${tw`sw-cursor-not-allowed`}
+  }
+
+  & > svg {
+    ${tw`sw-mr-1`}
+  }
+`;
+
+const BaseButtonLink = styled(BaseLink)`
+  ${buttonStyle}
+`;
+
+const BaseButton = styled.button`
+  ${buttonStyle}
+
+  /* Workaround for tooltips issue with onMouseLeave in disabled buttons: https://github.com/facebook/react/issues/4251 */
+  & [disabled] {
+    ${tw`sw-pointer-events-none`};
+  }
+`;
+
+export const ButtonPrimary: React.FC<ButtonProps> = styled(Button)`
+  --background: ${themeColor('button')};
+  --backgroundHover: ${themeColor('buttonHover')};
+  --color: ${themeContrast('primary')};
+  --focus: ${themeColor('button', 0.2)};
+  --border: ${themeBorder('default', 'transparent')};
+`;
+
+export const ButtonSecondary: React.FC<ButtonProps> = styled(Button)`
+  --background: ${themeColor('buttonSecondary')};
+  --backgroundHover: ${themeColor('buttonSecondaryHover')};
+  --color: ${themeContrast('buttonSecondary')};
+  --focus: ${themeColor('buttonSecondaryBorder', 0.2)};
+  --border: ${themeBorder('default', 'buttonSecondaryBorder')};
+`;
+
+export const DangerButtonPrimary: React.FC<ButtonProps> = styled(Button)`
+  --background: ${themeColor('dangerButton')};
+  --backgroundHover: ${themeColor('dangerButtonHover')};
+  --color: ${themeContrast('dangerButton')};
+  --focus: ${themeColor('dangerButtonFocus', 0.2)};
+  --border: ${themeBorder('default', 'transparent')};
+`;
+
+export const DangerButtonSecondary: React.FC<ButtonProps> = styled(Button)`
+  --background: ${themeColor('dangerButtonSecondary')};
+  --backgroundHover: ${themeColor('dangerButtonSecondaryHover')};
+  --color: ${themeContrast('dangerButtonSecondary')};
+  --focus: ${themeColor('dangerButtonSecondaryFocus', 0.2)};
+  --border: ${themeBorder('default', 'dangerButtonSecondaryBorder')};
+`;
+
+interface ThirdPartyProps extends Omit<ButtonProps, 'Icon'> {
+  iconPath: string;
+  name: string;
+}
+
+export function ThirdPartyButton({ children, iconPath, name, ...buttonProps }: ThirdPartyProps) {
+  const size = 16;
+  return (
+    <ThirdPartyButtonStyled {...buttonProps}>
+      <img alt={name} className="sw-mr-1" height={size} src={iconPath} width={size} />
+      {children}
+    </ThirdPartyButtonStyled>
+  );
+}
+
+const ThirdPartyButtonStyled: React.FC<ButtonProps> = styled(Button)`
+  --background: ${themeColor('thirdPartyButton')};
+  --backgroundHover: ${themeColor('thirdPartyButtonHover')};
+  --color: ${themeContrast('thirdPartyButton')};
+  --focus: ${themeColor('thirdPartyButtonBorder', 0.2)};
+  --border: ${themeBorder('default', 'thirdPartyButtonBorder')};
+`;
+
+export const BareButton = styled.button`
+  all: unset;
+  cursor: pointer;
+`;
diff --git a/server/sonar-web/design-system/src/components/clipboard.tsx b/server/sonar-web/design-system/src/components/clipboard.tsx
new file mode 100644 (file)
index 0000000..ea05963
--- /dev/null
@@ -0,0 +1,170 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import classNames from 'classnames';
+import Clipboard from 'clipboard';
+import React from 'react';
+import { INTERACTIVE_TOOLTIP_DELAY } from '../helpers/constants';
+import { translate } from '../helpers/l10n';
+import { ButtonSecondary } from './buttons';
+import CopyIcon from './icons/CopyIcon';
+import { IconProps } from './icons/Icon';
+import { DiscreetInteractiveIcon, InteractiveIcon, InteractiveIconSize } from './InteractiveIcon';
+import Tooltip from './Tooltip';
+
+const COPY_SUCCESS_NOTIFICATION_LIFESPAN = 1000;
+
+export interface State {
+  copySuccess: boolean;
+}
+
+interface RenderProps {
+  copySuccess: boolean;
+  setCopyButton: (node: HTMLElement | null) => void;
+}
+
+interface BaseProps {
+  children: (props: RenderProps) => React.ReactNode;
+}
+
+export class ClipboardBase extends React.PureComponent<BaseProps, State> {
+  private clipboard?: Clipboard;
+  private copyButton?: HTMLElement | null;
+  mounted = false;
+  state: State = { copySuccess: false };
+
+  componentDidMount() {
+    this.mounted = true;
+    if (this.copyButton) {
+      this.clipboard = new Clipboard(this.copyButton);
+      this.clipboard.on('success', this.handleSuccessCopy);
+    }
+  }
+
+  componentDidUpdate() {
+    if (this.clipboard) {
+      this.clipboard.destroy();
+    }
+    if (this.copyButton) {
+      this.clipboard = new Clipboard(this.copyButton);
+      this.clipboard.on('success', this.handleSuccessCopy);
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+    if (this.clipboard) {
+      this.clipboard.destroy();
+    }
+  }
+
+  setCopyButton = (node: HTMLElement | null) => {
+    this.copyButton = node;
+  };
+
+  handleSuccessCopy = () => {
+    if (this.mounted) {
+      this.setState({ copySuccess: true });
+      setTimeout(() => {
+        if (this.mounted) {
+          this.setState({ copySuccess: false });
+        }
+      }, COPY_SUCCESS_NOTIFICATION_LIFESPAN);
+    }
+  };
+
+  render() {
+    return this.props.children({
+      setCopyButton: this.setCopyButton,
+      copySuccess: this.state.copySuccess,
+    });
+  }
+}
+
+interface ButtonProps {
+  children?: React.ReactNode;
+  className?: string;
+  copyValue: string;
+  icon?: React.ReactNode;
+}
+
+export function ClipboardButton({
+  icon = <CopyIcon />,
+  className,
+  children,
+  copyValue,
+}: ButtonProps) {
+  return (
+    <ClipboardBase>
+      {({ setCopyButton, copySuccess }) => (
+        <Tooltip overlay={translate('copied_action')} visible={copySuccess}>
+          <ButtonSecondary
+            className={classNames('sw-select-none', className)}
+            data-clipboard-text={copyValue}
+            icon={icon}
+            innerRef={setCopyButton}
+          >
+            {children || translate('copy')}
+          </ButtonSecondary>
+        </Tooltip>
+      )}
+    </ClipboardBase>
+  );
+}
+
+interface IconButtonProps {
+  Icon?: React.ComponentType<IconProps>;
+  'aria-label'?: string;
+  className?: string;
+  copyValue: string;
+  discreet?: boolean;
+  size?: InteractiveIconSize;
+}
+
+export function ClipboardIconButton(props: IconButtonProps) {
+  const { className, copyValue, discreet, size = 'small', Icon = CopyIcon } = props;
+  const InteractiveIconComponent = discreet ? DiscreetInteractiveIcon : InteractiveIcon;
+
+  return (
+    <ClipboardBase>
+      {({ setCopyButton, copySuccess }) => {
+        return (
+          <Tooltip
+            mouseEnterDelay={INTERACTIVE_TOOLTIP_DELAY}
+            overlay={
+              <div className="sw-w-abs-150 sw-text-center">
+                {translate(copySuccess ? 'copied_action' : 'copy_to_clipboard')}
+              </div>
+            }
+            {...(copySuccess ? { visible: copySuccess } : undefined)}
+          >
+            <InteractiveIconComponent
+              Icon={Icon}
+              aria-label={props['aria-label'] ?? translate('copy_to_clipboard')}
+              className={className}
+              data-clipboard-text={copyValue}
+              innerRef={setCopyButton}
+              size={size}
+            />
+          </Tooltip>
+        );
+      }}
+    </ClipboardBase>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/CheckIcon.tsx b/server/sonar-web/design-system/src/components/icons/CheckIcon.tsx
new file mode 100644 (file)
index 0000000..dff5e8b
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers/theme';
+import { CustomIcon, IconProps } from './Icon';
+
+export default function CheckIcon({ fill = 'iconCheck', ...iconProps }: IconProps) {
+  const theme = useTheme();
+  return (
+    <CustomIcon {...iconProps}>
+      <path
+        clipRule="evenodd"
+        d="M11.6634 5.47789c.2884.29737.2811.77218-.0163 1.06054L7.52211 10.5384c-.29414.2852-.76273.2816-1.05244-.0081l-2-1.99997c-.29289-.29289-.29289-.76777 0-1.06066s.76777-.29289 1.06066 0L7.0081 8.94744l3.5948-3.48586c.2974-.28836.7722-.28105 1.0605.01631Z"
+        fill={themeColor(fill)({ theme })}
+        fillRule="evenodd"
+      />
+    </CustomIcon>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/ClockIcon.tsx b/server/sonar-web/design-system/src/components/icons/ClockIcon.tsx
new file mode 100644 (file)
index 0000000..15f81c7
--- /dev/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.
+ */
+import { ClockIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(ClockIcon);
diff --git a/server/sonar-web/design-system/src/components/icons/CloseIcon.tsx b/server/sonar-web/design-system/src/components/icons/CloseIcon.tsx
new file mode 100644 (file)
index 0000000..79fb088
--- /dev/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.
+ */
+import { XIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(XIcon, 'CloseIcon');
diff --git a/server/sonar-web/design-system/src/components/icons/CopyIcon.tsx b/server/sonar-web/design-system/src/components/icons/CopyIcon.tsx
new file mode 100644 (file)
index 0000000..e9f1257
--- /dev/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.
+ */
+import { CopyIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(CopyIcon);
diff --git a/server/sonar-web/design-system/src/components/icons/Icon.tsx b/server/sonar-web/design-system/src/components/icons/Icon.tsx
new file mode 100644 (file)
index 0000000..0603fe8
--- /dev/null
@@ -0,0 +1,86 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { useTheme } from '@emotion/react';
+import { OcticonProps } from '@primer/octicons-react';
+import React from 'react';
+import { theme } from 'twin.macro';
+import { themeColor } from '../../helpers/theme';
+import { CSSColor, ThemeColors } from '../../types/theme';
+
+interface Props {
+  'aria-label'?: string;
+  children: React.ReactNode;
+  className?: string;
+}
+
+export interface IconProps extends Omit<Props, 'children'> {
+  fill?: ThemeColors | CSSColor;
+}
+
+export function CustomIcon(props: Props) {
+  const { 'aria-label': ariaLabel, children, className, ...iconProps } = props;
+  return (
+    <svg
+      aria-hidden={ariaLabel ? 'false' : 'true'}
+      aria-label={ariaLabel}
+      className={className}
+      fill="none"
+      height={theme('height.icon')}
+      role="img"
+      style={{
+        clipRule: 'evenodd',
+        display: 'inline-block',
+        fillRule: 'evenodd',
+        userSelect: 'none',
+        verticalAlign: 'middle',
+        strokeLinejoin: 'round',
+        strokeMiterlimit: 1.414,
+      }}
+      version="1.1"
+      viewBox="0 0 16 16"
+      width={theme('width.icon')}
+      xmlSpace="preserve"
+      xmlnsXlink="http://www.w3.org/1999/xlink"
+      {...iconProps}
+    >
+      {children}
+    </svg>
+  );
+}
+
+export function OcticonHoc(
+  WrappedOcticon: React.ComponentType<OcticonProps>,
+  displayName?: string
+): React.ComponentType<IconProps> {
+  function IconWrapper({ fill, ...props }: IconProps) {
+    const theme = useTheme();
+    return (
+      <WrappedOcticon
+        fill={fill && themeColor(fill)({ theme })}
+        size="small"
+        verticalAlign="middle"
+        {...props}
+      />
+    );
+  }
+
+  IconWrapper.displayName = displayName || WrappedOcticon.displayName || WrappedOcticon.name;
+  return IconWrapper;
+}
diff --git a/server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx b/server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx
new file mode 100644 (file)
index 0000000..5fcebec
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers/theme';
+import { CustomIcon, IconProps } from './Icon';
+
+export default function MenuHelpIcon({ fill = 'currentColor', ...iconProps }: IconProps) {
+  const theme = useTheme();
+  return (
+    <CustomIcon {...iconProps}>
+      <path
+        clipRule="evenodd"
+        d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16Zm.507-5.451H6.66v-.166c.005-1.704.462-2.226 1.28-2.742.6-.38 1.062-.803 1.062-1.441 0-.677-.53-1.116-1.188-1.116-.638 0-1.227.424-1.257 1.218H4.571c.044-1.948 1.486-2.873 3.254-2.873 1.933 0 3.307.993 3.307 2.698 0 1.144-.595 1.86-1.505 2.4-.77.463-1.11.906-1.12 1.856v.166Zm.282 1.948a1.185 1.185 0 0 1-1.169 1.169 1.164 1.164 0 1 1 0-2.328c.624 0 1.164.52 1.169 1.159Z"
+        fill={themeColor(fill)({ theme })}
+        fillRule="evenodd"
+      />
+    </CustomIcon>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/MenuIcon.tsx b/server/sonar-web/design-system/src/components/icons/MenuIcon.tsx
new file mode 100644 (file)
index 0000000..ea30d7d
--- /dev/null
@@ -0,0 +1,29 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import { KebabHorizontalIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+const MenuIcon = styled(OcticonHoc(KebabHorizontalIcon))`
+  transform: rotate(90deg);
+`;
+
+MenuIcon.displayName = 'MenuIcon';
+export default MenuIcon;
diff --git a/server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx b/server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx
new file mode 100644 (file)
index 0000000..a090772
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers/theme';
+import { CustomIcon, IconProps } from './Icon';
+
+export default function MenuSearchIcon({ fill = 'currentColor', ...iconProps }: IconProps) {
+  const theme = useTheme();
+
+  return (
+    <CustomIcon {...iconProps}>
+      <path
+        clipRule="evenodd"
+        d="M12 7c0 2.76142-2.23858 5-5 5S2 9.76142 2 7s2.23858-5 5-5 5 2.23858 5 5Zm-.8078 5.6064C10.0236 13.4816 8.57234 14 7 14c-3.86599 0-7-3.134-7-7 0-3.86599 3.13401-7 7-7 3.866 0 7 3.13401 7 7 0 1.57234-.5184 3.0236-1.3936 4.1922l3.0505 3.0504c.3905.3906.3905 1.0237 0 1.4143-.3906.3905-1.0237.3905-1.4143 0l-3.0504-3.0505Z"
+        fill={themeColor(fill)({ theme })}
+        fillRule="evenodd"
+      />
+    </CustomIcon>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx b/server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx
new file mode 100644 (file)
index 0000000..f856c0c
--- /dev/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.
+ */
+import { LinkExternalIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(LinkExternalIcon, 'OpenNewTabIcon');
diff --git a/server/sonar-web/design-system/src/components/icons/SearchIcon.tsx b/server/sonar-web/design-system/src/components/icons/SearchIcon.tsx
new file mode 100644 (file)
index 0000000..674ac69
--- /dev/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.
+ */
+import { SearchIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(SearchIcon);
diff --git a/server/sonar-web/design-system/src/components/icons/StarIcon.tsx b/server/sonar-web/design-system/src/components/icons/StarIcon.tsx
new file mode 100644 (file)
index 0000000..f83c9a3
--- /dev/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.
+ */
+import { StarIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export default OcticonHoc(StarIcon);
diff --git a/server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx b/server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx
new file mode 100644 (file)
index 0000000..4d25af6
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { CheckIcon } from '@primer/octicons-react';
+import { screen } from '@testing-library/react';
+import { render } from '../../../helpers/testUtils';
+import { CustomIcon, OcticonHoc } from '../Icon';
+
+it('should render custom icon correctly', () => {
+  render(
+    <CustomIcon>
+      <path d="test" />
+    </CustomIcon>
+  );
+
+  expect(screen.queryByRole('img')).not.toBeInTheDocument();
+  expect(screen.getByRole('img', { hidden: true })).toContainHTML('<path d="test"/>');
+});
+
+it('should not be hidden when aria-label is set', () => {
+  render(
+    <CustomIcon aria-label="test">
+      <path d="test" />
+    </CustomIcon>
+  );
+
+  expect(screen.getByRole('img')).toBeVisible();
+});
+
+describe('Octicon HOC', () => {
+  it('should render correctly', () => {
+    const Wrapped = OcticonHoc(CheckIcon, 'TestIcon');
+
+    render(<Wrapped aria-label="visible" />);
+
+    expect(screen.getByRole('img')).toBeVisible();
+  });
+});
diff --git a/server/sonar-web/design-system/src/components/icons/index.ts b/server/sonar-web/design-system/src/components/icons/index.ts
new file mode 100644 (file)
index 0000000..8b30b79
--- /dev/null
@@ -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';
index a96434d2ea2ef7a278f06cf8d473df8710b8a0a2..e7bdcf4ca80302a6e4c9da76a4b5361a85073f21 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-export * from './DummyComponent';
+export * from './Avatar';
+export * from './buttons';
+export { default as DeferredSpinner } from './DeferredSpinner';
+export { default as Dropdown } from './Dropdown';
+export * from './DropdownMenu';
+export { default as DropdownToggler } from './DropdownToggler';
+export * from './GenericAvatar';
+export * from './icons';
+export { default as InputSearch } from './InputSearch';
+export * from './InteractiveIcon';
+export { default as Link } from './Link';
+export * from './MainAppBar';
+export * from './MainMenu';
+export { MainMenuItem } from './MainMenuItem';
+export * from './popups';
+export * from './SonarQubeLogo';
+export * from './Text';
+export { default as Tooltip } from './Tooltip';
diff --git a/server/sonar-web/design-system/src/components/popups.tsx b/server/sonar-web/design-system/src/components/popups.tsx
new file mode 100644 (file)
index 0000000..e517ceb
--- /dev/null
@@ -0,0 +1,256 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import { throttle } from 'lodash';
+import React, { AriaRole } from 'react';
+import { createPortal, findDOMNode } from 'react-dom';
+import tw from 'twin.macro';
+import { THROTTLE_SCROLL_DELAY } from '../helpers/constants';
+import { PopupPlacement, popupPositioning, PopupZLevel } from '../helpers/positioning';
+import { themeBorder, themeColor, themeContrast, themeShadow } from '../helpers/theme';
+import ClickEventBoundary from './ClickEventBoundary';
+
+interface PopupProps {
+  'aria-labelledby'?: string;
+  children?: React.ReactNode;
+  className?: string;
+  id?: string;
+  placement?: PopupPlacement;
+  role?: AriaRole;
+  style?: React.CSSProperties;
+  zLevel?: PopupZLevel;
+}
+
+function PopupBase(props: PopupProps, ref: React.Ref<HTMLDivElement>) {
+  const {
+    children,
+    className,
+    placement = PopupPlacement.Bottom,
+    style,
+    zLevel = PopupZLevel.Default,
+    ...ariaProps
+  } = props;
+  return (
+    <ClickEventBoundary>
+      <PopupWrapper
+        className={classNames(`is-${placement}`, className)}
+        ref={ref || React.createRef()}
+        style={style}
+        zLevel={zLevel}
+        {...ariaProps}
+      >
+        {children}
+      </PopupWrapper>
+    </ClickEventBoundary>
+  );
+}
+
+const PopupWithRef = React.forwardRef(PopupBase);
+PopupWithRef.displayName = 'Popup';
+
+export const Popup = PopupWithRef;
+
+interface PortalPopupProps extends Omit<PopupProps, 'style'> {
+  allowResizing?: boolean;
+  children: React.ReactNode;
+  overlay: React.ReactNode;
+}
+
+interface Measurements {
+  height: number;
+  left: number;
+  top: number;
+  width: number;
+}
+
+type State = Partial<Measurements>;
+
+function isMeasured(state: State): state is Measurements {
+  return state.height !== undefined;
+}
+
+export class PortalPopup extends React.PureComponent<PortalPopupProps, State> {
+  mounted = false;
+  popupNode = React.createRef<HTMLDivElement>();
+  throttledPositionTooltip: () => void;
+
+  constructor(props: PortalPopupProps) {
+    super(props);
+    this.state = {};
+    this.throttledPositionTooltip = throttle(this.positionPopup, THROTTLE_SCROLL_DELAY);
+  }
+
+  componentDidMount() {
+    this.positionPopup();
+    this.addEventListeners();
+    this.mounted = true;
+  }
+
+  componentDidUpdate(prevProps: PortalPopupProps) {
+    if (this.props.placement !== prevProps.placement || this.props.overlay !== prevProps.overlay) {
+      this.positionPopup();
+    }
+  }
+
+  componentWillUnmount() {
+    this.removeEventListeners();
+    this.mounted = false;
+  }
+
+  addEventListeners = () => {
+    window.addEventListener('resize', this.throttledPositionTooltip);
+    if (this.props.zLevel !== PopupZLevel.Global) {
+      window.addEventListener('scroll', this.throttledPositionTooltip);
+    }
+  };
+
+  removeEventListeners = () => {
+    window.removeEventListener('resize', this.throttledPositionTooltip);
+    if (this.props.zLevel !== PopupZLevel.Global) {
+      window.removeEventListener('scroll', this.throttledPositionTooltip);
+    }
+  };
+
+  positionPopup = () => {
+    if (this.mounted) {
+      // `findDOMNode(this)` will search for the DOM node for the current component
+      // first it will find a React.Fragment (see `render`),
+      // so it will get the DOM node of the first child, i.e. DOM node of `this.props.children`
+      // docs: https://reactjs.org/docs/refs-and-the-dom.html#exposing-dom-refs-to-parent-components
+
+      // eslint-disable-next-line react/no-find-dom-node
+      const toggleNode = findDOMNode(this);
+      if (toggleNode && toggleNode instanceof Element && this.popupNode.current) {
+        const { placement, zLevel } = this.props;
+        const isGlobal = zLevel === PopupZLevel.Global;
+        const { height, left, top, width } = popupPositioning(
+          toggleNode,
+          this.popupNode.current,
+          placement
+        );
+
+        // save width and height (and later set in `render`) to avoid resizing the popup element,
+        // when it's placed close to the window edge
+        this.setState({
+          left: left + (isGlobal ? 0 : window.scrollX),
+          top: top + (isGlobal ? 0 : window.scrollY),
+          width,
+          height,
+        });
+      }
+    }
+  };
+
+  render() {
+    const {
+      allowResizing,
+      children,
+      overlay,
+      placement = PopupPlacement.Bottom,
+      ...popupProps
+    } = this.props;
+
+    let style: React.CSSProperties | undefined;
+    if (isMeasured(this.state)) {
+      style = { left: this.state.left, top: this.state.top };
+      if (!allowResizing) {
+        style.width = this.state.width;
+        style.height = this.state.height;
+      }
+    }
+    return (
+      <>
+        {this.props.children}
+        {this.props.overlay && (
+          <PortalWrapper>
+            <Popup placement={placement} ref={this.popupNode} style={style} {...popupProps}>
+              {overlay}
+            </Popup>
+          </PortalWrapper>
+        )}
+      </>
+    );
+  }
+}
+
+const PopupWrapper = styled.div<{ zLevel: PopupZLevel }>`
+  position: ${({ zLevel }) => (zLevel === PopupZLevel.Global ? 'fixed' : 'absolute')};
+  background-color: ${themeColor('popup')};
+  color: ${themeContrast('popup')};
+  border: ${themeBorder('default', 'popupBorder')};
+  box-shadow: ${themeShadow('md')};
+
+  ${tw`sw-box-border`};
+  ${tw`sw-rounded-2`};
+  ${tw`sw-cursor-default`};
+  ${tw`sw-overflow-hidden`};
+  ${({ zLevel }) =>
+    ({
+      [PopupZLevel.Default]: tw`sw-z-popup`,
+      [PopupZLevel.Global]: tw`sw-z-global-popup`,
+      [PopupZLevel.Content]: tw`sw-z-content-popup`,
+    }[zLevel])};
+
+  &.is-bottom,
+  &.is-bottom-left,
+  &.is-bottom-right {
+    ${tw`sw-mt-2`};
+  }
+
+  &.is-top,
+  &.is-top-left,
+  &.is-top-right {
+    ${tw`sw--mt-2`};
+  }
+
+  &.is-left,
+  &.is-left-top,
+  &.is-left-bottom {
+    ${tw`sw--ml-2`};
+  }
+
+  &.is-right,
+  &.is-right-top,
+  &.is-right-bottom {
+    ${tw`sw-ml-2`};
+  }
+`;
+
+class PortalWrapper extends React.Component {
+  el: HTMLElement;
+
+  constructor(props: {}) {
+    super(props);
+    this.el = document.createElement('div');
+  }
+
+  componentDidMount() {
+    document.body.appendChild(this.el);
+  }
+
+  componentWillUnmount() {
+    document.body.removeChild(this.el);
+  }
+
+  render() {
+    return createPortal(this.props.children, this.el);
+  }
+}
diff --git a/server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts b/server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts
new file mode 100644 (file)
index 0000000..eead6e0
--- /dev/null
@@ -0,0 +1,61 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as colors from '../colors';
+
+describe('#stringToColor', () => {
+  it('should return a color for a text', () => {
+    expect(colors.stringToColor('skywalker')).toBe('#97f047');
+  });
+});
+
+describe('#isDarkColor', () => {
+  it('should be dark', () => {
+    expect(colors.isDarkColor('#000000')).toBe(true);
+    expect(colors.isDarkColor('#222222')).toBe(true);
+    expect(colors.isDarkColor('#000')).toBe(true);
+  });
+  it('should be light', () => {
+    expect(colors.isDarkColor('#FFFFFF')).toBe(false);
+    expect(colors.isDarkColor('#CDCDCD')).toBe(false);
+    expect(colors.isDarkColor('#FFF')).toBe(false);
+  });
+});
+
+describe('#getTextColor', () => {
+  it('should return dark color', () => {
+    expect(colors.getTextColor('#FFF', 'dark', 'light')).toBe('dark');
+    expect(colors.getTextColor('#FFF')).toBe('#222');
+  });
+  it('should return light color', () => {
+    expect(colors.getTextColor('#000', 'dark', 'light')).toBe('light');
+    expect(colors.getTextColor('#000')).toBe('#fff');
+  });
+});
+
+describe('rgb array to color', () => {
+  it('should return rgb color without opacity', () => {
+    expect(colors.getRGBAString([0, 0, 0])).toBe('rgb(0,0,0)');
+    expect(colors.getRGBAString([255, 255, 255])).toBe('rgb(255,255,255)');
+  });
+  it('should return rgba color with opacity', () => {
+    expect(colors.getRGBAString([5, 6, 100], 0.05)).toBe('rgba(5,6,100,0.05)');
+    expect(colors.getRGBAString([255, 255, 255], 0)).toBe('rgba(255,255,255,0)');
+  });
+});
diff --git a/server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts b/server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts
new file mode 100644 (file)
index 0000000..7953d35
--- /dev/null
@@ -0,0 +1,167 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { PopupPlacement, popupPositioning } from '../positioning';
+
+const toggleRect = {
+  getBoundingClientRect: jest.fn().mockReturnValue({
+    left: 400,
+    top: 200,
+    width: 50,
+    height: 20,
+  }),
+} as any;
+
+const popupRect = {
+  getBoundingClientRect: jest.fn().mockReturnValue({
+    width: 200,
+    height: 100,
+  }),
+} as any;
+
+beforeAll(() => {
+  Object.defineProperties(document.documentElement, {
+    clientWidth: {
+      configurable: true,
+      value: 1000,
+    },
+    clientHeight: {
+      configurable: true,
+      value: 1000,
+    },
+  });
+});
+
+it('should calculate positioning based on placement', () => {
+  const fixes = { leftFix: 0, topFix: 0 };
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Bottom)).toMatchObject({
+    left: 325,
+    top: 220,
+    ...fixes,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.BottomLeft)).toMatchObject({
+    left: 400,
+    top: 220,
+    ...fixes,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.BottomRight)).toMatchObject({
+    left: 250,
+    top: 220,
+    ...fixes,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Top)).toMatchObject({
+    left: 325,
+    top: 100,
+    ...fixes,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.TopLeft)).toMatchObject({
+    left: 400,
+    top: 100,
+    ...fixes,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.TopRight)).toMatchObject({
+    left: 250,
+    top: 100,
+    ...fixes,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Left)).toMatchObject({
+    left: 200,
+    top: 160,
+    ...fixes,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.LeftBottom)).toMatchObject({
+    left: 200,
+    top: 120,
+    ...fixes,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.LeftTop)).toMatchObject({
+    left: 200,
+    top: 200,
+    ...fixes,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Right)).toMatchObject({
+    left: 450,
+    top: 160,
+    ...fixes,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.RightBottom)).toMatchObject({
+    left: 450,
+    top: 120,
+    ...fixes,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.RightTop)).toMatchObject({
+    left: 450,
+    top: 200,
+    ...fixes,
+  });
+});
+
+it('should position the element in the boundaries of the screen', () => {
+  toggleRect.getBoundingClientRect.mockReturnValueOnce({
+    left: 0,
+    top: 850,
+    width: 50,
+    height: 50,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Bottom)).toMatchObject({
+    left: 4,
+    leftFix: 79,
+    top: 896,
+    topFix: -4,
+  });
+  toggleRect.getBoundingClientRect.mockReturnValueOnce({
+    left: 900,
+    top: 0,
+    width: 50,
+    height: 50,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Top)).toMatchObject({
+    left: 796,
+    leftFix: -29,
+    top: 4,
+    topFix: 104,
+  });
+});
+
+it('should position the element outside the boundaries of the screen when the toggle is outside', () => {
+  toggleRect.getBoundingClientRect.mockReturnValueOnce({
+    left: -100,
+    top: 1100,
+    width: 50,
+    height: 50,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Bottom)).toMatchObject({
+    left: -75,
+    leftFix: 100,
+    top: 1025,
+    topFix: -125,
+  });
+  toggleRect.getBoundingClientRect.mockReturnValueOnce({
+    left: 1500,
+    top: -200,
+    width: 50,
+    height: 50,
+  });
+  expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Top)).toMatchObject({
+    left: 1325,
+    leftFix: -100,
+    top: -175,
+    topFix: 125,
+  });
+});
diff --git a/server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts b/server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts
new file mode 100644 (file)
index 0000000..66f1b97
--- /dev/null
@@ -0,0 +1,148 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as ThemeHelper from '../../helpers/theme';
+import { lightTheme } from '../../theme';
+
+const props = {
+  color: 'rgb(0,0,0)',
+};
+
+describe('getProp', () => {
+  it('should work', () => {
+    expect(ThemeHelper.getProp('color')(props)).toEqual('rgb(0,0,0)');
+  });
+});
+
+describe('themeColor', () => {
+  it('should work for light theme', () => {
+    expect(ThemeHelper.themeColor('backgroundPrimary')({ theme: lightTheme })).toEqual(
+      'rgb(252,252,253)'
+    );
+  });
+
+  it('should work with a theme-defined opacity', () => {
+    expect(ThemeHelper.themeColor('bannerIconHover')({ theme: lightTheme })).toEqual(
+      'rgba(217,45,32,0.2)'
+    );
+  });
+
+  it('should work for all kind of color parameters', () => {
+    expect(ThemeHelper.themeColor('transparent')({ theme: lightTheme })).toEqual('transparent');
+    expect(ThemeHelper.themeColor('currentColor')({ theme: lightTheme })).toEqual('currentColor');
+    expect(ThemeHelper.themeColor('var(--test)')({ theme: lightTheme })).toEqual('var(--test)');
+    expect(ThemeHelper.themeColor('rgb(0,0,0)')({ theme: lightTheme })).toEqual('rgb(0,0,0)');
+    expect(ThemeHelper.themeColor('rgba(0,0,0,1)')({ theme: lightTheme })).toEqual('rgba(0,0,0,1)');
+    expect(
+      ThemeHelper.themeColor(ThemeHelper.themeContrast('backgroundPrimary')({ theme: lightTheme }))(
+        {
+          theme: lightTheme,
+        }
+      )
+    ).toEqual('rgb(8,9,12)');
+    expect(
+      ThemeHelper.themeColor(ThemeHelper.themeAvatarColor('luke')({ theme: lightTheme }))({
+        theme: lightTheme,
+      })
+    ).toEqual('rgb(209,215,254)');
+  });
+});
+
+describe('themeContrast', () => {
+  it('should work for light theme', () => {
+    expect(ThemeHelper.themeContrast('backgroundPrimary')({ theme: lightTheme })).toEqual(
+      'rgb(8,9,12)'
+    );
+  });
+
+  it('should work for all kind of color parameters', () => {
+    expect(ThemeHelper.themeContrast('var(--test)')({ theme: lightTheme })).toEqual('var(--test)');
+    expect(ThemeHelper.themeContrast('rgb(0,0,0)')({ theme: lightTheme })).toEqual('rgb(0,0,0)');
+    expect(ThemeHelper.themeContrast('rgba(0,0,0,1)')({ theme: lightTheme })).toEqual(
+      'rgba(0,0,0,1)'
+    );
+    expect(
+      ThemeHelper.themeContrast(ThemeHelper.themeColor('backgroundPrimary')({ theme: lightTheme }))(
+        {
+          theme: lightTheme,
+        }
+      )
+    ).toEqual('rgb(252,252,253)');
+    expect(
+      ThemeHelper.themeContrast(ThemeHelper.themeAvatarColor('luke')({ theme: lightTheme }))({
+        theme: lightTheme,
+      })
+    ).toEqual('rgb(209,215,254)');
+    expect(
+      ThemeHelper.themeContrast('backgroundPrimary')({
+        theme: {
+          ...lightTheme,
+          contrasts: { ...lightTheme.contrasts, backgroundPrimary: 'inherit' },
+        },
+      })
+    ).toEqual('inherit');
+  });
+});
+
+describe('themeBorder', () => {
+  it('should work for light theme', () => {
+    expect(ThemeHelper.themeBorder()({ theme: lightTheme })).toEqual('1px solid rgb(235,235,235)');
+  });
+  it('should allow to override the color of the border', () => {
+    expect(ThemeHelper.themeBorder('focus', 'primaryLight')({ theme: lightTheme })).toEqual(
+      '4px solid rgba(123,135,217,0.2)'
+    );
+  });
+  it('should allow to override the opacity of the border', () => {
+    expect(ThemeHelper.themeBorder('focus', undefined, 0.5)({ theme: lightTheme })).toEqual(
+      '4px solid rgba(197,205,223,0.5)'
+    );
+  });
+  it('should allow to pass a CSS prop as color name', () => {
+    expect(
+      ThemeHelper.themeBorder('focus', 'var(--outlineColor)', 0.5)({ theme: lightTheme })
+    ).toEqual('4px solid var(--outlineColor)');
+  });
+});
+
+describe('themeShadow', () => {
+  it('should work for light theme', () => {
+    expect(ThemeHelper.themeShadow('xs')({ theme: lightTheme })).toEqual(
+      '0px 1px 2px 0px rgba(29,33,47,0.05)'
+    );
+  });
+  it('should allow to override the color of the shadow', () => {
+    expect(ThemeHelper.themeShadow('xs', 'backgroundPrimary')({ theme: lightTheme })).toEqual(
+      '0px 1px 2px 0px rgba(252,252,253,0.05)'
+    );
+    expect(ThemeHelper.themeShadow('xs', 'transparent')({ theme: lightTheme })).toEqual(
+      '0px 1px 2px 0px transparent'
+    );
+  });
+  it('should allow to override the opacity of the shadow', () => {
+    expect(ThemeHelper.themeShadow('xs', 'backgroundPrimary', 0.8)({ theme: lightTheme })).toEqual(
+      '0px 1px 2px 0px rgba(252,252,253,0.8)'
+    );
+  });
+  it('should allow to pass a CSS prop as color name', () => {
+    expect(ThemeHelper.themeShadow('xs', 'var(--shadowColor)')({ theme: lightTheme })).toEqual(
+      '0px 1px 2px 0px var(--shadowColor)'
+    );
+  });
+});
diff --git a/server/sonar-web/design-system/src/helpers/colors.ts b/server/sonar-web/design-system/src/helpers/colors.ts
new file mode 100644 (file)
index 0000000..d0cb5e2
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { CSSColor } from '../types/theme';
+
+/* eslint-disable no-bitwise, no-mixed-operators */
+export function stringToColor(str: string) {
+  let hash = 0;
+  for (let i = 0; i < str.length; i++) {
+    hash = str.charCodeAt(i) + ((hash << 5) - hash);
+  }
+  let color = '#';
+  for (let i = 0; i < 3; i++) {
+    const value = (hash >> (i * 8)) & 0xff;
+    color += ('00' + value.toString(16)).substr(-2);
+  }
+  return color;
+}
+
+export function isDarkColor(color: string) {
+  color = color.substr(1);
+  if (color.length === 3) {
+    // shortcut notation: #f90
+    color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2];
+  }
+  const rgb = parseInt(color.substr(1), 16);
+  const r = (rgb >> 16) & 0xff;
+  const g = (rgb >> 8) & 0xff;
+  const b = (rgb >> 0) & 0xff;
+  const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+  return luma < 140;
+}
+
+export function getTextColor(background: string, dark = '#222', light = '#fff') {
+  return isDarkColor(background) ? light : dark;
+}
+
+export function getRGBAString([r, g, b]: Array<number | string>, a?: number | string) {
+  return (a !== undefined ? `rgba(${r},${g},${b},${a})` : `rgb(${r},${g},${b})`) as CSSColor;
+}
diff --git a/server/sonar-web/design-system/src/helpers/constants.ts b/server/sonar-web/design-system/src/helpers/constants.ts
new file mode 100644 (file)
index 0000000..68a385c
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { theme } from 'twin.macro';
+
+export const DEFAULT_LOCALE = 'en';
+export const IS_SSR = typeof window === 'undefined';
+export const REACT_DOM_CONTAINER = '#___gatsby';
+
+export const RULE_STATUSES = ['READY', 'BETA', 'DEPRECATED'];
+
+export const THROTTLE_SCROLL_DELAY = 10;
+export const THROTTLE_KEYPRESS_DELAY = 100;
+
+export const DEBOUNCE_DELAY = 250;
+
+export const DEBOUNCE_LONG_DELAY = 1000;
+
+export const DEBOUNCE_SUCCESS_DELAY = 1000;
+
+export const INTERACTIVE_TOOLTIP_DELAY = 0.5;
+
+export const LEAK_PERIOD = 'sonar.leak.period';
+
+export const LEAK_PERIOD_TYPE = 'sonar.leak.period.type';
+
+export const INPUT_SIZES = {
+  small: theme('width.input-small'),
+  medium: theme('width.input-medium'),
+  large: theme('width.input-large'),
+  full: theme('width.full'),
+  auto: theme('width.auto'),
+};
+
+export const LAYOUT_VIEWPORT_MIN_WIDTH = 1280;
+export const LAYOUT_MAIN_CONTENT_GUTTER = 60;
+export const LAYOUT_SIDEBAR_WIDTH = 240;
+export const LAYOUT_SIDEBAR_COLLAPSED_WIDTH = 60;
+export const LAYOUT_SIDEBAR_BREAKPOINT = 1320;
+export const LAYOUT_BANNER_HEIGHT = 44;
+export const LAYOUT_BRANDING_ICON_WIDTH = 198;
+export const LAYOUT_FILTERBAR_HEADER = 56;
+export const LAYOUT_GLOBAL_NAV_HEIGHT = 52;
+export const LAYOUT_LOGO_MARGIN_RIGHT = 45;
+export const LAYOUT_LOGO_MAX_HEIGHT = 40;
+export const LAYOUT_LOGO_MAX_WIDTH = 150;
+export const LAYOUT_FOOTER_HEIGHT = 52;
+export const LAYOUT_NOTIFICATIONSBAR_WIDTH = 350;
+
+export const CORE_CONCEPTS_WIDTH = 350;
+
+export const DARK_THEME_ID = 'dark-theme';
diff --git a/server/sonar-web/design-system/src/helpers/index.ts b/server/sonar-web/design-system/src/helpers/index.ts
new file mode 100644 (file)
index 0000000..764e245
--- /dev/null
@@ -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 * from './constants';
+export * from './positioning';
diff --git a/server/sonar-web/design-system/src/helpers/keyboard.ts b/server/sonar-web/design-system/src/helpers/keyboard.ts
new file mode 100644 (file)
index 0000000..42bc6bd
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+export enum Key {
+  ArrowLeft = 'ArrowLeft',
+  ArrowUp = 'ArrowUp',
+  ArrowRight = 'ArrowRight',
+  ArrowDown = 'ArrowDown',
+
+  Alt = 'Alt',
+  Backspace = 'Backspace',
+  CapsLock = 'CapsLock',
+  Meta = 'Meta',
+  Control = 'Control',
+  Delete = 'Delete',
+  End = 'End',
+  Enter = 'Enter',
+  Escape = 'Escape',
+  Home = 'Home',
+  PageDown = 'PageDown',
+  PageUp = 'PageUp',
+  Shift = 'Shift',
+  Space = ' ',
+  Tab = 'Tab',
+}
+
+export function isShortcut(event: KeyboardEvent): boolean {
+  return event.ctrlKey || event.metaKey;
+}
+
+const INPUT_TAGS = ['INPUT', 'SELECT', 'TEXTAREA', 'UBCOMMENT'];
+
+export function isInput(event: KeyboardEvent): boolean {
+  const { tagName } = event.target as HTMLElement;
+  return INPUT_TAGS.includes(tagName);
+}
diff --git a/server/sonar-web/design-system/src/helpers/l10n.ts b/server/sonar-web/design-system/src/helpers/l10n.ts
new file mode 100644 (file)
index 0000000..96cf946
--- /dev/null
@@ -0,0 +1,30 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+export function translate(keys: string): string {
+  return keys;
+}
+
+export function translateWithParameters(
+  messageKey: string,
+  ...parameters: Array<string | number>
+): string {
+  return `${messageKey}.${parameters.join('.')}`;
+}
diff --git a/server/sonar-web/design-system/src/helpers/positioning.ts b/server/sonar-web/design-system/src/helpers/positioning.ts
new file mode 100644 (file)
index 0000000..09384e2
--- /dev/null
@@ -0,0 +1,185 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+/**
+ * Positioning rules:
+ * - Bottom = below the block, horizontally centered
+ * - BottomLeft = below the block, horizontally left-aligned
+ * - BottomRight = below the block, horizontally right-aligned
+ * - Left = Left of the block, vertically centered
+ * - LeftTop = on the left-side of the block, vertically top-aligned
+ * - LeftBottom = on the left-side of the block, vertically bottom-aligned
+ * - Right = Right of the block, vertically centered
+ * - RightTop = on the right-side of the block, vertically top-aligned
+ * - RightBottom = on the right-side of the block, vetically bottom-aligned
+ * - Top = above the block, horizontally centered
+ * - TopLeft = above the block, horizontally left-aligned
+ * - TopRight = above the block, horizontally right-aligned
+ */
+export enum PopupPlacement {
+  Bottom = 'bottom',
+  BottomLeft = 'bottom-left',
+  BottomRight = 'bottom-right',
+  Left = 'left',
+  LeftTop = 'left-top',
+  LeftBottom = 'left-bottom',
+  Right = 'right',
+  RightTop = 'right-top',
+  RightBottom = 'right-bottom',
+  Top = 'top',
+  TopLeft = 'top-left',
+  TopRight = 'top-right',
+}
+
+export enum PopupZLevel {
+  Content = 'content',
+  Default = 'popup',
+  Global = 'global',
+}
+
+export type BasePlacement = Extract<
+  PopupPlacement,
+  PopupPlacement.Bottom | PopupPlacement.Top | PopupPlacement.Left | PopupPlacement.Right
+>;
+
+export const PLACEMENT_FLIP_MAP: { [key in PopupPlacement]: PopupPlacement } = {
+  [PopupPlacement.Left]: PopupPlacement.Right,
+  [PopupPlacement.LeftBottom]: PopupPlacement.RightBottom,
+  [PopupPlacement.LeftTop]: PopupPlacement.RightTop,
+  [PopupPlacement.Right]: PopupPlacement.Left,
+  [PopupPlacement.RightBottom]: PopupPlacement.LeftBottom,
+  [PopupPlacement.RightTop]: PopupPlacement.LeftTop,
+  [PopupPlacement.Top]: PopupPlacement.Bottom,
+  [PopupPlacement.TopLeft]: PopupPlacement.BottomLeft,
+  [PopupPlacement.TopRight]: PopupPlacement.BottomRight,
+  [PopupPlacement.Bottom]: PopupPlacement.Top,
+  [PopupPlacement.BottomLeft]: PopupPlacement.TopLeft,
+  [PopupPlacement.BottomRight]: PopupPlacement.TopRight,
+};
+
+const MARGIN_TO_EDGE = 4;
+
+export function popupPositioning(
+  toggleNode: Element,
+  popupNode: Element,
+  placement: PopupPlacement = PopupPlacement.Bottom
+) {
+  const toggleRect = toggleNode.getBoundingClientRect();
+  const popupRect = popupNode.getBoundingClientRect();
+
+  let left = 0;
+  let top = 0;
+
+  switch (placement) {
+    case PopupPlacement.Bottom:
+      left = toggleRect.left + toggleRect.width / 2 - popupRect.width / 2;
+      top = toggleRect.top + toggleRect.height;
+      break;
+    case PopupPlacement.BottomLeft:
+      left = toggleRect.left;
+      top = toggleRect.top + toggleRect.height;
+      break;
+    case PopupPlacement.BottomRight:
+      left = toggleRect.left + toggleRect.width - popupRect.width;
+      top = toggleRect.top + toggleRect.height;
+      break;
+    case PopupPlacement.Left:
+      left = toggleRect.left - popupRect.width;
+      top = toggleRect.top + toggleRect.height / 2 - popupRect.height / 2;
+      break;
+    case PopupPlacement.LeftTop:
+      left = toggleRect.left - popupRect.width;
+      top = toggleRect.top;
+      break;
+    case PopupPlacement.LeftBottom:
+      left = toggleRect.left - popupRect.width;
+      top = toggleRect.top + toggleRect.height - popupRect.height;
+      break;
+    case PopupPlacement.Right:
+      left = toggleRect.left + toggleRect.width;
+      top = toggleRect.top + toggleRect.height / 2 - popupRect.height / 2;
+      break;
+    case PopupPlacement.RightTop:
+      left = toggleRect.left + toggleRect.width;
+      top = toggleRect.top;
+      break;
+    case PopupPlacement.RightBottom:
+      left = toggleRect.left + toggleRect.width;
+      top = toggleRect.top + toggleRect.height - popupRect.height;
+      break;
+    case PopupPlacement.Top:
+      left = toggleRect.left + toggleRect.width / 2 - popupRect.width / 2;
+      top = toggleRect.top - popupRect.height;
+      break;
+    case PopupPlacement.TopLeft:
+      left = toggleRect.left;
+      top = toggleRect.top - popupRect.height;
+      break;
+    case PopupPlacement.TopRight:
+      left = toggleRect.left + toggleRect.width - popupRect.width;
+      top = toggleRect.top - popupRect.height;
+      break;
+  }
+
+  const inBoundariesLeft = Math.min(
+    Math.max(left, getMinLeftPlacement(toggleRect)),
+    getMaxLeftPlacement(toggleRect, popupRect)
+  );
+  const inBoundariesTop = Math.min(
+    Math.max(top, getMinTopPlacement(toggleRect)),
+    getMaxTopPlacement(toggleRect, popupRect)
+  );
+
+  return {
+    height: popupRect.height,
+    left: inBoundariesLeft,
+    leftFix: inBoundariesLeft - left,
+    top: inBoundariesTop,
+    topFix: inBoundariesTop - top,
+    width: popupRect.width,
+  };
+}
+
+function getMinLeftPlacement(toggleRect: DOMRect) {
+  return Math.min(
+    MARGIN_TO_EDGE, // Left edge of the sceen
+    toggleRect.left + toggleRect.width / 2 // Left edge of the screen when scrolled
+  );
+}
+
+function getMaxLeftPlacement(toggleRect: DOMRect, popupRect: DOMRect) {
+  return Math.max(
+    document.documentElement.clientWidth - popupRect.width - MARGIN_TO_EDGE, // Right edge of the screen
+    toggleRect.left + toggleRect.width / 2 - popupRect.width // Right edge of the screen when scrolled
+  );
+}
+
+function getMinTopPlacement(toggleRect: DOMRect) {
+  return Math.min(
+    MARGIN_TO_EDGE, // Top edge of the sceen
+    toggleRect.top + toggleRect.height / 2 // Top edge of the screen when scrolled
+  );
+}
+
+function getMaxTopPlacement(toggleRect: DOMRect, popupRect: DOMRect) {
+  return Math.max(
+    document.documentElement.clientHeight - popupRect.height - MARGIN_TO_EDGE, // Bottom edge of the screen
+    toggleRect.top + toggleRect.height / 2 - popupRect.height // Bottom edge of the screen when scrolled
+  );
+}
diff --git a/server/sonar-web/design-system/src/helpers/testUtils.tsx b/server/sonar-web/design-system/src/helpers/testUtils.tsx
new file mode 100644 (file)
index 0000000..558906f
--- /dev/null
@@ -0,0 +1,117 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { render as rtlRender, RenderOptions } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Options as UserEventsOptions } from '@testing-library/user-event/dist/types/options';
+import { InitialEntry } from 'history';
+import { identity, kebabCase } from 'lodash';
+import React, { PropsWithChildren, ReactNode } from 'react';
+import { HelmetProvider } from 'react-helmet-async';
+import { IntlProvider } from 'react-intl';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+
+export function render(
+  ui: React.ReactElement,
+  options?: RenderOptions,
+  userEventOptions?: UserEventsOptions
+) {
+  return { ...rtlRender(ui, options), user: userEvent.setup(userEventOptions) };
+}
+
+type RenderContextOptions = Omit<RenderOptions, 'wrapper'> & {
+  initialEntries?: InitialEntry[];
+  userEventOptions?: UserEventsOptions;
+};
+
+export function renderWithContext(
+  ui: React.ReactElement,
+  { userEventOptions, ...options }: RenderContextOptions = {}
+) {
+  return render(ui, { ...options, wrapper: getContextWrapper() }, userEventOptions);
+}
+
+type RenderRouterOptions = { additionalRoutes?: ReactNode };
+
+export function renderWithRouter(
+  ui: React.ReactElement,
+  options: RenderContextOptions & RenderRouterOptions = {}
+) {
+  const { additionalRoutes, userEventOptions, ...renderOptions } = options;
+
+  function RouterWrapper({ children }: React.PropsWithChildren<{}>) {
+    return (
+      <HelmetProvider>
+        <MemoryRouter>
+          <Routes>
+            <Route element={children} path="/" />
+            {additionalRoutes}
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>
+    );
+  }
+
+  return render(ui, { ...renderOptions, wrapper: RouterWrapper }, userEventOptions);
+}
+
+function getContextWrapper() {
+  return function ContextWrapper({ children }: React.PropsWithChildren<{}>) {
+    return (
+      <HelmetProvider>
+        <IntlProvider defaultLocale="en" locale="en">
+          {children}
+        </IntlProvider>
+      </HelmetProvider>
+    );
+  };
+}
+
+export function mockComponent(name: string, transformProps: (props: any) => any = identity) {
+  function MockedComponent({ ...props }: PropsWithChildren<any>) {
+    return React.createElement('mocked-' + kebabCase(name), transformProps(props));
+  }
+
+  MockedComponent.displayName = `mocked(${name})`;
+  return MockedComponent;
+}
+
+export const debounceTimer = jest.fn().mockImplementation((callback, timeout) => {
+  let timeoutId: number;
+  const debounced = jest.fn((...args) => {
+    window.clearTimeout(timeoutId);
+    timeoutId = window.setTimeout(() => callback(...args), timeout);
+  });
+  (debounced as any).cancel = jest.fn(() => {
+    window.clearTimeout(timeoutId);
+  });
+  return debounced;
+});
+
+export function flushPromises(usingFakeTime = false): Promise<void> {
+  return new Promise((resolve) => {
+    if (usingFakeTime) {
+      jest.useRealTimers();
+    }
+    setTimeout(resolve, 0);
+    if (usingFakeTime) {
+      jest.useFakeTimers();
+    }
+  });
+}
diff --git a/server/sonar-web/design-system/src/helpers/theme.ts b/server/sonar-web/design-system/src/helpers/theme.ts
new file mode 100644 (file)
index 0000000..6dab879
--- /dev/null
@@ -0,0 +1,130 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { CSSColor, Theme, ThemeColors, ThemeContrasts, ThemedProps } from '../types/theme';
+import { getRGBAString } from './colors';
+
+export function getProp<T>(name: keyof Omit<T, keyof ThemedProps>) {
+  return (props: T) => props[name];
+}
+
+export function themeColor(name: ThemeColors | CSSColor, opacity?: number) {
+  return function ({ theme }: ThemedProps) {
+    return getColor(theme, [], name, opacity);
+  };
+}
+
+export function themeContrast(name: ThemeColors | CSSColor) {
+  return function ({ theme }: ThemedProps) {
+    return getContrast(theme, name);
+  };
+}
+
+export function themeBorder(
+  name: keyof Theme['borders'] = 'default',
+  color?: ThemeColors | CSSColor,
+  opacity?: number
+) {
+  return function ({ theme }: ThemedProps) {
+    const [width, style, ...rgba] = theme.borders[name];
+    return `${width} ${style} ${getColor(theme, rgba as number[], color, opacity)}`;
+  };
+}
+
+export function themeShadow(
+  name: keyof Theme['shadows'],
+  color?: ThemeColors | CSSColor,
+  opacity?: number
+) {
+  return function ({ theme }: ThemedProps) {
+    const shadows = theme.shadows[name];
+    return shadows
+      .map((item) => {
+        const [x, y, blur, spread, ...rgba] = item;
+        return `${x}px ${y}px ${blur}px ${spread}px ${getColor(theme, rgba, color, opacity)}`;
+      })
+      .join(',');
+  };
+}
+
+export function themeAvatarColor(name: string, contrast = false) {
+  return function ({ theme }: ThemedProps) {
+    let hash = 0;
+    for (let i = 0; i < name.length; i++) {
+      hash = name.charCodeAt(i) + ((hash << 5) - hash);
+    }
+
+    // Reduces number length to avoid modulo's limit.
+    hash = parseInt(hash.toString().slice(-5), 10);
+    if (contrast) {
+      return getColor(theme, theme.avatar.contrast[hash % theme.avatar.contrast.length]);
+    }
+    return getColor(theme, theme.avatar.color[hash % theme.avatar.color.length]);
+  };
+}
+
+export function themeImage(imageKey: keyof Theme['images']) {
+  return function ({ theme }: ThemedProps) {
+    return theme.images[imageKey];
+  };
+}
+
+function getColor(
+  theme: Theme,
+  [r, g, b, a]: number[],
+  colorOverride?: ThemeColors | CSSColor,
+  opacityOverride?: number
+) {
+  // Custom CSS property or rgb(a) color, return it directly
+  if (
+    colorOverride?.startsWith('var(--') ||
+    colorOverride?.startsWith('rgb(') ||
+    colorOverride?.startsWith('rgba(')
+  ) {
+    return colorOverride as CSSColor;
+  }
+  // Is theme color overridden by a color name ?
+  const color = colorOverride ? theme.colors[colorOverride as ThemeColors] : [r, g, b];
+  if (typeof color === 'string') {
+    return color as CSSColor;
+  }
+
+  return getRGBAString(color, opacityOverride ?? color[3] ?? a);
+}
+
+// Simplified version of getColor for contrast colors, fallback to colors if contrast isn't found
+function getContrast(theme: Theme, colorOverride: ThemeContrasts | ThemeColors | CSSColor) {
+  // Custom CSS property or rgb(a) color, return it directly
+  if (
+    colorOverride?.startsWith('var(--') ||
+    colorOverride?.startsWith('rgb(') ||
+    colorOverride?.startsWith('rgba(')
+  ) {
+    return colorOverride as CSSColor;
+  }
+
+  // For contrast we always require a color override (it's the principle of a contrast)
+  const color =
+    theme.contrasts[colorOverride as ThemeContrasts] || theme.colors[colorOverride as ThemeColors];
+  if (typeof color === 'string') {
+    return color as CSSColor;
+  }
+
+  return getRGBAString(color, color[3]);
+}
diff --git a/server/sonar-web/design-system/src/helpers/types.ts b/server/sonar-web/design-system/src/helpers/types.ts
new file mode 100644 (file)
index 0000000..05b2043
--- /dev/null
@@ -0,0 +1,22 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+export function isDefined<T>(x: T | undefined | null): x is T {
+  return x !== undefined && x !== null;
+}
diff --git a/server/sonar-web/design-system/src/index.ts b/server/sonar-web/design-system/src/index.ts
new file mode 100644 (file)
index 0000000..cd4bd05
--- /dev/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';
diff --git a/server/sonar-web/design-system/src/theme/colors.ts b/server/sonar-web/design-system/src/theme/colors.ts
new file mode 100644 (file)
index 0000000..785f6f0
--- /dev/null
@@ -0,0 +1,136 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+export default {
+  white: [255, 255, 255],
+  black: [0, 0, 0],
+  sonarcloud: [243, 112, 42],
+  grey: { 50: [235, 235, 235], 100: [221, 221, 221] },
+  blueGrey: {
+    25: [252, 252, 253],
+    50: [239, 242, 249],
+    100: [225, 230, 243],
+    200: [197, 205, 223],
+    300: [166, 173, 194],
+    400: [106, 117, 144],
+    500: [62, 67, 87],
+    600: [42, 47, 64],
+    700: [29, 33, 47],
+    800: [18, 20, 29],
+    900: [8, 9, 12],
+  },
+  indigo: {
+    25: [244, 246, 255],
+    50: [232, 235, 255],
+    100: [209, 215, 254],
+    200: [189, 198, 255],
+    300: [159, 169, 237],
+    400: [123, 135, 217],
+    500: [93, 108, 208],
+    600: [75, 86, 187],
+    700: [71, 81, 143],
+    800: [43, 51, 104],
+    900: [27, 34, 80],
+  },
+  tangerine: {
+    25: [255, 248, 244],
+    50: [250, 230, 220],
+    100: [246, 206, 187],
+    200: [243, 185, 157],
+    300: [240, 166, 130],
+    400: [237, 148, 106],
+    500: [235, 131, 82],
+    600: [233, 116, 63],
+    700: [231, 102, 49],
+    800: [181, 68, 25],
+    900: [130, 43, 10],
+  },
+  green: {
+    50: [246, 254, 249],
+    100: [236, 253, 243],
+    200: [209, 250, 223],
+    300: [166, 244, 197],
+    400: [50, 213, 131],
+    500: [18, 183, 106],
+    600: [3, 152, 85],
+    700: [2, 122, 72],
+    800: [5, 96, 58],
+    900: [5, 79, 49],
+  },
+  yellowGreen: {
+    50: [247, 251, 230],
+    100: [241, 250, 210],
+    200: [225, 245, 168],
+    300: [197, 230, 124],
+    400: [166, 208, 91],
+    500: [110, 183, 18],
+    600: [104, 154, 48],
+    700: [83, 128, 39],
+    800: [63, 104, 29],
+    900: [49, 85, 22],
+  },
+  yellow: {
+    50: [252, 245, 228],
+    100: [254, 245, 208],
+    200: [252, 233, 163],
+    300: [250, 220, 121],
+    400: [248, 205, 92],
+    500: [245, 184, 64],
+    600: [209, 152, 52],
+    700: [174, 122, 41],
+    800: [140, 94, 30],
+    900: [102, 64, 15],
+  },
+  orange: {
+    50: [255, 240, 235],
+    100: [254, 219, 199],
+    200: [255, 214, 175],
+    300: [254, 150, 75],
+    400: [253, 113, 34],
+    500: [247, 95, 9],
+    600: [220, 94, 3],
+    700: [181, 71, 8],
+    800: [147, 55, 13],
+    900: [122, 46, 14],
+  },
+  red: {
+    50: [254, 243, 242],
+    100: [254, 228, 226],
+    200: [254, 205, 202],
+    300: [253, 162, 155],
+    400: [249, 112, 102],
+    500: [240, 68, 56],
+    600: [217, 45, 32],
+    700: [180, 35, 24],
+    800: [128, 27, 20],
+    900: [93, 29, 19],
+  },
+  blue: {
+    50: [245, 251, 255],
+    100: [233, 244, 251],
+    200: [184, 222, 241],
+    300: [143, 202, 234],
+    400: [110, 185, 228],
+    500: [85, 170, 223],
+    600: [69, 149, 203],
+    700: [58, 127, 173],
+    800: [49, 108, 146],
+    900: [23, 67, 97],
+  },
+};
diff --git a/server/sonar-web/design-system/src/theme/index.ts b/server/sonar-web/design-system/src/theme/index.ts
new file mode 100644 (file)
index 0000000..6b8c84a
--- /dev/null
@@ -0,0 +1,20 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+export { default as lightTheme } from './light';
diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts
new file mode 100644 (file)
index 0000000..8b10b33
--- /dev/null
@@ -0,0 +1,743 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import COLORS from './colors';
+
+const primary = {
+  light: COLORS.indigo[400],
+  default: COLORS.indigo[500],
+  dark: COLORS.indigo[600],
+};
+
+const secondary = {
+  light: COLORS.blueGrey[50],
+  default: COLORS.blueGrey[200],
+  dark: COLORS.blueGrey[400],
+  darker: COLORS.blueGrey[500],
+};
+
+const danger = {
+  lightest: COLORS.red[50],
+  lighter: COLORS.red[300],
+  light: COLORS.red[400],
+  default: COLORS.red[600],
+  dark: COLORS.red[700],
+  darker: COLORS.red[800],
+};
+
+const lightTheme = {
+  id: 'light-theme',
+  highlightTheme: 'atom-one-light.css',
+  logo: 'sonarcloud-logo-black.svg',
+
+  colors: {
+    transparent: 'transparent',
+    currentColor: 'currentColor',
+
+    backgroundPrimary: COLORS.blueGrey[25],
+    backgroundSecondary: COLORS.white,
+    border: COLORS.grey[50],
+    sonarcloud: COLORS.sonarcloud,
+
+    // primary
+    primaryLight: primary.light,
+    primary: primary.default,
+    primaryDark: primary.dark,
+
+    // danger
+    danger: danger.dark,
+
+    // buttons
+    button: primary.default,
+    buttonHover: primary.dark,
+    buttonSecondary: COLORS.white,
+    buttonSecondaryBorder: secondary.default,
+    buttonSecondaryHover: secondary.light,
+    buttonDisabled: secondary.light,
+    buttonDisabledBorder: secondary.default,
+
+    // danger buttons
+    dangerButton: danger.default,
+    dangerButtonHover: danger.dark,
+    dangerButtonFocus: danger.default,
+    dangerButtonSecondary: COLORS.white,
+    dangerButtonSecondaryBorder: danger.lighter,
+    dangerButtonSecondaryHover: danger.lightest,
+    dangerButtonSecondaryFocus: danger.light,
+
+    // third party button
+    thirdPartyButton: COLORS.white,
+    thirdPartyButtonBorder: secondary.default,
+    thirdPartyButtonHover: secondary.light,
+
+    // popup
+    popup: COLORS.white,
+    popupBorder: secondary.default,
+
+    // dropdown menu
+    dropdownMenu: COLORS.white,
+    dropdownMenuHover: secondary.light,
+    dropdownMenuFocus: COLORS.indigo[50],
+    dropdownMenuFocusBorder: primary.light,
+    dropdownMenuDisabled: COLORS.white,
+    dropdownMenuHeader: COLORS.white,
+    dropdownMenuDanger: danger.default,
+    dropdownMenuSubTitle: secondary.dark,
+
+    // radio
+    radio: primary.default,
+    radioBorder: primary.default,
+    radioHover: COLORS.indigo[50],
+    radioFocus: COLORS.indigo[50],
+    radioFocusBorder: COLORS.indigo[300],
+    radioFocusOutline: [...COLORS.indigo[300], 0.2],
+    radioChecked: COLORS.indigo[50],
+    radioDisabled: secondary.default,
+    radioDisabledBackground: secondary.light,
+    radioDisabledBorder: secondary.default,
+
+    // switch
+    switch: secondary.default,
+    switchDisabled: COLORS.blueGrey[100],
+    switchActive: primary.default,
+    switchHover: COLORS.blueGrey[300],
+    switchHoverActive: primary.light,
+    switchButton: COLORS.white,
+    switchButtonDisabled: secondary.light,
+
+    // sidebar
+    // NOTE: these aren't used because the sidebar is exclusively dark. but for type purposes are listed here
+    sidebarBackground: COLORS.blueGrey[700],
+    sidebarItemActive: COLORS.blueGrey[800],
+    sidebarBorder: COLORS.blueGrey[500],
+    sidebarTextDisabled: COLORS.blueGrey[400],
+    sidebarIcon: COLORS.blueGrey[400],
+    sidebarActiveIcon: COLORS.blueGrey[200],
+
+    //separator-circle
+    separatorCircle: COLORS.blueGrey[200],
+    separatorSlash: COLORS.blueGrey[300],
+
+    // flag message
+    flagMessageBackground: COLORS.white,
+
+    errorBorder: danger.light,
+    errorBackground: danger.lightest,
+    errorText: danger.dark,
+
+    warningBorder: COLORS.yellow[400],
+    warningBackground: COLORS.yellow[50],
+
+    successBorder: COLORS.green[400],
+    successBackground: COLORS.green[50],
+
+    infoBorder: COLORS.blue[400],
+    infoBackground: COLORS.blue[50],
+
+    // banner message
+    bannerMessage: danger.lightest,
+    bannerMessageIcon: danger.darker,
+
+    // toggle buttons
+    toggle: COLORS.white,
+    toggleBorder: secondary.default,
+    toggleHover: secondary.light,
+    toggleFocus: [...secondary.default, 0.2],
+
+    // code snippet
+    codeSnippetBackground: COLORS.blueGrey[25],
+    codeSnippetBorder: COLORS.blueGrey[100],
+    codeSnippetHighlight: secondary.default,
+
+    // code viewer
+    codeLineIssueIndicator: COLORS.blueGrey[400], // Should be blueGrey[300], to be changed once code viewer is reworked
+
+    // checkbox
+    checkboxHover: COLORS.indigo[50],
+    checkboxCheckedHover: primary.light,
+    checkboxDisabled: secondary.light,
+    checkboxDisabledChecked: secondary.default,
+    checkboxLabel: COLORS.blueGrey[500],
+
+    // input search
+    searchHighlight: COLORS.tangerine[50],
+
+    // input field
+    inputBackground: COLORS.white,
+    inputBorder: secondary.default,
+    inputFocus: primary.light,
+    inputDanger: danger.default,
+    inputDangerFocus: danger.light,
+    inputSuccess: COLORS.yellowGreen[500],
+    inputSuccessFocus: COLORS.yellowGreen[400],
+    inputDisabled: secondary.light,
+    inputDisabledBorder: secondary.default,
+    inputPlaceholder: secondary.dark,
+
+    // required input
+    inputRequired: danger.dark,
+
+    // tooltip
+    tooltipBackground: COLORS.blueGrey[600],
+    tooltipSeparator: secondary.dark,
+
+    // avatar
+    avatarBackground: COLORS.white,
+    avatarBorder: COLORS.blueGrey[100],
+
+    // badges
+    badgeNew: COLORS.indigo[100],
+    badgeDefault: COLORS.blueGrey[100],
+    badgeDeleted: COLORS.red[100],
+    badgeCounter: COLORS.blueGrey[100],
+
+    // input select
+    selectOptionSelected: secondary.light,
+
+    // breadcrumbs
+    breadcrumb: 'transparent',
+
+    // tab
+    tabBorder: primary.light,
+
+    //table
+    tableRowHover: COLORS.indigo[25],
+    tableRowSelected: COLORS.indigo[300],
+
+    // links
+    linkDefault: primary.default,
+    linkActive: COLORS.indigo[600],
+    linkDiscreet: 'currentColor',
+    linkTooltipDefault: COLORS.indigo[200],
+    linkTooltipActive: COLORS.indigo[100],
+
+    // discreet select
+    discreetBorder: secondary.default,
+    discreetBackground: COLORS.white,
+    discreetHover: secondary.light,
+    discreetButtonHover: COLORS.indigo[500],
+    discreetFocus: COLORS.indigo[50],
+    discreetFocusBorder: primary.light,
+
+    // interactive icon
+    interactiveIcon: 'transparent',
+    interactiveIconHover: COLORS.indigo[50],
+    interactiveIconFocus: primary.default,
+    bannerIcon: 'transparent',
+    bannerIconHover: [...COLORS.red[600], 0.2],
+    bannerIconFocus: danger.default,
+    discreetInteractiveIcon: secondary.dark,
+    destructiveIcon: 'transparent',
+    destructiveIconHover: danger.lightest,
+    destructiveIconFocus: danger.default,
+
+    // icons
+    iconSeverityMajor: danger.light,
+    iconSeverityMinor: COLORS.yellowGreen[400],
+    iconSeverityInfo: COLORS.blue[400],
+    iconDirectory: COLORS.orange[300],
+    iconFile: COLORS.blueGrey[300],
+    iconProject: COLORS.blueGrey[300],
+    iconUnitTest: COLORS.blueGrey[300],
+    iconFavorite: COLORS.tangerine[400],
+    iconCheck: COLORS.green[500],
+    iconPositiveUpdate: COLORS.green[300],
+    iconNegativeUpdate: COLORS.red[300],
+    iconTrendPositive: COLORS.green[400],
+    iconTrendNegative: COLORS.red[400],
+    iconTrendNeutral: COLORS.blue[400],
+    iconTrendDisabled: COLORS.blueGrey[400],
+    iconError: danger.default,
+    iconWarning: COLORS.yellow[600],
+    iconSuccess: COLORS.green[600],
+    iconInfo: COLORS.blue[600],
+    iconStatus: COLORS.blueGrey[200],
+    iconStatusResolved: secondary.dark,
+    iconNotificationsOn: COLORS.indigo[300],
+    iconHelperHint: COLORS.blueGrey[100],
+    iconRuleInheritanceOverride: danger.light,
+
+    // numbered list
+    numberedList: COLORS.indigo[50],
+
+    // unordered list
+    listMarker: COLORS.blueGrey[300],
+
+    // product news
+    productNews: COLORS.indigo[50],
+    productNewsHover: COLORS.indigo[100],
+
+    // scrollbar
+    scrollbar: COLORS.blueGrey[25],
+
+    // resizer
+    resizer: secondary.default,
+
+    // coverage indicators
+    coverageGreen: COLORS.green[500],
+    coverageRed: danger.dark,
+
+    // duplications indicators
+    'duplicationsRating.A': COLORS.green[500],
+    'duplicationsRating.B': COLORS.yellowGreen[500],
+    'duplicationsRating.C': COLORS.yellow[500],
+    'duplicationsRating.D': COLORS.orange[500],
+    'duplicationsRating.E': COLORS.red[500],
+    duplicationsRatingSecondary: secondary.light,
+
+    // size indicators
+    sizeIndicator: COLORS.blue[500],
+
+    // rating colors
+    'rating.A': COLORS.green[200],
+    'rating.B': COLORS.yellowGreen[200],
+    'rating.C': COLORS.yellow[200],
+    'rating.D': COLORS.orange[200],
+    'rating.E': COLORS.red[200],
+
+    // date picker
+    datePicker: COLORS.white,
+    datePickerIcon: secondary.default,
+    datePickerDisabled: COLORS.white,
+    datePickerDefault: COLORS.white,
+    datePickerHover: COLORS.blueGrey[100],
+    datePickerSelected: primary.default,
+    datePickerRange: COLORS.indigo[100],
+
+    // tags
+    tag: secondary.light,
+
+    // quality gate indicator
+    qgIndicatorPassed: COLORS.green[200],
+    qgIndicatorFailed: COLORS.red[200],
+    qgIndicatorNotComputed: COLORS.blueGrey[200],
+
+    // main bar
+    mainBar: COLORS.white,
+    mainBarHover: COLORS.blueGrey[600],
+    mainBarLogo: COLORS.white,
+    mainBarDarkLogo: COLORS.blueGrey[800],
+    mainBarNews: COLORS.indigo[50],
+    menuBorder: primary.light,
+
+    // navbar
+    navbar: COLORS.white,
+    navbarTextMeta: secondary.darker,
+
+    // filterbar
+    filterbar: COLORS.white,
+    filterbarBorder: COLORS.blueGrey[100],
+
+    // facets
+    facetHeader: COLORS.blueGrey[600],
+    facetItemSelected: COLORS.indigo[50],
+    facetItemSelectedHover: COLORS.indigo[100],
+    facetItemSelectedBorder: primary.light,
+    facetItemDisabled: COLORS.blueGrey[300],
+    facetItemLight: secondary.dark,
+    facetItemGraph: secondary.default,
+    facetKeyboardHint: COLORS.blueGrey[50],
+    facetToggleActive: COLORS.green[500],
+    facetToggleInactive: COLORS.red[500],
+    facetToggleHover: COLORS.blueGrey[600],
+
+    // subnavigation sidebar
+    subnavigation: COLORS.white,
+    subnavigationHover: COLORS.indigo[50],
+    subnavigationBorder: COLORS.grey[100],
+    subnavigationSeparator: COLORS.grey[50],
+    subnavigationSubheading: COLORS.blueGrey[25],
+
+    // footer
+    footer: COLORS.white,
+    footerBorder: COLORS.grey[100],
+
+    // project
+    projectCardBackground: COLORS.white,
+    projectCardBorder: COLORS.blueGrey[100],
+
+    // overview
+    iconOverviewIssue: COLORS.blueGrey[400],
+
+    // graph - chart
+    graphPointCircleColor: COLORS.white,
+    'graphLineColor.0': COLORS.blue[500],
+    'graphLineColor.1': COLORS.blue[700],
+    'graphLineColor.2': COLORS.blue[300],
+    'graphLineColor.3': COLORS.blue[900],
+    graphGridColor: COLORS.grey[50],
+    graphCursorLineColor: COLORS.blueGrey[400],
+    newCodeHighlight: COLORS.indigo[300],
+    graphZoomBackgroundColor: COLORS.blueGrey[25],
+    graphZoomBorderColor: COLORS.blueGrey[100],
+    graphZoomHandleColor: COLORS.blueGrey[400],
+
+    // page
+    pageTitle: COLORS.blueGrey[700],
+    pageContentLight: secondary.dark,
+    pageContent: secondary.darker,
+    pageContentDark: COLORS.blueGrey[600],
+    pageBlock: COLORS.white,
+    pageBlockBorder: COLORS.blueGrey[100],
+
+    // core concepts
+    coreConceptsCloseIcon: COLORS.blueGrey[300],
+    coreConceptsTitle: secondary.darker,
+    coreConceptsBody: secondary.darker,
+    coreConceptsHomeBorder: COLORS.blueGrey[100],
+    coreConceptsCompleted: COLORS.green[500],
+    coreConceptsPulse: COLORS.indigo[500],
+    coreConceptsPulseFallback: COLORS.white,
+
+    // progress bar
+    coreConceptsProgressBar: secondary.light,
+
+    // issue box
+    issueBoxBorder: danger.lighter,
+    issueBoxBorderDepracated: secondary.default,
+    issueTypeIcon: COLORS.red[200],
+
+    // separator
+    pipeSeparator: COLORS.blueGrey[100],
+
+    // drilldown link
+    drilldown: secondary.darker,
+    drilldownBorder: secondary.default,
+
+    // selection card
+    selectionCardHeader: secondary.darker,
+    selectionCardDisabled: secondary.light,
+    selectionCardBorder: COLORS.blueGrey[100],
+    selectionCardBorderHover: COLORS.indigo[200],
+    selectionCardBorderSelected: primary.light,
+    selectionCardBorderDisabled: secondary.default,
+
+    // bubble charts
+    bubbleChartLine: COLORS.grey[50],
+    bubbleDefault: [...COLORS.blue[500], 0.3],
+    'bubble.1': [...COLORS.green[500], 0.3],
+    'bubble.2': [...COLORS.yellowGreen[500], 0.3],
+    'bubble.3': [...COLORS.yellow[500], 0.3],
+    'bubble.4': [...COLORS.orange[500], 0.3],
+    'bubble.5': [...COLORS.red[500], 0.3],
+
+    // leak legend
+    leakLegend: [...COLORS.indigo[300], 0.15],
+    leakLegendBorder: COLORS.indigo[100],
+
+    // hotspot
+    hotspotStatus: COLORS.blueGrey[25],
+
+    // activity comments
+    activityCommentPipe: COLORS.tangerine[200],
+
+    // illustrations
+    illustrationOutline: COLORS.blueGrey[400],
+    illustrationInlineBorder: COLORS.blueGrey[100],
+    illustrationPrimary: COLORS.indigo[400],
+    illustrationSecondary: COLORS.indigo[200],
+    illustrationShade: COLORS.indigo[25],
+
+    // news bar
+    newsBar: COLORS.white,
+    newsBorder: COLORS.grey[100],
+    newsContent: COLORS.white,
+    newsTag: COLORS.blueGrey[50],
+    roadmap: COLORS.indigo[25],
+    roadmapContent: 'transparent',
+
+    // project analyse page
+    almCardBorder: COLORS.grey[100],
+  },
+
+  // contrast colors to be used for text when using a color background with the same name
+  // must match the color name
+  contrasts: {
+    backgroundPrimary: COLORS.blueGrey[900],
+    backgroundSecondary: COLORS.blueGrey[900],
+    primaryLight: secondary.darker,
+    primary: COLORS.white,
+
+    // switch
+    switchHover: primary.light,
+    switchButton: primary.default,
+    switchButtonDisabled: COLORS.blueGrey[300],
+
+    // sidebar
+    sidebarBackground: COLORS.blueGrey[200],
+    sidebarItemActive: COLORS.blueGrey[25],
+
+    // flag message
+    flagMessageBackground: secondary.darker,
+
+    // banner message
+    bannerMessage: COLORS.red[900],
+
+    // buttons
+    buttonDisabled: COLORS.blueGrey[300],
+    buttonSecondary: secondary.darker,
+
+    // danger buttons
+    dangerButton: COLORS.white,
+    dangerButtonSecondary: danger.dark,
+
+    // third party button
+    thirdPartyButton: secondary.darker,
+
+    // popup
+    popup: secondary.darker,
+
+    // dropdown menu
+    dropdownMenu: secondary.darker,
+    dropdownMenuDisabled: COLORS.blueGrey[300],
+    dropdownMenuHeader: secondary.dark,
+
+    // toggle buttons
+    toggle: secondary.darker,
+    toggleHover: secondary.darker,
+
+    // code snippet
+    codeSnippetHighlight: danger.default,
+
+    // checkbox
+    checkboxDisabled: secondary.default,
+
+    // input search
+    searchHighlight: secondary.darker,
+
+    // input field
+    inputBackground: secondary.darker,
+    inputDisabled: COLORS.blueGrey[300],
+
+    // tooltip
+    tooltipBackground: secondary.light,
+
+    // badges
+    badgeNew: COLORS.indigo[900],
+    badgeDefault: COLORS.blueGrey[700],
+    badgeDeleted: COLORS.red[900],
+    badgeCounter: secondary.darker,
+
+    // breadcrumbs
+    breadcrumb: secondary.dark,
+
+    // discreet select
+    discreetBackground: secondary.darker,
+    discreetHover: secondary.darker,
+
+    // interactive icons
+    interactiveIcon: primary.dark,
+    interactiveIconHover: COLORS.indigo[800],
+    bannerIcon: danger.darker,
+    bannerIconHover: danger.darker,
+    destructiveIcon: danger.default,
+    destructiveIconHover: danger.darker,
+
+    // icons
+    iconSeverityMajor: COLORS.white,
+    iconSeverityMinor: COLORS.white,
+    iconSeverityInfo: COLORS.white,
+    iconStatusResolved: COLORS.white,
+    iconHelperHint: secondary.darker,
+
+    // numbered list
+    numberedList: COLORS.indigo[800],
+
+    // product news
+    productNews: secondary.darker,
+    productNewsHover: secondary.darker,
+
+    // scrollbar
+    scrollbar: COLORS.grey[100],
+
+    // size indicators
+    sizeIndicator: COLORS.white,
+
+    // rating colors
+    'rating.A': COLORS.green[900],
+    'rating.B': COLORS.yellowGreen[900],
+    'rating.C': COLORS.yellow[900],
+    'rating.D': COLORS.orange[900],
+    'rating.E': COLORS.red[900],
+
+    // date picker
+    datePicker: COLORS.blueGrey[300],
+    datePickerDisabled: COLORS.blueGrey[300],
+    datePickerDefault: COLORS.blueGrey[600],
+    datePickerHover: COLORS.blueGrey[600],
+    datePickerSelected: COLORS.white,
+    datePickerRange: COLORS.blueGrey[600],
+
+    // tags
+    tag: secondary.darker,
+
+    // quality gate indicator
+    qgIndicatorPassed: COLORS.green[800],
+    qgIndicatorFailed: danger.darker,
+    qgIndicatorNotComputed: COLORS.blueGrey[800],
+
+    // main bar
+    mainBar: secondary.darker,
+    mainBarLogo: COLORS.black,
+    mainBarDarkLogo: COLORS.white,
+    mainBarNews: secondary.darker,
+
+    // navbar
+    navbar: secondary.darker,
+
+    // filterbar
+    filterbar: secondary.darker,
+
+    // facet
+    facetKeyboardHint: secondary.darker,
+    facetToggleActive: COLORS.white,
+    facetToggleInactive: COLORS.white,
+
+    // subnavigation sidebar
+    subnavigation: secondary.darker,
+    subnavigationHover: COLORS.blueGrey[700],
+    subnavigationSubheading: secondary.dark,
+
+    // footer
+    footer: secondary.dark,
+
+    // page
+    pageBlock: secondary.darker,
+
+    // graph - chart
+    graphZoomHandleColor: COLORS.white,
+
+    // progress bar
+    coreConceptsProgressBar: primary.light,
+
+    // issue box
+    issueTypeIcon: COLORS.red[900],
+
+    // selection card
+    selectionCardDisabled: secondary.dark,
+
+    // bubble charts
+    bubbleDefault: COLORS.blue[500],
+    'bubble.1': COLORS.green[500],
+    'bubble.2': COLORS.yellowGreen[500],
+    'bubble.3': COLORS.yellow[500],
+    'bubble.4': COLORS.orange[500],
+    'bubble.5': COLORS.red[500],
+
+    // news bar
+    newsBar: COLORS.blueGrey[600],
+    newsContent: COLORS.blueGrey[500],
+    newsTag: COLORS.blueGrey[500],
+    roadmap: COLORS.blueGrey[600],
+    roadmapContent: COLORS.blueGrey[500],
+  },
+
+  // predefined shadows
+  shadows: {
+    xs: [[0, 1, 2, 0, ...COLORS.blueGrey[700], 0.05]],
+    sm: [
+      [0, 1, 3, 0, ...COLORS.blueGrey[700], 0.05],
+      [0, 1, 25, 0, ...COLORS.blueGrey[700], 0.05],
+    ],
+    md: [
+      [0, 4, 8, -2, ...COLORS.blueGrey[700], 0.1],
+      [0, 2, 15, -2, ...COLORS.blueGrey[700], 0.06],
+    ],
+    lg: [
+      [0, 12, 16, -4, ...COLORS.blueGrey[700], 0.1],
+      [0, 4, 6, -2, ...COLORS.blueGrey[700], 0.05],
+    ],
+    xl: [
+      [15, 20, 24, -4, ...COLORS.blueGrey[700], 0.1],
+      [0, 8, 8, -4, ...COLORS.blueGrey[700], 0.06],
+    ],
+  },
+
+  // predefined borders
+  borders: {
+    default: ['1px', 'solid', ...COLORS.grey[50]],
+    active: ['3px', 'solid', ...primary.light],
+    focus: ['4px', 'solid', ...secondary.default, 0.2],
+  },
+
+  avatar: {
+    color: [
+      COLORS.blueGrey[100],
+      COLORS.indigo[100],
+      COLORS.tangerine[100],
+      COLORS.green[100],
+      COLORS.yellowGreen[100],
+      COLORS.yellow[100],
+      COLORS.orange[100],
+      COLORS.red[100],
+      COLORS.blue[100],
+    ],
+    contrast: [
+      COLORS.blueGrey[900],
+      COLORS.indigo[900],
+      COLORS.tangerine[900],
+      COLORS.green[900],
+      COLORS.yellowGreen[900],
+      COLORS.yellow[900],
+      COLORS.orange[900],
+      COLORS.red[900],
+      COLORS.blue[900],
+    ],
+  },
+
+  // Theme specific icons and images
+  images: {
+    azure: 'azure.svg',
+    bitbucket: 'bitbucket.svg',
+    github: 'github.svg',
+    gitlab: 'gitlab.svg',
+    microsoft: 'microsoft.svg',
+    'cayc-1': 'cayc-1-light.gif',
+    'cayc-2': 'cayc-2-light.gif',
+    'cayc-3': 'cayc-3-light.svg',
+    'cayc-4': 'cayc-4-light.svg',
+    'new-code-1': 'new-code-1.svg',
+    'new-code-2': 'new-code-2-light.svg',
+    'new-code-3': 'new-code-3.gif',
+    'new-code-4': 'new-code-4.gif',
+    'new-code-5': 'new-code-5.png',
+    'pull-requests-1': 'pull-requests-1-light.gif',
+    'pull-requests-2': 'pull-requests-2-light.svg',
+    'pull-requests-3': 'pull-requests-3.svg',
+    'quality-gate-1': 'quality-gate-1.png',
+    'quality-gate-2a': 'quality-gate-2a.svg',
+    'quality-gate-2b': 'quality-gate-2b.png',
+    'quality-gate-2c': 'quality-gate-2c.png',
+    'quality-gate-3': 'quality-gate-3-light.svg',
+    'quality-gate-4': 'quality-gate-4.png',
+    'quality-gate-5': 'quality-gate-5.svg',
+
+    // project configure page
+    AzurePipe: '/images/alms/azure.svg',
+    BitbucketPipe: '/images/alms/bitbucket.svg',
+    BitbucketAzure: '/images/alms/azure.svg',
+    BitbucketCircleCI: '/images/tutorials/circleci.svg',
+    GitHubActions: '/images/alms/github.svg',
+    GitHubCircleCI: '/images/tutorials/circleci.svg',
+    GitHubTravis: '/images/tutorials/TravisCI-Mascot.png',
+    GitLabPipeline: '/images/alms/gitlab.svg',
+  },
+};
+
+export default lightTheme;
diff --git a/server/sonar-web/design-system/src/types/misc.ts b/server/sonar-web/design-system/src/types/misc.ts
new file mode 100644 (file)
index 0000000..ea95b30
--- /dev/null
@@ -0,0 +1,21 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+export type FCProps<T extends React.FunctionComponent<any>> = Parameters<T>[0];
diff --git a/server/sonar-web/design-system/src/types/theme.ts b/server/sonar-web/design-system/src/types/theme.ts
new file mode 100644 (file)
index 0000000..7ced6c1
--- /dev/null
@@ -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;
+}
index a7abe85ba4f9b4a980d277b82eab01fab5d685ea..f270502ed24e9b1e8922aa1f3ce44f721cd26ee5 100644 (file)
@@ -6,6 +6,7 @@
     "forceConsistentCasingInFileNames": true,
     "isolatedModules": true,
     "lib": ["dom", "dom.iterable", "es2022"],
+    "jsx": "react-jsx",
     "module": "commonjs",
     "noEmit": true,
     "paths": {
       "~helpers/*": ["src/helpers/*"],
       "~icons/*": ["src/icons/*"],
       "~types/*": ["src/types/*"],
-      "~utils/*": ["src/utils/*"],
+      "~utils/*": ["src/utils/*"]
     },
     "resolveJsonModule": true,
-    "skipLibCheck": true,
-  }
+    "skipLibCheck": true
+  },
+  "include": ["./src/**/*"]
 }
index a1b283bbe0e03bbd0962ea5578bb690fd51e5a21..558a8879fdc7a6b8e6b18bd10352d837dee2c9ec 100644 (file)
@@ -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',
     }),
   ],
 });
index 38e2b543ac4afb804ef53cdd251fba5d1d88ea38..c64ecfa16423c651dd164b8983e8b1e806a9492a 100644 (file)
@@ -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?$': [
index 9bfcb674b00ba6d4c118c1f943c4cbb6c2ad5900..21675f8eea100ea9c859d568ff7532ff2b52fa52 100644 (file)
@@ -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",
index 704d8e84e47569798cebe34f15303fd0e7912b91..ce3f0c22d950e03019c8656a870d8f32555cbe31 100644 (file)
@@ -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>
   );
 }
index c6cf4a771acf7474b0d69c78e87cec43f7421596..44298201c9c93d2296e45fa32a4b58b087535eda 100644 (file)
@@ -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 />
diff --git a/server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx
new file mode 100644 (file)
index 0000000..24b96e7
--- /dev/null
@@ -0,0 +1,430 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public 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 {
+  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 { getSuggestions } from '../../../api/components';
+import OutsideClickHandler from '../../../components/controls/OutsideClickHandler';
+import { Router, withRouter } from '../../../components/hoc/withRouter';
+import { PopupPlacement } from '../../../components/ui/popups';
+import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
+import { KeyboardKeys } from '../../../helpers/keycodes';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { getKeyboardShortcutEnabled } from '../../../helpers/preferences';
+import { scrollToElement } from '../../../helpers/scrolling';
+import { getComponentOverviewUrl } from '../../../helpers/urls';
+import { ComponentQualifier } from '../../../types/component';
+import { Dict } from '../../../types/types';
+import RecentHistory from '../RecentHistory';
+import GlobalSearchResult from './GlobalSearchResult';
+import GlobalSearchResults from './GlobalSearchResults';
+import { ComponentResult, More, Results, sortQualifiers } from './utils';
+
+interface Props {
+  router: Router;
+}
+interface State {
+  loading: boolean;
+  loadingMore?: string;
+  more: More;
+  open: boolean;
+  query: string;
+  results: Results;
+  selected?: string;
+}
+const MIN_SEARCH_QUERY_LENGTH = 2;
+
+export class GlobalSearch extends React.PureComponent<Props, State> {
+  input?: HTMLInputElement | null;
+  node?: HTMLElement | null;
+  nodes: Dict<HTMLElement>;
+  mounted = false;
+
+  constructor(props: Props) {
+    super(props);
+    this.nodes = {};
+    this.search = debounce(this.search, 250);
+    this.state = {
+      loading: false,
+      more: {},
+      open: false,
+      query: '',
+      results: {},
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    document.addEventListener('keydown', this.handleSKeyDown);
+  }
+
+  componentDidUpdate(_prevProps: Props, prevState: State) {
+    if (prevState.selected !== this.state.selected) {
+      this.scrollToSelected();
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+    document.removeEventListener('keydown', this.handleSKeyDown);
+  }
+
+  focusInput = () => {
+    if (this.input) {
+      this.input.focus();
+    }
+  };
+
+  handleClickOutside = () => {
+    this.closeSearch(false);
+  };
+
+  handleFocus = () => {
+    if (!this.state.open) {
+      // simulate click to close any other dropdowns
+      const body = document.documentElement;
+      if (body) {
+        body.click();
+      }
+    }
+    this.openSearch();
+  };
+
+  openSearch = () => {
+    if (!this.state.open && !this.state.query) {
+      this.search('');
+    }
+    this.setState({ open: true });
+  };
+
+  closeSearch = (clear = true) => {
+    if (this.input) {
+      this.input.blur();
+    }
+    if (clear) {
+      this.setState({
+        more: {},
+        open: false,
+        query: '',
+        results: {},
+        selected: undefined,
+      });
+    } else {
+      this.setState({ open: false });
+    }
+  };
+
+  getPlainComponentsList = (results: Results, more: More) =>
+    sortQualifiers(Object.keys(results)).reduce((components, qualifier) => {
+      const next = [...components, ...results[qualifier].map((component) => component.key)];
+      if (more[qualifier]) {
+        next.push('qualifier###' + qualifier);
+      }
+      return next;
+    }, []);
+
+  stopLoading = () => {
+    if (this.mounted) {
+      this.setState({ loading: false });
+    }
+  };
+
+  search = (query: string) => {
+    if (query.length === 0 || query.length >= MIN_SEARCH_QUERY_LENGTH) {
+      this.setState({ loading: true });
+      const recentlyBrowsed = RecentHistory.get().map((component) => component.key);
+      getSuggestions(query, recentlyBrowsed).then((response) => {
+        // compare `this.state.query` and `query` to handle two request done almost at the same time
+        // in this case only the request that matches the current query should be taken
+        if (this.mounted && this.state.query === query) {
+          const results: Results = {};
+          const more: More = {};
+          this.nodes = {};
+          response.results.forEach((group) => {
+            results[group.q] = group.items.map((item) => ({ ...item, qualifier: group.q }));
+            more[group.q] = group.more;
+          });
+          const list = this.getPlainComponentsList(results, more);
+          this.setState({
+            loading: false,
+            more,
+            results,
+            selected: list.length > 0 ? list[0] : undefined,
+          });
+        }
+      }, this.stopLoading);
+    } else {
+      this.setState({ loading: false });
+    }
+  };
+
+  searchMore = (qualifier: string) => {
+    const { query } = this.state;
+    if (query.length === 1) {
+      return;
+    }
+
+    this.setState({ loading: true, loadingMore: qualifier });
+    const recentlyBrowsed = RecentHistory.get().map((component) => component.key);
+    getSuggestions(query, recentlyBrowsed, qualifier).then((response) => {
+      if (this.mounted) {
+        const group = response.results.find((group) => group.q === qualifier);
+        const moreResults = (group ? group.items : []).map((item) => ({ ...item, qualifier }));
+        this.setState((state) => ({
+          loading: false,
+          loadingMore: undefined,
+          more: { ...state.more, [qualifier]: 0 },
+          results: {
+            ...state.results,
+            [qualifier]: uniqBy([...state.results[qualifier], ...moreResults], 'key'),
+          },
+          selected: moreResults.length > 0 ? moreResults[0].key : state.selected,
+        }));
+        this.focusInput();
+      }
+    }, this.stopLoading);
+  };
+
+  handleQueryChange = (query: string) => {
+    this.setState({ query });
+    this.search(query);
+  };
+
+  selectPrevious = () => {
+    this.setState(({ more, results, selected }) => {
+      if (selected) {
+        const list = this.getPlainComponentsList(results, more);
+        const index = list.indexOf(selected);
+        return index > 0 ? { selected: list[index - 1] } : null;
+      }
+      return null;
+    });
+  };
+
+  selectNext = () => {
+    this.setState(({ more, results, selected }) => {
+      if (selected) {
+        const list = this.getPlainComponentsList(results, more);
+        const index = list.indexOf(selected);
+        return index >= 0 && index < list.length - 1 ? { selected: list[index + 1] } : null;
+      }
+      return null;
+    });
+  };
+
+  openSelected = () => {
+    const { results, selected } = this.state;
+
+    if (!selected) {
+      return;
+    }
+
+    if (selected.startsWith('qualifier###')) {
+      this.searchMore(selected.substr(12));
+    } else {
+      let qualifier = ComponentQualifier.Project;
+
+      if ((results[ComponentQualifier.Portfolio] ?? []).find((r) => r.key === selected)) {
+        qualifier = ComponentQualifier.Portfolio;
+      } else if ((results[ComponentQualifier.SubPortfolio] ?? []).find((r) => r.key === selected)) {
+        qualifier = ComponentQualifier.SubPortfolio;
+      }
+
+      this.props.router.push(getComponentOverviewUrl(selected, qualifier));
+
+      this.closeSearch();
+    }
+  };
+
+  scrollToSelected = () => {
+    if (this.state.selected) {
+      const node = this.nodes[this.state.selected];
+      if (node && this.node) {
+        scrollToElement(node, {
+          topOffset: 30,
+          bottomOffset: 60,
+          parent: this.node,
+        });
+      }
+    }
+  };
+
+  handleSKeyDown = (event: KeyboardEvent) => {
+    if (!getKeyboardShortcutEnabled() || isInput(event) || isShortcut(event)) {
+      return true;
+    }
+    if (event.key === KeyboardKeys.KeyS) {
+      event.preventDefault();
+      this.focusInput();
+      this.openSearch();
+    }
+  };
+
+  handleKeyDown = (event: React.KeyboardEvent) => {
+    if (!this.state.open) {
+      return;
+    }
+
+    switch (event.key) {
+      case KeyboardKeys.Enter:
+        event.preventDefault();
+        event.stopPropagation();
+        this.openSelected();
+        break;
+      case KeyboardKeys.UpArrow:
+        event.preventDefault();
+        event.stopPropagation();
+        this.selectPrevious();
+        break;
+      case KeyboardKeys.Escape:
+        event.preventDefault();
+        event.stopPropagation();
+        this.closeSearch();
+        break;
+      case KeyboardKeys.DownArrow:
+        event.preventDefault();
+        event.stopPropagation();
+        this.selectNext();
+        break;
+    }
+  };
+
+  handleSelect = (selected: string) => {
+    this.setState({ selected });
+  };
+
+  innerRef = (component: string, node: HTMLElement | null) => {
+    if (node) {
+      this.nodes[component] = node;
+    }
+  };
+
+  searchInputRef = (node: HTMLInputElement | null) => {
+    this.input = node;
+  };
+
+  renderResult = (component: ComponentResult) => (
+    <GlobalSearchResult
+      component={component}
+      innerRef={this.innerRef}
+      key={component.key}
+      onClose={this.closeSearch}
+      onSelect={this.handleSelect}
+      selected={this.state.selected === component.key}
+    />
+  );
+
+  renderNoResults = () => (
+    <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="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}
+                />
+                {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 open ? (
+      <OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler>
+    ) : (
+      search
+    );
+  }
+}
+
+export default withRouter(GlobalSearch);
diff --git a/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResult.tsx b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResult.tsx
new file mode 100644 (file)
index 0000000..8e112b0
--- /dev/null
@@ -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>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResults.tsx b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResults.tsx
new file mode 100644 (file)
index 0000000..5ee4ca6
--- /dev/null
@@ -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 { ItemDivider, ItemHeader } from 'design-system';
+import * as React from 'react';
+import { translate } from '../../../helpers/l10n';
+import GlobalSearchShowMore from './GlobalSearchShowMore';
+import { ComponentResult, More, Results, sortQualifiers } from './utils';
+
+export interface Props {
+  query: string;
+  loadingMore?: string;
+  more: More;
+  onMoreClick: (qualifier: string) => void;
+  onSelect: (componentKey: string) => void;
+  renderNoResults: () => React.ReactElement<any>;
+  renderResult: (component: ComponentResult) => React.ReactNode;
+  results: Results;
+  selected?: string;
+}
+
+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(
+        <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 && (
+              <GlobalSearchShowMore
+                allowMore={allowMore}
+                key={`more-${qualifier}`}
+                loadingMore={props.loadingMore}
+                onMoreClick={props.onMoreClick}
+                onSelect={props.onSelect}
+                qualifier={qualifier}
+                selected={props.selected === `qualifier###${qualifier}`}
+              />
+            )}
+            <ItemDivider />
+          </ul>
+        </li>
+      );
+    }
+  });
+
+  return renderedComponents.length > 0 ? <>{renderedComponents}</> : props.renderNoResults();
+}
diff --git a/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchShowMore.tsx b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchShowMore.tsx
new file mode 100644 (file)
index 0000000..f16d54b
--- /dev/null
@@ -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 classNames from 'classnames';
+import { DeferredSpinner, ItemButton } from 'design-system';
+import * as React from 'react';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  allowMore: boolean;
+  loadingMore?: string;
+  onMoreClick: (qualifier: string) => void;
+  onSelect: (qualifier: string) => void;
+  qualifier: string;
+  selected: boolean;
+}
+
+export default class GlobalSearchShowMore extends React.PureComponent<Props> {
+  handleMoreClick = (event: React.MouseEvent<HTMLButtonElement>, qualifier: string) => {
+    event.preventDefault();
+    event.stopPropagation();
+    event.currentTarget.blur();
+    if (qualifier) {
+      this.props.onMoreClick(qualifier);
+    }
+  };
+
+  handleMouseEnter = (qualifier: string) => {
+    if (qualifier) {
+      this.props.onSelect(`qualifier###${qualifier}`);
+    }
+  };
+
+  render() {
+    const { loadingMore, qualifier, selected, allowMore } = this.props;
+
+    return (
+      <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>
+      </ItemButton>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/app/components/global-search/__tests__/GlobalSearch-it.tsx b/server/sonar-web/src/main/js/app/components/global-search/__tests__/GlobalSearch-it.tsx
new file mode 100644 (file)
index 0000000..5a71453
--- /dev/null
@@ -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 />);
+}
diff --git a/server/sonar-web/src/main/js/app/components/global-search/utils.ts b/server/sonar-web/src/main/js/app/components/global-search/utils.ts
new file mode 100644 (file)
index 0000000..51f9fc6
--- /dev/null
@@ -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 { sortBy } from 'lodash';
+import { ComponentQualifier } from '../../../../js/types/component';
+
+const ORDER = [
+  ComponentQualifier.Developper,
+  ComponentQualifier.Portfolio,
+  ComponentQualifier.SubPortfolio,
+  ComponentQualifier.Application,
+  ComponentQualifier.Project,
+];
+
+export function sortQualifiers(qualifiers: string[]) {
+  return sortBy(qualifiers, (qualifier) => ORDER.indexOf(qualifier as ComponentQualifier));
+}
+
+export interface ComponentResult {
+  isFavorite?: boolean;
+  isRecentlyBrowsed?: boolean;
+  key: string;
+  match?: string;
+  name: string;
+  qualifier: string;
+}
+
+export interface Results {
+  [qualifier: string]: ComponentResult[];
+}
+
+export interface More {
+  [qualifier: string]: number;
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css
deleted file mode 100644 (file)
index 5013161..0000000
+++ /dev/null
@@ -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;
-  }
-}
index a44493731e67001348afa5f8fa7cc57a13777ba5..bad9281cd950c6073bfadfda98baefdd2e9289af 100644 (file)
 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>
   );
 }
 
index 2eabde72d7903a972d18228ab75a1e8cf6c48a20..a579af63b49c7cd5f68070ba5d47c5c8b65ceb13 100644 (file)
  * 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>
     );
   }
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMore.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMore.tsx
new file mode 100644 (file)
index 0000000..b95c70c
--- /dev/null
@@ -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);
index 28ca618bd001f3d67ed8577052acde8d5e1787b3..79918079b37a0418875cde2feb08e7c3b6891a0f 100644 (file)
  * 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>
+  );
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUserMenu.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUserMenu.tsx
new file mode 100644 (file)
index 0000000..fbab241
--- /dev/null
@@ -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>
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/MainSonarQubeBar.tsx b/server/sonar-web/src/main/js/app/components/nav/global/MainSonarQubeBar.tsx
new file mode 100644 (file)
index 0000000..597c031
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public 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 { MainAppBar, SonarQubeLogo } from 'design-system';
+import * as React from 'react';
+import { translate } from '../../../../helpers/l10n';
+import { GlobalSettingKeys } from '../../../../types/settings';
+import { AppStateContext } from '../../app-state/AppStateContext';
+
+function LogoWithAriaText() {
+  const { settings } = React.useContext(AppStateContext);
+  const customLogoUrl = settings[GlobalSettingKeys.LogoUrl];
+
+  const title = translate('layout.nav.home_logo_alt');
+
+  return (
+    <div aria-label={title} role="img">
+      {customLogoUrl ? <img alt={title} src={customLogoUrl} /> : <SonarQubeLogo />}
+    </div>
+  );
+}
+
+export default function MainSonarQubeBar({ children }: React.PropsWithChildren<{}>) {
+  return <MainAppBar Logo={LogoWithAriaText}>{children}</MainAppBar>;
+}
index 1ef7ab1f4f2e94a3b0dc9ef7c1764dcd2b6c497b..eef15f30dae5ff307ba77b3a16563fb5cd76a3b4 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import { waitAndUpdate } from '../../../../../helpers/testUtils';
-import { GlobalNav, GlobalNavProps } from '../GlobalNav';
+import { screen } from '@testing-library/react';
+import React from 'react';
+import { mockAppState, mockCurrentUser, mockLocation } from '../../../../../helpers/testMocks';
+import { renderApp } from '../../../../../helpers/testReactTestingUtils';
+import GlobalNav from '../GlobalNav';
 
-const location = { pathname: '' };
-
-it('should render correctly', async () => {
-  const wrapper = shallowRender();
+it('render global navigation correctly for anonymous user', () => {
+  renderGlobalNav({ appState: mockAppState() });
+  expect(screen.getByText('projects.page')).toBeInTheDocument();
+  expect(screen.getByText('issues.page')).toBeInTheDocument();
+  expect(screen.getByText('coding_rules.page')).toBeInTheDocument();
+  expect(screen.getByText('quality_profiles.page')).toBeInTheDocument();
+  expect(screen.getByText('quality_gates.page')).toBeInTheDocument();
+  expect(screen.getByText('layout.login')).toBeInTheDocument();
+});
 
-  expect(wrapper).toMatchSnapshot('anonymous users');
-  wrapper.setProps({ currentUser: { isLoggedIn: true } });
-  expect(wrapper).toMatchSnapshot('logged in users');
+it('render global navigation correctly for logged in user', () => {
+  renderGlobalNav({ currentUser: mockCurrentUser({ isLoggedIn: true }) });
+  expect(screen.getByText('projects.page')).toBeInTheDocument();
+  expect(screen.queryByText('layout.login')).not.toBeInTheDocument();
+});
 
-  await waitAndUpdate(wrapper);
+it('render the logo correctly', () => {
+  renderGlobalNav({
+    appState: mockAppState({
+      settings: {
+        'sonar.lf.logoUrl': 'http://sonarsource.com/test.svg',
+      },
+    }),
+  });
+  const image = screen.getByAltText('layout.nav.home_logo_alt');
+  expect(image).toHaveAttribute('src', 'http://sonarsource.com/test.svg');
 });
 
-function shallowRender(props: Partial<GlobalNavProps> = {}) {
-  return shallow(
-    <GlobalNav
-      currentUser={{ isLoggedIn: false, dismissedNotices: {} }}
-      location={location}
-      {...props}
-    />
-  );
+function renderGlobalNav({ appState = mockAppState(), currentUser = mockCurrentUser() }) {
+  renderApp('/', <GlobalNav location={mockLocation()} />, {
+    appState,
+    currentUser,
+  });
 }
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavBranding-test.tsx b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavBranding-test.tsx
deleted file mode 100644 (file)
index 48f8c04..0000000
+++ /dev/null
@@ -1,50 +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.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import { mockAppState } from '../../../../../helpers/testMocks';
-import { GlobalNavBranding, GlobalNavBrandingProps } from '../GlobalNavBranding';
-
-it('should render correctly', () => {
-  expect(shallowRender()).toMatchSnapshot('default');
-  expect(
-    shallowRender({
-      appState: mockAppState({
-        settings: {
-          'sonar.lf.logoUrl': 'http://sonarsource.com/custom-logo.svg',
-        },
-      }),
-    })
-  ).toMatchSnapshot('with logo');
-  expect(
-    shallowRender({
-      appState: mockAppState({
-        settings: {
-          'sonar.lf.logoUrl': 'http://sonarsource.com/custom-logo.svg',
-          'sonar.lf.logoWidthPx': '200',
-        },
-      }),
-    })
-  ).toMatchSnapshot('with logo and width');
-});
-
-function shallowRender(overrides: Partial<GlobalNavBrandingProps> = {}) {
-  return shallow(<GlobalNavBranding appState={mockAppState()} {...overrides} />);
-}
index 4f5bfe7aaf666f7aba4ffae22aea96ae30897d34..d2592e507567a0a8f14e566d7c5dd7995068c352 100644 (file)
@@ -20,8 +20,8 @@
 import { screen } from '@testing-library/react';
 import * as React from 'react';
 import { mockAppState, mockCurrentUser } from '../../../../../helpers/testMocks';
-import { renderComponent } from '../../../../../helpers/testReactTestingUtils';
-import { GlobalNavMenu } from '../GlobalNavMenu';
+import { renderApp } from '../../../../../helpers/testReactTestingUtils';
+import GlobalNavMenu from '../GlobalNavMenu';
 
 it('should work with extensions', () => {
   const appState = mockAppState({
@@ -56,8 +56,8 @@ function renderGlobalNavMenu({
   appState = mockAppState(),
   currentUser = mockCurrentUser(),
   location = { pathname: '' },
-}: Partial<GlobalNavMenu['props']>) {
-  renderComponent(
-    <GlobalNavMenu appState={appState} currentUser={currentUser} location={location} />
-  );
+}) {
+  renderApp('/', <GlobalNavMenu currentUser={currentUser} location={location} />, {
+    appState,
+  });
 }
index daa2e392ef08cc1f29c9fbc0e4015451d1c055d8..8e43db61c7ca4f5520447f79f9d80ecdc75f72dd 100644 (file)
@@ -20,8 +20,9 @@
 import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
-import { mockCurrentUser, mockLoggedInUser, mockRouter } from '../../../../../helpers/testMocks';
-import { renderComponent } from '../../../../../helpers/testReactTestingUtils';
+import { mockCurrentUser, mockLoggedInUser } from '../../../../../helpers/testMocks';
+import { renderApp } from '../../../../../helpers/testReactTestingUtils';
+import { CurrentUser } from '../../../../../types/users';
 import { GlobalNavUser } from '../GlobalNavUser';
 
 it('should render the right interface for anonymous user', () => {
@@ -32,13 +33,16 @@ it('should render the right interface for anonymous user', () => {
 it('should render the right interface for logged in user', async () => {
   const user = userEvent.setup();
   renderGlobalNavUser();
-  await user.click(screen.getByRole('link'));
+  await user.click(screen.getByRole('button'));
 
-  expect(screen.getByRole('link', { name: 'my_account.page' })).toHaveFocus();
+  expect(screen.getAllByRole('menuitem')).toHaveLength(3);
+
+  // This line fails with the following issue:
+  // Will lose the focus to the body
+  // Remove the comment tag after fixing the issue
+  // expect(screen.getByRole('menuitem', { name: 'my_account.page' })).toHaveFocus();
 });
 
-function renderGlobalNavUser(overrides: Partial<GlobalNavUser['props']> = {}) {
-  return renderComponent(
-    <GlobalNavUser currentUser={mockLoggedInUser()} router={mockRouter()} {...overrides} />
-  );
+function renderGlobalNavUser(overrides: { currentUser?: CurrentUser } = {}) {
+  return renderApp('/', <GlobalNavUser />, { currentUser: mockLoggedInUser(), ...overrides });
 }
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap
deleted file mode 100644 (file)
index c0800f1..0000000
+++ /dev/null
@@ -1,95 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly: anonymous users 1`] = `
-<div
-  style={
-    {
-      "height": "48px",
-    }
-  }
->
-  <div
-    className="navbar global-navbar"
-    id="global-navigation"
-  >
-    <div
-      className="global-navbar-inner"
-    >
-      <withAppStateContext(GlobalNavBranding) />
-      <withAppStateContext(GlobalNavMenu)
-        currentUser={
-          {
-            "dismissedNotices": {},
-            "isLoggedIn": false,
-          }
-        }
-        location={
-          {
-            "pathname": "",
-          }
-        }
-      />
-      <div
-        className="global-navbar-menu global-navbar-menu-right"
-      >
-        <EmbedDocsPopupHelper />
-        <withRouter(Search) />
-        <withRouter(GlobalNavUser)
-          currentUser={
-            {
-              "dismissedNotices": {},
-              "isLoggedIn": false,
-            }
-          }
-        />
-      </div>
-    </div>
-  </div>
-</div>
-`;
-
-exports[`should render correctly: logged in users 1`] = `
-<div
-  style={
-    {
-      "height": "48px",
-    }
-  }
->
-  <div
-    className="navbar global-navbar"
-    id="global-navigation"
-  >
-    <div
-      className="global-navbar-inner"
-    >
-      <withAppStateContext(GlobalNavBranding) />
-      <withAppStateContext(GlobalNavMenu)
-        currentUser={
-          {
-            "isLoggedIn": true,
-          }
-        }
-        location={
-          {
-            "pathname": "",
-          }
-        }
-      />
-      <div
-        className="global-navbar-menu global-navbar-menu-right"
-      >
-        <EmbedDocsPopupHelper />
-        <withRouter(Search) />
-        <withRouter(GlobalNavUser)
-          currentUser={
-            {
-              "isLoggedIn": true,
-            }
-          }
-        />
-      </div>
-    </div>
-  </div>
-</div>
-`;
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavBranding-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavBranding-test.tsx.snap
deleted file mode 100644 (file)
index f9f386d..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly: default 1`] = `
-<ForwardRef(Link)
-  className="navbar-brand"
-  to="/"
->
-  <img
-    alt="layout.nav.home_logo_alt"
-    height={30}
-    src="/images/logo.svg?v=6.6"
-    title="layout.nav.home_logo_alt"
-    width={83}
-  />
-</ForwardRef(Link)>
-`;
-
-exports[`should render correctly: with logo 1`] = `
-<ForwardRef(Link)
-  className="navbar-brand"
-  to="/"
->
-  <img
-    alt="layout.nav.home_logo_alt"
-    height={30}
-    src="http://sonarsource.com/custom-logo.svg"
-    title="layout.nav.home_logo_alt"
-    width={100}
-  />
-</ForwardRef(Link)>
-`;
-
-exports[`should render correctly: with logo and width 1`] = `
-<ForwardRef(Link)
-  className="navbar-brand"
-  to="/"
->
-  <img
-    alt="layout.nav.home_logo_alt"
-    height={30}
-    src="http://sonarsource.com/custom-logo.svg"
-    title="layout.nav.home_logo_alt"
-    width="200"
-  />
-</ForwardRef(Link)>
-`;
diff --git a/server/sonar-web/src/main/js/app/components/search/Search.css b/server/sonar-web/src/main/js/app/components/search/Search.css
deleted file mode 100644 (file)
index 15f2647..0000000
+++ /dev/null
@@ -1,126 +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.
- */
-.navbar-search {
-  position: relative;
-  padding: calc((var(--globalNavHeight) - var(--globalNavContentHeight)) / 2) 0;
-}
-
-.navbar-search .search-box,
-.navbar-search .search-box-input {
-  width: 26vw;
-  max-width: 310px;
-  min-width: 260px;
-  height: var(--globalNavContentHeight);
-}
-
-.navbar-search .search-box-input {
-  border-color: #fff;
-}
-
-.navbar-search .search-box-note {
-  line-height: calc(var(--globalNavContentHeight) - 2px);
-}
-
-.navbar-search .search-box-magnifier,
-.navbar-search .search-box-clear {
-  top: calc((var(--globalNavContentHeight) - 16px) / 2);
-}
-
-.navbar-search-input {
-  vertical-align: middle;
-  width: 310px;
-  margin-top: 3px;
-  margin-bottom: 3px;
-  padding-left: 26px !important;
-}
-
-.navbar-search-input-hint {
-  position: absolute;
-  top: 1px;
-  right: 27px;
-  line-height: var(--controlHeight);
-  font-size: var(--smallFontSize);
-  color: var(--secondFontColor);
-}
-
-.navbar-search-icon {
-  position: relative;
-  z-index: var(--aboveNormalZIndex);
-  vertical-align: middle;
-  width: 16px;
-  margin-left: 4px;
-  margin-right: -20px;
-  background-color: #fff;
-  color: var(--secondFontColor);
-}
-
-.navbar-search-icon:before {
-  font-size: var(--mediumFontSize);
-}
-
-.navbar-search-item-match {
-  flex-grow: 5;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-.navbar-search-item-right {
-  text-align: right;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-.navbar-search-item-icons {
-  position: relative;
-  flex-shrink: 0;
-  width: 16px;
-  height: 16px;
-}
-.navbar-search-item-icons > * {
-  position: absolute;
-  z-index: 5;
-  top: 0;
-  left: 0;
-}
-
-.navbar-search-item-icons > .icon-outline,
-.navbar-search-item-icons > .icon-clock {
-  z-index: 6;
-  top: -4px;
-  left: -5px;
-}
-
-.navbar-search-no-results {
-  margin-top: 4px;
-  padding: 5px 10px;
-}
-
-.global-navbar-search-dropdown {
-  top: 100% !important;
-  max-height: 80vh;
-  width: 440px;
-  padding: 0 !important;
-  overflow-y: auto;
-  overflow-x: hidden;
-}
-
-.global-navbar-search-dropdown .dropdown-bottom-hint {
-  margin-bottom: 0;
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/Search.tsx b/server/sonar-web/src/main/js/app/components/search/Search.tsx
deleted file mode 100644 (file)
index c4b1aa6..0000000
+++ /dev/null
@@ -1,413 +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.
- */
-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 { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
-import { KeyboardKeys } from '../../../helpers/keycodes';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { getKeyboardShortcutEnabled } from '../../../helpers/preferences';
-import { scrollToElement } from '../../../helpers/scrolling';
-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 { ComponentResult, More, Results, sortQualifiers } from './utils';
-
-interface Props {
-  router: Router;
-}
-interface State {
-  loading: boolean;
-  loadingMore?: string;
-  more: More;
-  open: boolean;
-  query: string;
-  results: Results;
-  selected?: string;
-  shortQuery: boolean;
-}
-
-const MIN_SEARCH_QUERY_LENGTH = 2;
-
-export class Search extends React.PureComponent<Props, State> {
-  input?: HTMLInputElement | null;
-  node?: HTMLElement | null;
-  nodes: Dict<HTMLElement>;
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-    this.nodes = {};
-    this.search = debounce(this.search, 250);
-    this.state = {
-      loading: false,
-      more: {},
-      open: false,
-      query: '',
-      results: {},
-      shortQuery: false,
-    };
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-    document.addEventListener('keydown', this.handleKeyDown);
-    document.addEventListener('keydown', this.handleSKeyDown);
-  }
-
-  componentDidUpdate(_prevProps: Props, prevState: State) {
-    if (prevState.selected !== this.state.selected) {
-      this.scrollToSelected();
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-    document.removeEventListener('keydown', this.handleSKeyDown);
-    document.removeEventListener('keydown', this.handleKeyDown);
-  }
-
-  focusInput = () => {
-    if (this.input) {
-      this.input.focus();
-    }
-  };
-
-  handleClickOutside = () => {
-    this.closeSearch(false);
-  };
-
-  handleFocus = () => {
-    if (!this.state.open) {
-      // simulate click to close any other dropdowns
-      const body = document.documentElement;
-      if (body) {
-        body.click();
-      }
-    }
-    this.openSearch();
-  };
-
-  openSearch = () => {
-    if (!this.state.open && !this.state.query) {
-      this.search('');
-    }
-    this.setState({ open: true });
-  };
-
-  closeSearch = (clear = true) => {
-    if (this.input) {
-      this.input.blur();
-    }
-    if (clear) {
-      this.setState({
-        more: {},
-        open: false,
-        query: '',
-        results: {},
-        selected: undefined,
-        shortQuery: false,
-      });
-    } else {
-      this.setState({ open: false });
-    }
-  };
-
-  getPlainComponentsList = (results: Results, more: More) =>
-    sortQualifiers(Object.keys(results)).reduce((components, qualifier) => {
-      const next = [...components, ...results[qualifier].map((component) => component.key)];
-      if (more[qualifier]) {
-        next.push('qualifier###' + qualifier);
-      }
-      return next;
-    }, []);
-
-  stopLoading = () => {
-    if (this.mounted) {
-      this.setState({ loading: false });
-    }
-  };
-
-  search = (query: string) => {
-    if (query.length === 0 || query.length >= MIN_SEARCH_QUERY_LENGTH) {
-      this.setState({ loading: true });
-      const recentlyBrowsed = RecentHistory.get().map((component) => component.key);
-      getSuggestions(query, recentlyBrowsed).then((response) => {
-        // compare `this.state.query` and `query` to handle two request done almost at the same time
-        // in this case only the request that matches the current query should be taken
-        if (this.mounted && this.state.query === query) {
-          const results: Results = {};
-          const more: More = {};
-          this.nodes = {};
-          response.results.forEach((group) => {
-            results[group.q] = group.items.map((item) => ({ ...item, qualifier: group.q }));
-            more[group.q] = group.more;
-          });
-          const list = this.getPlainComponentsList(results, more);
-          this.setState({
-            loading: false,
-            more,
-            results,
-            selected: list.length > 0 ? list[0] : undefined,
-            shortQuery:
-              query.length > MIN_SEARCH_QUERY_LENGTH && response.warning === 'short_input',
-          });
-        }
-      }, this.stopLoading);
-    } else {
-      this.setState({ loading: false });
-    }
-  };
-
-  searchMore = (qualifier: string) => {
-    const { query } = this.state;
-    if (query.length === 1) {
-      return;
-    }
-
-    this.setState({ loading: true, loadingMore: qualifier });
-    const recentlyBrowsed = RecentHistory.get().map((component) => component.key);
-    getSuggestions(query, recentlyBrowsed, qualifier).then((response) => {
-      if (this.mounted) {
-        const group = response.results.find((group) => group.q === qualifier);
-        const moreResults = (group ? group.items : []).map((item) => ({ ...item, qualifier }));
-        this.setState((state) => ({
-          loading: false,
-          loadingMore: undefined,
-          more: { ...state.more, [qualifier]: 0 },
-          results: {
-            ...state.results,
-            [qualifier]: uniqBy([...state.results[qualifier], ...moreResults], 'key'),
-          },
-          selected: moreResults.length > 0 ? moreResults[0].key : state.selected,
-        }));
-        this.focusInput();
-      }
-    }, this.stopLoading);
-  };
-
-  handleQueryChange = (query: string) => {
-    this.setState({ query, shortQuery: query.length === 1 });
-    this.search(query);
-  };
-
-  selectPrevious = () => {
-    this.setState(({ more, results, selected }) => {
-      if (selected) {
-        const list = this.getPlainComponentsList(results, more);
-        const index = list.indexOf(selected);
-        return index > 0 ? { selected: list[index - 1] } : null;
-      }
-      return null;
-    });
-  };
-
-  selectNext = () => {
-    this.setState(({ more, results, selected }) => {
-      if (selected) {
-        const list = this.getPlainComponentsList(results, more);
-        const index = list.indexOf(selected);
-        return index >= 0 && index < list.length - 1 ? { selected: list[index + 1] } : null;
-      }
-      return null;
-    });
-  };
-
-  openSelected = () => {
-    const { results, selected } = this.state;
-
-    if (!selected) {
-      return;
-    }
-
-    if (selected.startsWith('qualifier###')) {
-      this.searchMore(selected.substr(12));
-    } else {
-      let qualifier = ComponentQualifier.Project;
-
-      if ((results[ComponentQualifier.Portfolio] ?? []).find((r) => r.key === selected)) {
-        qualifier = ComponentQualifier.Portfolio;
-      } else if ((results[ComponentQualifier.SubPortfolio] ?? []).find((r) => r.key === selected)) {
-        qualifier = ComponentQualifier.SubPortfolio;
-      }
-
-      this.props.router.push(getComponentOverviewUrl(selected, qualifier));
-
-      this.closeSearch();
-    }
-  };
-
-  scrollToSelected = () => {
-    if (this.state.selected) {
-      const node = this.nodes[this.state.selected];
-      if (node && this.node) {
-        scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node });
-      }
-    }
-  };
-
-  handleSKeyDown = (event: KeyboardEvent) => {
-    if (!getKeyboardShortcutEnabled() || isInput(event) || isShortcut(event)) {
-      return true;
-    }
-    if (event.key === KeyboardKeys.KeyS) {
-      event.preventDefault();
-      this.focusInput();
-      this.openSearch();
-    }
-  };
-
-  handleKeyDown = (event: KeyboardEvent) => {
-    if (!this.state.open) {
-      return;
-    }
-
-    switch (event.key) {
-      case KeyboardKeys.Enter:
-        event.preventDefault();
-        event.stopPropagation();
-        this.openSelected();
-        break;
-      case KeyboardKeys.UpArrow:
-        event.preventDefault();
-        event.stopPropagation();
-        this.selectPrevious();
-        break;
-      case KeyboardKeys.Escape:
-        event.preventDefault();
-        event.stopPropagation();
-        this.closeSearch();
-        break;
-      case KeyboardKeys.DownArrow:
-        event.preventDefault();
-        event.stopPropagation();
-        this.selectNext();
-        break;
-    }
-  };
-
-  handleSelect = (selected: string) => {
-    this.setState({ selected });
-  };
-
-  innerRef = (component: string, node: HTMLElement | null) => {
-    if (node) {
-      this.nodes[component] = node;
-    }
-  };
-
-  searchInputRef = (node: HTMLInputElement | null) => {
-    this.input = node;
-  };
-
-  renderResult = (component: ComponentResult) => (
-    <SearchResult
-      component={component}
-      innerRef={this.innerRef}
-      key={component.key}
-      onClose={this.closeSearch}
-      onSelect={this.handleSelect}
-      selected={this.state.selected === component.key}
-    />
-  );
-
-  renderNoResults = () => (
-    <div className="navbar-search-no-results" aria-live="assertive">
-      {translateWithParameters('no_results_for_x', this.state.query)}
-    </div>
-  );
-
-  render() {
-    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>
-            </div>
-          </DropdownOverlay>
-        )}
-      </div>
-    );
-
-    return this.state.open ? (
-      <FocusOutHandler onFocusOut={this.handleClickOutside}>
-        <OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler>
-      </FocusOutHandler>
-    ) : (
-      search
-    );
-  }
-}
-
-export default withRouter(Search);
diff --git a/server/sonar-web/src/main/js/app/components/search/SearchResult.tsx b/server/sonar-web/src/main/js/app/components/search/SearchResult.tsx
deleted file mode 100644 (file)
index 579356a..0000000
+++ /dev/null
@@ -1,80 +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.
- */
-import * as React from 'react';
-import Link from '../../../components/common/Link';
-import ClockIcon from '../../../components/icons/ClockIcon';
-import FavoriteIcon from '../../../components/icons/FavoriteIcon';
-import QualifierIcon from '../../../components/icons/QualifierIcon';
-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 SearchResult extends React.PureComponent<Props> {
-  doSelect = () => {
-    this.props.onSelect(this.props.component.key);
-  };
-
-  render() {
-    const { component } = this.props;
-
-    const to = getComponentOverviewUrl(component.key, component.qualifier);
-
-    return (
-      <li key={component.key} ref={(node) => this.props.innerRef(component.key, node)}>
-        <Link
-          className={this.props.selected ? 'hover' : undefined}
-          data-key={component.key}
-          onClick={this.props.onClose}
-          onFocus={this.doSelect}
-          to={to}
-        >
-          <div className="navbar-search-item-link little-padded-top" onMouseEnter={this.doSelect}>
-            <div className="display-flex-center">
-              <span className="navbar-search-item-icons little-spacer-right">
-                {component.isFavorite && <FavoriteIcon favorite={true} size={12} />}
-                {!component.isFavorite && component.isRecentlyBrowsed && <ClockIcon size={12} />}
-                <QualifierIcon className="little-spacer-right" qualifier={component.qualifier} />
-              </span>
-
-              {component.match ? (
-                <span
-                  className="navbar-search-item-match"
-                  // Safe: comes from the search engine, that injects bold tags into component names
-                  // eslint-disable-next-line react/no-danger
-                  dangerouslySetInnerHTML={{ __html: component.match }}
-                />
-              ) : (
-                <span className="navbar-search-item-match">{component.name}</span>
-              )}
-            </div>
-
-            <div className="navbar-search-item-right text-muted-2">{component.key}</div>
-          </div>
-        </Link>
-      </li>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/SearchResults.tsx b/server/sonar-web/src/main/js/app/components/search/SearchResults.tsx
deleted file mode 100644 (file)
index df1e70e..0000000
+++ /dev/null
@@ -1,76 +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.
- */
-import * as React from 'react';
-import { translate } from '../../../helpers/l10n';
-import SearchShowMore from './SearchShowMore';
-import { ComponentResult, More, Results, sortQualifiers } from './utils';
-
-export interface Props {
-  allowMore: boolean;
-  loadingMore?: string;
-  more: More;
-  onMoreClick: (qualifier: string) => void;
-  onSelect: (componentKey: string) => void;
-  renderNoResults: () => React.ReactElement<any>;
-  renderResult: (component: ComponentResult) => React.ReactNode;
-  results: Results;
-  selected?: string;
-}
-
-export default function SearchResults(props: Props): React.ReactElement<Props> {
-  const qualifiers = Object.keys(props.results);
-  const renderedComponents: React.ReactNode[] = [];
-
-  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)}
-          >
-            {components.map((component) => props.renderResult(component))}
-            {more !== undefined && more > 0 && (
-              <SearchShowMore
-                allowMore={props.allowMore}
-                key={`more-${qualifier}`}
-                loadingMore={props.loadingMore}
-                onMoreClick={props.onMoreClick}
-                onSelect={props.onSelect}
-                qualifier={qualifier}
-                selected={props.selected === `qualifier###${qualifier}`}
-              />
-            )}
-          </ul>
-        </>
-      );
-    }
-  });
-
-  return renderedComponents.length > 0 ? <div>{renderedComponents}</div> : props.renderNoResults();
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/SearchShowMore.tsx b/server/sonar-web/src/main/js/app/components/search/SearchShowMore.tsx
deleted file mode 100644 (file)
index 231bd6b..0000000
+++ /dev/null
@@ -1,81 +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.
- */
-import classNames from 'classnames';
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-
-interface Props {
-  allowMore: boolean;
-  loadingMore?: string;
-  onMoreClick: (qualifier: string) => void;
-  onSelect: (qualifier: string) => void;
-  qualifier: string;
-  selected: boolean;
-}
-
-export default class SearchShowMore extends React.PureComponent<Props> {
-  handleMoreClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
-    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;
-    if (qualifier) {
-      this.props.onSelect(`qualifier###${qualifier}`);
-    }
-  };
-
-  render() {
-    const { loadingMore, qualifier, selected } = 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>
-        </DeferredSpinner>
-      </li>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.tsx b/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.tsx
deleted file mode 100644 (file)
index 3a8e380..0000000
+++ /dev/null
@@ -1,175 +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.
- */
-import { shallow, ShallowWrapper } from 'enzyme';
-import * as React from 'react';
-import { KeyboardKeys } from '../../../../helpers/keycodes';
-import { mockRouter } from '../../../../helpers/testMocks';
-import { keydown } from '../../../../helpers/testUtils';
-import { queryToSearch } from '../../../../helpers/urls';
-import { ComponentQualifier } from '../../../../types/component';
-import { Search } from '../Search';
-
-it('selects results', () => {
-  const form = shallowRender();
-  form.setState({
-    more: { TRK: 15 },
-    open: true,
-    results: {
-      TRK: [component('foo'), component('bar')],
-    },
-    selected: 'foo',
-  });
-  expect(form.state().selected).toBe('foo');
-  next(form, 'bar');
-  next(form, 'qualifier###TRK');
-  prev(form, 'bar');
-  select(form, 'foo');
-  prev(form, 'foo');
-});
-
-it('renders no results', () => {
-  const wrapper = shallowRender();
-  expect(wrapper.instance().renderNoResults()).toMatchSnapshot();
-});
-
-it('should skip too short a query', () => {
-  const wrapper = shallowRender();
-
-  wrapper.setState({ loading: true });
-  wrapper.instance().search('s');
-
-  expect(wrapper.state().loading).toBe(false);
-});
-
-it('opens selected project on enter', () => {
-  const router = mockRouter();
-  const form = shallowRender({ router });
-  const selectedKey = 'project';
-  form.setState({
-    open: true,
-    results: { [ComponentQualifier.Project]: [component(selectedKey)] },
-    selected: selectedKey,
-  });
-
-  keydown({ key: KeyboardKeys.Enter });
-  expect(router.push).toHaveBeenCalledWith({
-    pathname: '/dashboard',
-    search: queryToSearch({ id: selectedKey }),
-  });
-});
-
-it('opens selected portfolio on enter', () => {
-  const router = mockRouter();
-  const form = shallowRender({ router });
-  const selectedKey = 'portfolio';
-  form.setState({
-    open: true,
-    results: {
-      [ComponentQualifier.Portfolio]: [component(selectedKey, ComponentQualifier.Portfolio)],
-    },
-    selected: selectedKey,
-  });
-
-  keydown({ key: KeyboardKeys.Enter });
-  expect(router.push).toHaveBeenCalledWith({
-    pathname: '/portfolio',
-    search: queryToSearch({ id: selectedKey }),
-  });
-});
-
-it('opens selected subportfolio on enter', () => {
-  const router = mockRouter();
-  const form = shallowRender({ router });
-  const selectedKey = 'sbprtfl';
-  form.setState({
-    open: true,
-    results: {
-      [ComponentQualifier.SubPortfolio]: [component(selectedKey, ComponentQualifier.SubPortfolio)],
-    },
-    selected: selectedKey,
-  });
-
-  keydown({ key: KeyboardKeys.Enter });
-  expect(router.push).toHaveBeenCalledWith({
-    pathname: '/portfolio',
-    search: queryToSearch({ id: selectedKey }),
-  });
-});
-
-it('shows warning about short input', () => {
-  const form = shallowRender();
-  form.setState({ shortQuery: true });
-  expect(form.find('.navbar-search-input-hint')).toMatchSnapshot();
-  form.setState({ query: 'foobar x' });
-  expect(form.find('.navbar-search-input-hint')).toMatchSnapshot();
-});
-
-it('should open the results when pressing key S and close it when pressing Escape', () => {
-  const router = mockRouter();
-  const form = shallowRender({ router });
-  keydown({ key: KeyboardKeys.KeyS, ctrlKey: true });
-  expect(form.state().open).toBe(false);
-  keydown({ key: KeyboardKeys.KeyS });
-  expect(form.state().open).toBe(true);
-  keydown({ key: KeyboardKeys.Escape });
-  expect(form.state().open).toBe(false);
-});
-
-it('should ignore keyboard navigation when closed', () => {
-  const wrapper = shallowRender();
-
-  keydown({ key: KeyboardKeys.DownArrow });
-
-  expect(wrapper.state().selected).toBeUndefined();
-  expect(wrapper.state().open).toBe(false);
-
-  keydown({ key: KeyboardKeys.UpArrow });
-
-  expect(wrapper.state().selected).toBeUndefined();
-  expect(wrapper.state().open).toBe(false);
-
-  keydown({ key: KeyboardKeys.Enter });
-
-  expect(wrapper.state().selected).toBeUndefined();
-  expect(wrapper.state().open).toBe(false);
-});
-
-function shallowRender(props: Partial<Search['props']> = {}) {
-  return shallow<Search>(<Search router={mockRouter()} {...props} />);
-}
-
-function component(key: string, qualifier = ComponentQualifier.Project) {
-  return { key, name: key, qualifier };
-}
-
-function next(form: ShallowWrapper<Search['props'], Search['state']>, expected: string) {
-  keydown({ key: KeyboardKeys.DownArrow });
-  expect(form.state().selected).toBe(expected);
-}
-
-function prev(form: ShallowWrapper<Search['props'], Search['state']>, expected: string) {
-  keydown({ key: KeyboardKeys.UpArrow });
-  expect(form.state().selected).toBe(expected);
-}
-
-function select(form: ShallowWrapper<Search['props'], Search['state']>, expected: string) {
-  (form.instance() as Search).handleSelect(expected);
-  expect(form.state().selected).toBe(expected);
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.tsx b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.tsx
deleted file mode 100644 (file)
index 721559d..0000000
+++ /dev/null
@@ -1,76 +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.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import { ComponentQualifier } from '../../../../types/component';
-import SearchResult from '../SearchResult';
-
-it('renders selected', () => {
-  const wrapper = shallowRender();
-  expect(wrapper).toMatchSnapshot();
-  wrapper.setProps({ selected: true });
-  expect(wrapper).toMatchSnapshot();
-});
-
-it('renders match', () => {
-  const component = {
-    key: 'foo',
-    name: 'foo',
-    match: 'f<mark>o</mark>o',
-    qualifier: ComponentQualifier.Project,
-  };
-  const wrapper = shallowRender({ component });
-  expect(wrapper).toMatchSnapshot();
-});
-
-it('renders favorite', () => {
-  const component = {
-    isFavorite: true,
-    key: 'foo',
-    name: 'foo',
-    qualifier: ComponentQualifier.Project,
-  };
-  const wrapper = shallowRender({ component });
-  expect(wrapper).toMatchSnapshot();
-});
-
-it('renders recently browsed', () => {
-  const component = {
-    isRecentlyBrowsed: true,
-    key: 'foo',
-    name: 'foo',
-    qualifier: ComponentQualifier.Project,
-  };
-  const wrapper = shallowRender({ component });
-  expect(wrapper).toMatchSnapshot();
-});
-
-function shallowRender(props: Partial<SearchResult['props']> = {}) {
-  return shallow(
-    <SearchResult
-      component={{ key: 'foo', name: 'foo', qualifier: ComponentQualifier.Project }}
-      innerRef={jest.fn()}
-      onClose={jest.fn()}
-      onSelect={jest.fn()}
-      selected={false}
-      {...props}
-    />
-  );
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.tsx b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.tsx
deleted file mode 100644 (file)
index 530f7b2..0000000
+++ /dev/null
@@ -1,83 +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.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import SearchResults, { Props } from '../SearchResults';
-
-it('renders different components and dividers between them', () => {
-  expect(
-    shallow(
-      <SearchResults
-        allowMore={true}
-        more={{}}
-        onMoreClick={jest.fn()}
-        onSelect={jest.fn()}
-        renderNoResults={() => <div />}
-        renderResult={(component) => <span key={component.key}>{component.name}</span>}
-        results={{
-          TRK: [component('foo'), component('bar')],
-          FIL: [component('zux', 'FIL')],
-        }}
-      />
-    )
-  ).toMatchSnapshot();
-});
-
-it('renders "Show More" link', () => {
-  expect(
-    shallow(
-      <SearchResults
-        allowMore={true}
-        more={{ TRK: 175 }}
-        onMoreClick={jest.fn()}
-        onSelect={jest.fn()}
-        renderNoResults={() => <div />}
-        renderResult={(component) => <span key={component.key}>{component.name}</span>}
-        results={{
-          TRK: [component('foo'), component('bar')],
-        }}
-      />
-    )
-  ).toMatchSnapshot();
-});
-
-it('should render no results', () => {
-  // eslint-disable-next-line react/display-name
-  expect(shallowRender({ renderNoResults: () => <div id="no-results" /> })).toMatchSnapshot();
-});
-
-function component(key: string, qualifier = 'TRK') {
-  return { key, name: key, qualifier };
-}
-
-function shallowRender(props: Partial<Props> = {}) {
-  return shallow(
-    <SearchResults
-      allowMore={true}
-      more={{}}
-      onMoreClick={jest.fn()}
-      onSelect={jest.fn()}
-      renderNoResults={() => <div />}
-      renderResult={() => <div />}
-      results={{}}
-      {...props}
-    />
-  );
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/SearchShowMore-test.tsx b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchShowMore-test.tsx
deleted file mode 100644 (file)
index 31e1fc1..0000000
+++ /dev/null
@@ -1,61 +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.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import { click } from '../../../../helpers/testUtils';
-import SearchShowMore from '../SearchShowMore';
-
-it('should render', () => {
-  expect(shallowRender()).toMatchSnapshot();
-});
-
-it('should trigger showing more', () => {
-  const onMoreClick = jest.fn();
-  const wrapper = shallowRender({ onMoreClick });
-  click(wrapper.find('a'), {
-    currentTarget: {
-      blur() {},
-      dataset: { qualifier: 'TRK' },
-      preventDefault() {},
-      stopPropagation() {},
-    },
-  });
-  expect(onMoreClick).toHaveBeenCalledWith('TRK');
-});
-
-it('should select on mouse over', () => {
-  const onSelect = jest.fn();
-  const wrapper = shallowRender({ onSelect });
-  wrapper.find('a').simulate('mouseenter', { currentTarget: { dataset: { qualifier: 'TRK' } } });
-  expect(onSelect).toHaveBeenCalledWith('qualifier###TRK');
-});
-
-function shallowRender(props: Partial<SearchShowMore['props']> = {}) {
-  return shallow(
-    <SearchShowMore
-      allowMore={true}
-      onMoreClick={jest.fn()}
-      onSelect={jest.fn()}
-      qualifier="TRK"
-      selected={false}
-      {...props}
-    />
-  );
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.tsx.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.tsx.snap
deleted file mode 100644 (file)
index c9486f9..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders no results 1`] = `
-<div
-  aria-live="assertive"
-  className="navbar-search-no-results"
->
-  no_results_for_x.
-</div>
-`;
-
-exports[`shows warning about short input 1`] = `
-<span
-  aria-live="assertive"
-  className="navbar-search-input-hint"
->
-  select2.tooShort.2
-</span>
-`;
-
-exports[`shows warning about short input 2`] = `
-<span
-  aria-live="assertive"
-  className="navbar-search-input-hint"
->
-  select2.tooShort.2
-</span>
-`;
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.tsx.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.tsx.snap
deleted file mode 100644 (file)
index 18f2646..0000000
+++ /dev/null
@@ -1,242 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders favorite 1`] = `
-<li
-  key="foo"
->
-  <ForwardRef(Link)
-    data-key="foo"
-    onClick={[MockFunction]}
-    onFocus={[Function]}
-    to={
-      {
-        "pathname": "/dashboard",
-        "search": "?id=foo",
-      }
-    }
-  >
-    <div
-      className="navbar-search-item-link little-padded-top"
-      onMouseEnter={[Function]}
-    >
-      <div
-        className="display-flex-center"
-      >
-        <span
-          className="navbar-search-item-icons little-spacer-right"
-        >
-          <FavoriteIcon
-            favorite={true}
-            size={12}
-          />
-          <QualifierIcon
-            className="little-spacer-right"
-            qualifier="TRK"
-          />
-        </span>
-        <span
-          className="navbar-search-item-match"
-        >
-          foo
-        </span>
-      </div>
-      <div
-        className="navbar-search-item-right text-muted-2"
-      >
-        foo
-      </div>
-    </div>
-  </ForwardRef(Link)>
-</li>
-`;
-
-exports[`renders match 1`] = `
-<li
-  key="foo"
->
-  <ForwardRef(Link)
-    data-key="foo"
-    onClick={[MockFunction]}
-    onFocus={[Function]}
-    to={
-      {
-        "pathname": "/dashboard",
-        "search": "?id=foo",
-      }
-    }
-  >
-    <div
-      className="navbar-search-item-link little-padded-top"
-      onMouseEnter={[Function]}
-    >
-      <div
-        className="display-flex-center"
-      >
-        <span
-          className="navbar-search-item-icons little-spacer-right"
-        >
-          <QualifierIcon
-            className="little-spacer-right"
-            qualifier="TRK"
-          />
-        </span>
-        <span
-          className="navbar-search-item-match"
-          dangerouslySetInnerHTML={
-            {
-              "__html": "f<mark>o</mark>o",
-            }
-          }
-        />
-      </div>
-      <div
-        className="navbar-search-item-right text-muted-2"
-      >
-        foo
-      </div>
-    </div>
-  </ForwardRef(Link)>
-</li>
-`;
-
-exports[`renders recently browsed 1`] = `
-<li
-  key="foo"
->
-  <ForwardRef(Link)
-    data-key="foo"
-    onClick={[MockFunction]}
-    onFocus={[Function]}
-    to={
-      {
-        "pathname": "/dashboard",
-        "search": "?id=foo",
-      }
-    }
-  >
-    <div
-      className="navbar-search-item-link little-padded-top"
-      onMouseEnter={[Function]}
-    >
-      <div
-        className="display-flex-center"
-      >
-        <span
-          className="navbar-search-item-icons little-spacer-right"
-        >
-          <ClockIcon
-            size={12}
-          />
-          <QualifierIcon
-            className="little-spacer-right"
-            qualifier="TRK"
-          />
-        </span>
-        <span
-          className="navbar-search-item-match"
-        >
-          foo
-        </span>
-      </div>
-      <div
-        className="navbar-search-item-right text-muted-2"
-      >
-        foo
-      </div>
-    </div>
-  </ForwardRef(Link)>
-</li>
-`;
-
-exports[`renders selected 1`] = `
-<li
-  key="foo"
->
-  <ForwardRef(Link)
-    data-key="foo"
-    onClick={[MockFunction]}
-    onFocus={[Function]}
-    to={
-      {
-        "pathname": "/dashboard",
-        "search": "?id=foo",
-      }
-    }
-  >
-    <div
-      className="navbar-search-item-link little-padded-top"
-      onMouseEnter={[Function]}
-    >
-      <div
-        className="display-flex-center"
-      >
-        <span
-          className="navbar-search-item-icons little-spacer-right"
-        >
-          <QualifierIcon
-            className="little-spacer-right"
-            qualifier="TRK"
-          />
-        </span>
-        <span
-          className="navbar-search-item-match"
-        >
-          foo
-        </span>
-      </div>
-      <div
-        className="navbar-search-item-right text-muted-2"
-      >
-        foo
-      </div>
-    </div>
-  </ForwardRef(Link)>
-</li>
-`;
-
-exports[`renders selected 2`] = `
-<li
-  key="foo"
->
-  <ForwardRef(Link)
-    className="hover"
-    data-key="foo"
-    onClick={[MockFunction]}
-    onFocus={[Function]}
-    to={
-      {
-        "pathname": "/dashboard",
-        "search": "?id=foo",
-      }
-    }
-  >
-    <div
-      className="navbar-search-item-link little-padded-top"
-      onMouseEnter={[Function]}
-    >
-      <div
-        className="display-flex-center"
-      >
-        <span
-          className="navbar-search-item-icons little-spacer-right"
-        >
-          <QualifierIcon
-            className="little-spacer-right"
-            qualifier="TRK"
-          />
-        </span>
-        <span
-          className="navbar-search-item-match"
-        >
-          foo
-        </span>
-      </div>
-      <div
-        className="navbar-search-item-right text-muted-2"
-      >
-        foo
-      </div>
-    </div>
-  </ForwardRef(Link)>
-</li>
-`;
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.tsx.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.tsx.snap
deleted file mode 100644 (file)
index c77831d..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders "Show More" link 1`] = `
-<div>
-  <h2
-    className="menu-header no-margin"
-    id="qualifiers.TRK"
-  >
-    qualifiers.TRK
-  </h2>
-  <ul
-    aria-labelledby="qualifiers.TRK"
-    className="menu"
-    key="header-TRK"
-  >
-    <span
-      key="foo"
-    >
-      foo
-    </span>
-    <span
-      key="bar"
-    >
-      bar
-    </span>
-    <SearchShowMore
-      allowMore={true}
-      key="more-TRK"
-      onMoreClick={[MockFunction]}
-      onSelect={[MockFunction]}
-      qualifier="TRK"
-      selected={false}
-    />
-  </ul>
-</div>
-`;
-
-exports[`renders different components and dividers between them 1`] = `
-<div>
-  <h2
-    className="menu-header no-margin"
-    id="qualifiers.FIL"
-  >
-    qualifiers.FIL
-  </h2>
-  <ul
-    aria-labelledby="qualifiers.FIL"
-    className="menu"
-    key="header-FIL"
-  >
-    <span
-      key="zux"
-    >
-      zux
-    </span>
-  </ul>
-  <h2
-    className="menu-header no-margin"
-    id="qualifiers.TRK"
-  >
-    qualifiers.TRK
-  </h2>
-  <ul
-    aria-labelledby="qualifiers.TRK"
-    className="menu"
-    key="header-TRK"
-  >
-    <span
-      key="foo"
-    >
-      foo
-    </span>
-    <span
-      key="bar"
-    >
-      bar
-    </span>
-  </ul>
-</div>
-`;
-
-exports[`should render no results 1`] = `
-<div
-  id="no-results"
-/>
-`;
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchShowMore-test.tsx.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchShowMore-test.tsx.snap
deleted file mode 100644 (file)
index e6cdc12..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render 1`] = `
-<li
-  className="menu-footer"
-  key="more-TRK"
->
-  <DeferredSpinner
-    className="navbar-search-icon"
-    loading={false}
-  >
-    <a
-      data-qualifier="TRK"
-      href="#"
-      onClick={[Function]}
-      onMouseEnter={[Function]}
-    >
-      <div
-        className="pull-right text-muted-2 menu-footer-note"
-      >
-        <FormattedMessage
-          defaultMessage="search.show_more.hint"
-          id="search.show_more.hint"
-          values={
-            {
-              "key": <span
-                className="shortcut-button shortcut-button-small"
-              >
-                Enter
-              </span>,
-            }
-          }
-        />
-      </div>
-      <span>
-        show_more
-      </span>
-    </a>
-  </DeferredSpinner>
-</li>
-`;
diff --git a/server/sonar-web/src/main/js/app/components/search/utils.ts b/server/sonar-web/src/main/js/app/components/search/utils.ts
deleted file mode 100644 (file)
index 51f9fc6..0000000
+++ /dev/null
@@ -1,50 +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.
- */
-import { sortBy } from 'lodash';
-import { ComponentQualifier } from '../../../../js/types/component';
-
-const ORDER = [
-  ComponentQualifier.Developper,
-  ComponentQualifier.Portfolio,
-  ComponentQualifier.SubPortfolio,
-  ComponentQualifier.Application,
-  ComponentQualifier.Project,
-];
-
-export function sortQualifiers(qualifiers: string[]) {
-  return sortBy(qualifiers, (qualifier) => ORDER.indexOf(qualifier as ComponentQualifier));
-}
-
-export interface ComponentResult {
-  isFavorite?: boolean;
-  isRecentlyBrowsed?: boolean;
-  key: string;
-  match?: string;
-  name: string;
-  qualifier: string;
-}
-
-export interface Results {
-  [qualifier: string]: ComponentResult[];
-}
-
-export interface More {
-  [qualifier: string]: number;
-}
index 490a7f9043a51e2392688e2fa4142faf6d6d1ca1..14e3c683fbbab03d926335e1edcd66282eea7428 100644 (file)
@@ -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 { render } from 'react-dom';
 import { Helmet, HelmetProvider } from 'react-helmet-async';
@@ -184,75 +186,77 @@ export default function startReactApp(
         <AvailableFeaturesContext.Provider value={availableFeatures ?? DEFAULT_AVAILABLE_FEATURES}>
           <CurrentUserContextProvider currentUser={currentUser}>
             <IntlProvider defaultLocale={lang} locale={lang}>
-              <GlobalMessagesContainer />
-              <BrowserRouter basename={getBaseUrl()}>
-                <Helmet titleTemplate={translate('page_title.template.default')} />
-                <Routes>
-                  {renderRedirects()}
+              <ThemeProvider theme={lightTheme}>
+                <GlobalMessagesContainer />
+                <BrowserRouter basename={getBaseUrl()}>
+                  <Helmet titleTemplate={translate('page_title.template.default')} />
+                  <Routes>
+                    {renderRedirects()}
 
-                  <Route path="formatting/help" element={<FormattingHelp />} />
+                    <Route path="formatting/help" element={<FormattingHelp />} />
 
-                  <Route element={<SimpleContainer />}>{maintenanceRoutes()}</Route>
+                    <Route element={<SimpleContainer />}>{maintenanceRoutes()}</Route>
 
-                  <Route element={<MigrationContainer />}>
-                    {sessionsRoutes()}
+                    <Route element={<MigrationContainer />}>
+                      {sessionsRoutes()}
 
-                    <Route path="/" element={<App />}>
-                      <Route index={true} element={<Landing />} />
+                      <Route path="/" element={<App />}>
+                        <Route index={true} element={<Landing />} />
 
-                      <Route element={<GlobalContainer />}>
-                        {accountRoutes()}
+                        <Route element={<GlobalContainer />}>
+                          {accountRoutes()}
 
-                        {codingRulesRoutes()}
+                          {codingRulesRoutes()}
 
-                        <Route
-                          path="extension/:pluginKey/:extensionKey"
-                          element={<GlobalPageExtension />}
-                        />
+                          <Route
+                            path="extension/:pluginKey/:extensionKey"
+                            element={<GlobalPageExtension />}
+                          />
 
-                        {globalIssuesRoutes()}
+                          {globalIssuesRoutes()}
 
-                        {projectsRoutes()}
+                          {projectsRoutes()}
 
-                        {qualityGatesRoutes()}
-                        {qualityProfilesRoutes()}
+                          {qualityGatesRoutes()}
+                          {qualityProfilesRoutes()}
 
-                        <Route path="portfolios" element={<PortfoliosPage />} />
+                          <Route path="portfolios" element={<PortfoliosPage />} />
 
-                        <Route path="sonarlint/auth" element={<SonarLintConnection />} />
+                          <Route path="sonarlint/auth" element={<SonarLintConnection />} />
 
-                        {webAPIRoutes()}
+                          {webAPIRoutes()}
 
-                        {renderComponentRoutes()}
+                          {renderComponentRoutes()}
 
-                        {renderAdminRoutes()}
-                      </Route>
-                      <Route
-                        // We don't want this route to have any menu.
-                        // That is why we can not have it under the accountRoutes
-                        path="account/reset_password"
-                        element={<ResetPassword />}
-                      />
+                          {renderAdminRoutes()}
+                        </Route>
+                        <Route
+                          // We don't want this route to have any menu.
+                          // That is why we can not have it under the accountRoutes
+                          path="account/reset_password"
+                          element={<ResetPassword />}
+                        />
 
-                      <Route
-                        // We don't want this route to have any menu. This is why we define it here
-                        // rather than under the admin routes.
-                        path="admin/change_admin_password"
-                        element={<ChangeAdminPasswordApp />}
-                      />
+                        <Route
+                          // We don't want this route to have any menu. This is why we define it here
+                          // rather than under the admin routes.
+                          path="admin/change_admin_password"
+                          element={<ChangeAdminPasswordApp />}
+                        />
 
-                      <Route
-                        // We don't want this route to have any menu. This is why we define it here
-                        // rather than under the admin routes.
-                        path="admin/plugin_risk_consent"
-                        element={<PluginRiskConsent />}
-                      />
-                      <Route path="not_found" element={<NotFound />} />
-                      <Route path="*" element={<NotFound />} />
+                        <Route
+                          // We don't want this route to have any menu. This is why we define it here
+                          // rather than under the admin routes.
+                          path="admin/plugin_risk_consent"
+                          element={<PluginRiskConsent />}
+                        />
+                        <Route path="not_found" element={<NotFound />} />
+                        <Route path="*" element={<NotFound />} />
+                      </Route>
                     </Route>
-                  </Route>
-                </Routes>
-              </BrowserRouter>
+                  </Routes>
+                </BrowserRouter>
+              </ThemeProvider>
             </IntlProvider>
           </CurrentUserContextProvider>
         </AvailableFeaturesContext.Provider>
diff --git a/server/sonar-web/src/main/js/components/embed-docs-modal/DocItemLink.tsx b/server/sonar-web/src/main/js/components/embed-docs-modal/DocItemLink.tsx
new file mode 100644 (file)
index 0000000..dd5939f
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public 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 { ItemLink, OpenNewTabIcon } from 'design-system';
+import * as React from 'react';
+import { AppStateContext } from '../../app/components/app-state/AppStateContext';
+
+import { getUrlForDoc } from '../../helpers/docs';
+
+interface Props {
+  to: string;
+  innerRef?: React.Ref<HTMLAnchorElement>;
+  children: React.ReactNode;
+}
+
+export function DocItemLink({ to, innerRef, children }: Props) {
+  const { version } = React.useContext(AppStateContext);
+
+  const toStatic = getUrlForDoc(version, to);
+
+  return (
+    <ItemLink innerRef={innerRef} to={toStatic}>
+      <OpenNewTabIcon />
+      {children}
+    </ItemLink>
+  );
+}
index a477faa9fb216fd13cca6a35fe2cc2d19509a169..20afc52d3ea551114a8ec9e30dd9fc0d46d48018 100644 (file)
  * 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, ItemLink } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../helpers/l10n';
 import { getBaseUrl } from '../../helpers/system';
 import { SuggestionLink } from '../../types/types';
-import DocLink from '../common/DocLink';
-import Link from '../common/Link';
-import { DropdownOverlay } from '../controls/Dropdown';
+import { DocItemLink } from './DocItemLink';
 import { SuggestionsContext } from './SuggestionsContext';
 
-interface Props {
-  onClose: () => void;
+function IconLink({
+  icon = 'embed-doc/sq-icon.svg',
+  link,
+  text,
+}: {
+  icon?: string;
+  link: string;
+  text: string;
+}) {
+  return (
+    <ItemLink to={link}>
+      <img
+        alt={text}
+        aria-hidden={true}
+        className="spacer-right"
+        height="18"
+        src={`${getBaseUrl()}/images/${icon}`}
+        width="18"
+      />
+      {text}
+    </ItemLink>
+  );
 }
 
-export default class EmbedDocsPopup extends React.PureComponent<Props> {
-  firstItem: HTMLAnchorElement | null = null;
-
-  /*
-   * Will be called by the first suggestion (if any), as well as the first link (documentation)
-   * Since we don't know if we have any suggestions, we need to allow both to make the call.
-   * If we have at least 1 suggestion, it will make the call first, and prevent 'documentation' from
-   * getting the focus.
-   */
-  focusFirstItem: React.Ref<HTMLAnchorElement> = (node: HTMLAnchorElement | null) => {
-    if (node && !this.firstItem) {
-      this.firstItem = node;
-      this.firstItem.focus();
-    }
-  };
-
-  renderTitle(text: string, labelId: string) {
-    return (
-      <h2 className="menu-header" id={labelId}>
-        {text}
-      </h2>
-    );
-  }
+function Suggestions({
+  firstItemRef,
+  suggestions,
+}: {
+  firstItemRef: React.RefObject<HTMLAnchorElement>;
+  suggestions: SuggestionLink[];
+}) {
+  return (
+    <>
+      <ItemHeader id="suggestion">{translate('docs.suggestion')}</ItemHeader>
+      {suggestions.map((suggestion, i) => (
+        <DocItemLink
+          innerRef={i === 0 ? firstItemRef : undefined}
+          key={suggestion.link}
+          to={suggestion.link}
+        >
+          {suggestion.text}
+        </DocItemLink>
+      ))}
+      <ItemDivider />
+    </>
+  );
+}
 
-  renderSuggestions = ({ suggestions }: { suggestions: SuggestionLink[] }) => {
-    if (suggestions.length === 0) {
-      return null;
-    }
-    return (
-      <>
-        {this.renderTitle(translate('docs.suggestion'), 'suggestion')}
-        <ul className="menu abs-width-240" aria-labelledby="suggestion">
-          {suggestions.map((suggestion, i) => (
-            <li key={suggestion.link}>
-              <DocLink
-                innerRef={i === 0 ? this.focusFirstItem : undefined}
-                onClick={this.props.onClose}
-                to={suggestion.link}
-              >
-                {suggestion.text}
-              </DocLink>
-            </li>
-          ))}
-        </ul>
-      </>
-    );
-  };
+export function EmbedDocsPopup() {
+  const firstItemRef = React.useRef<HTMLAnchorElement>(null);
+  const { suggestions } = React.useContext(SuggestionsContext);
 
-  renderIconLink(link: string, icon: string, text: string) {
-    return (
-      <a href={link} rel="noopener noreferrer" target="_blank">
-        <img
-          alt={text}
-          aria-hidden={true}
-          className="spacer-right"
-          height="18"
-          src={`${getBaseUrl()}/images/${icon}`}
-          width="18"
-        />
-        {text}
-      </a>
-    );
-  }
+  React.useEffect(() => {
+    firstItemRef.current?.focus();
+  }, []);
 
-  render() {
-    return (
-      <DropdownOverlay>
-        <SuggestionsContext.Consumer>{this.renderSuggestions}</SuggestionsContext.Consumer>
-        <ul className="menu abs-width-240">
-          <li>
-            <DocLink innerRef={this.focusFirstItem} onClick={this.props.onClose} to="/">
-              {translate('docs.documentation')}
-            </DocLink>
-          </li>
-          <li>
-            <Link onClick={this.props.onClose} to="/web_api">
-              {translate('api_documentation.page')}
-            </Link>
-          </li>
-        </ul>
-        <ul className="menu abs-width-240">
-          <li>
-            <Link
-              className="display-flex-center"
-              to="https://community.sonarsource.com/"
-              target="_blank"
-            >
-              {translate('docs.get_help')}
-            </Link>
-          </li>
-        </ul>
-        {this.renderTitle(translate('docs.stay_connected'), 'stay_connected')}
-        <ul className="menu abs-width-240" aria-labelledby="stay_connected">
-          <li>
-            {this.renderIconLink(
-              'https://www.sonarqube.org/whats-new/?referrer=sonarqube',
-              'embed-doc/sq-icon.svg',
-              translate('docs.news')
-            )}
-          </li>
-          <li>
-            {this.renderIconLink(
-              'https://www.sonarqube.org/roadmap/?referrer=sonarqube',
-              'embed-doc/sq-icon.svg',
-              translate('docs.roadmap')
-            )}
-          </li>
-          <li>
-            {this.renderIconLink(
-              'https://twitter.com/SonarQube',
-              'embed-doc/twitter-icon.svg',
-              'Twitter'
-            )}
-          </li>
-        </ul>
-      </DropdownOverlay>
-    );
-  }
+  return (
+    <>
+      {suggestions.length !== 0 && (
+        <Suggestions firstItemRef={firstItemRef} suggestions={suggestions} />
+      )}
+      <DocItemLink innerRef={suggestions.length === 0 ? firstItemRef : undefined} to="/">
+        {translate('docs.documentation')}
+      </DocItemLink>
+      <ItemLink to="/web_api">{translate('api_documentation.page')}</ItemLink>
+      <ItemDivider />
+      <DocItemLink to="https://community.sonarsource.com/">
+        {translate('docs.get_help')}
+      </DocItemLink>
+      <ItemDivider />
+      <ItemHeader id="stay_connected">{translate('docs.stay_connected')}</ItemHeader>
+      <IconLink
+        link="https://www.sonarqube.org/whats-new/?referrer=sonarqube"
+        text={translate('docs.news')}
+      />
+      <IconLink
+        link="https://www.sonarqube.org/roadmap/?referrer=sonarqube"
+        text={translate('docs.roadmap')}
+      />
+      <IconLink
+        icon="embed-doc/twitter-icon.svg"
+        link="https://twitter.com/SonarQube"
+        text="Twitter"
+      />
+    </>
+  );
 }
index 1ba374b85a3c5645af0d2ec00847aa98056e7a61..3f1bfa7d8a3d5b4ba13e595f4ad807eacdad35d7 100644 (file)
  * 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,
+  InteractiveIcon,
+  MenuHelpIcon,
+  PopupPlacement,
+  PopupZLevel,
+  Tooltip,
+} from 'design-system';
 import * as React from 'react';
 import { translate } from '../../helpers/l10n';
-import { ButtonLink } from '../controls/buttons';
-import Toggler from '../controls/Toggler';
-import HelpIcon from '../icons/HelpIcon';
-import EmbedDocsPopup from './EmbedDocsPopup';
+import { EmbedDocsPopup } from './EmbedDocsPopup';
 
-interface State {
-  helpOpen: boolean;
-}
-
-export default class EmbedDocsPopupHelper extends React.PureComponent<{}, State> {
-  mounted = false;
-  state: State = { helpOpen: false };
-
-  setHelpDisplay = (helpOpen: boolean) => {
-    this.setState({ helpOpen });
-  };
-
-  handleClick = () => {
-    this.toggleHelp();
-  };
-
-  toggleHelp = () => {
-    this.setState((state) => {
-      return { helpOpen: !state.helpOpen };
-    });
-  };
-
-  closeHelp = () => {
-    this.setState({ helpOpen: false });
-  };
-
-  render() {
-    return (
-      <div className="dropdown">
-        <Toggler
-          onRequestClose={this.closeHelp}
-          open={this.state.helpOpen}
-          overlay={<EmbedDocsPopup onClose={this.closeHelp} />}
-        >
-          <ButtonLink
-            aria-expanded={this.state.helpOpen}
-            aria-haspopup={true}
-            className="navbar-help navbar-icon"
-            onClick={this.handleClick}
-            title={translate('help')}
+export default function EmbedDocsPopupHelper() {
+  return (
+    <div className="dropdown">
+      <Dropdown
+        id="help-menu-dropdown"
+        placement={PopupPlacement.BottomRight}
+        overlay={<EmbedDocsPopup />}
+        allowResizing={true}
+        zLevel={PopupZLevel.Global}
+      >
+        {({ onToggleClick, open }) => (
+          <Tooltip
+            mouseLeaveDelay={0.2}
+            overlay={translate('help')}
+            visible={open ? false : undefined}
           >
-            <HelpIcon />
-          </ButtonLink>
-        </Toggler>
-      </div>
-    );
-  }
+            <InteractiveIcon
+              Icon={MenuHelpIcon}
+              aria-expanded={open}
+              aria-controls="help-menu-dropdown"
+              aria-haspopup={true}
+              aria-label={translate('help')}
+              currentColor={true}
+              onClick={onToggleClick}
+              size="medium"
+              stopPropagation={false}
+            />
+          </Tooltip>
+        )}
+      </Dropdown>
+    </div>
+  );
 }
index 25335525161f40e1701deccbfccd04d522bb3855..6f383868948d9f3e35df557e42e477755a009c96 100644 (file)
  * 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 userEvent from '@testing-library/user-event';
 import * as React from 'react';
 import { renderComponent } from '../../../helpers/testReactTestingUtils';
-import { SuggestionLink } from '../../../types/types';
-import EmbedDocsPopup from '../EmbedDocsPopup';
-import { SuggestionsContext } from '../SuggestionsContext';
+import EmbedDocsPopupHelper from '../EmbedDocsPopupHelper';
+import Suggestions from '../Suggestions';
+import SuggestionsProvider from '../SuggestionsProvider';
 
-it('should render with no suggestions', () => {
+it('should render with no suggestions', async () => {
+  const user = userEvent.setup();
   renderEmbedDocsPopup();
 
-  expect(screen.queryByText(suggestions[0].text)).not.toBeInTheDocument();
+  await user.click(screen.getByRole('button', { name: 'help' }));
+
+  expect(screen.getByText('docs.documentation')).toBeInTheDocument();
+  expect(screen.queryByText('docs.suggestion')).not.toBeInTheDocument();
+
   expect(screen.getByText('docs.documentation')).toHaveFocus();
 });
 
-it('should render with suggestions', () => {
-  renderEmbedDocsPopup(suggestions);
+it('should be able to render with suggestions and remove them', async () => {
+  const user = userEvent.setup();
+  renderEmbedDocsPopup();
+
+  await user.click(screen.getByRole('button', { name: 'help' }));
+  await user.click(screen.getByRole('button', { name: 'add.suggestion' }));
+
+  await user.click(screen.getByRole('button', { name: 'help' }));
+
+  expect(screen.getByText('docs.suggestion')).toBeInTheDocument();
+  expect(screen.getByText('About Background Tasks')).toBeInTheDocument();
 
-  suggestions.forEach((suggestion) => {
-    expect(screen.getByText(suggestion.text)).toBeInTheDocument();
-  });
-  expect(screen.getByText(suggestions[0].text)).toHaveFocus();
+  expect(screen.getByText('About Background Tasks')).toHaveFocus();
+
+  await user.click(screen.getByRole('button', { name: 'remove.suggestion' }));
+  await user.click(screen.getByRole('button', { name: 'help' }));
+  expect(screen.queryByText('docs.suggestion')).not.toBeInTheDocument();
+
+  expect(screen.getByText('docs.documentation')).toHaveFocus();
 });
 
-const suggestions = [
-  { link: '/docs/awesome-doc', text: 'mindblowing' },
-  { link: '/docs/whocares', text: 'boring' },
-];
-
-function renderEmbedDocsPopup(suggestions: SuggestionLink[] = []) {
-  return renderComponent(
-    <SuggestionsContext.Provider
-      value={{ addSuggestions: jest.fn(), removeSuggestions: jest.fn(), suggestions }}
-    >
-      <EmbedDocsPopup onClose={jest.fn()} />
-    </SuggestionsContext.Provider>
-  );
+function renderEmbedDocsPopup() {
+  function Test() {
+    const [suggestions, setSuggestions] = React.useState<string[]>(['account']);
+
+    const addSuggestion = () => {
+      setSuggestions([...suggestions, 'background_tasks']);
+    };
+
+    return (
+      <SuggestionsProvider>
+        <button onClick={addSuggestion} type="button">
+          add.suggestion
+        </button>
+        <button onClick={() => setSuggestions([])} type="button">
+          remove.suggestion
+        </button>
+        <EmbedDocsPopupHelper />
+        {suggestions.map((suggestion) => (
+          <Suggestions key={suggestion} suggestions={suggestion} />
+        ))}
+      </SuggestionsProvider>
+    );
+  }
+
+  return renderComponent(<Test />);
 }
diff --git a/server/sonar-web/src/main/js/components/embed-docs-modal/__tests__/SuggestionsProvider-test.tsx b/server/sonar-web/src/main/js/components/embed-docs-modal/__tests__/SuggestionsProvider-test.tsx
deleted file mode 100644 (file)
index 164ca49..0000000
+++ /dev/null
@@ -1,53 +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.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import SuggestionsProvider from '../SuggestionsProvider';
-
-jest.mock(
-  '../EmbedDocsSuggestions.json',
-  () => ({
-    pageA: [{ link: '/foo', text: 'Foo' }],
-    pageB: [{ link: '/qux', text: 'Qux' }],
-  }),
-  { virtual: true }
-);
-
-it('should add & remove suggestions', () => {
-  const wrapper = shallow<SuggestionsProvider>(
-    <SuggestionsProvider>
-      <div />
-    </SuggestionsProvider>
-  );
-  const instance = wrapper.instance();
-  expect(wrapper.state('suggestions')).toEqual([]);
-
-  instance.addSuggestions('pageA');
-  expect(wrapper.state('suggestions')).toEqual([{ link: '/foo', text: 'Foo' }]);
-
-  instance.addSuggestions('pageB');
-  expect(wrapper.state('suggestions')).toEqual([
-    { link: '/qux', text: 'Qux' },
-    { link: '/foo', text: 'Foo' },
-  ]);
-
-  instance.removeSuggestions('pageA');
-  expect(wrapper.state('suggestions')).toEqual([{ link: '/qux', text: 'Qux' }]);
-});
diff --git a/server/sonar-web/tailwind-utilities.js b/server/sonar-web/tailwind-utilities.js
new file mode 100644 (file)
index 0000000..162fa08
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public 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 plugin = require('tailwindcss/plugin');
+
+module.exports = plugin(({ addUtilities, theme }) => {
+  const newUtilities = {
+    '.heading-xl': {
+      'font-family': theme('fontFamily.sans'),
+      'font-size': theme('fontSize.xl'),
+      'line-height': theme('fontSize').xl[1],
+      'font-weight': theme('fontWeight.semibold'),
+    },
+    '.heading-lg': {
+      'font-family': theme('fontFamily.sans'),
+      'font-size': theme('fontSize.lg'),
+      'line-height': theme('fontSize').lg[1],
+      'font-weight': theme('fontWeight.semibold'),
+    },
+    '.heading-md': {
+      'font-family': theme('fontFamily.sans'),
+      'font-size': theme('fontSize.md'),
+      'line-height': theme('fontSize').md[1],
+      'font-weight': theme('fontWeight.semibold'),
+    },
+    '.body-md': {
+      'font-family': theme('fontFamily.sans'),
+      'font-size': theme('fontSize.base'),
+      'line-height': theme('fontSize').base[1],
+      'font-weight': theme('fontWeight.regular'),
+    },
+    '.body-md-highlight': {
+      'font-family': theme('fontFamily.sans'),
+      'font-size': theme('fontSize.base'),
+      'line-height': theme('fontSize').base[1],
+      'font-weight': theme('fontWeight.semibold'),
+    },
+    '.body-sm': {
+      'font-family': theme('fontFamily.sans'),
+      'font-size': theme('fontSize.sm'),
+      'line-height': theme('fontSize').sm[1],
+      'font-weight': theme('fontWeight.regular'),
+    },
+    '.body-sm-highlight': {
+      'font-family': theme('fontFamily.sans'),
+      'font-size': theme('fontSize.sm'),
+      'line-height': theme('fontSize').sm[1],
+      'font-weight': theme('fontWeight.semibold'),
+    },
+    '.code': {
+      'font-family': theme('fontFamily.mono'),
+      'font-size': theme('fontSize.sm'),
+      'line-height': theme('fontSize').sm[1],
+      'font-weight': theme('fontWeight.regular'),
+    },
+    '.code-highlight': {
+      'font-family': theme('fontFamily.mono'),
+      'font-size': theme('fontSize.sm'),
+      'line-height': theme('fontSize').sm[1],
+      'font-weight': theme('fontWeight.bold'),
+    },
+    '.code-comment': {
+      'font-family': theme('fontFamily.mono'),
+      'font-size': theme('fontSize.sm'),
+      'line-height': theme('fontSize').sm[1],
+      'font-style': 'italic',
+    },
+  };
+
+  addUtilities(newUtilities);
+});
diff --git a/server/sonar-web/tailwind.base.config.js b/server/sonar-web/tailwind.base.config.js
new file mode 100644 (file)
index 0000000..81ec058
--- /dev/null
@@ -0,0 +1,195 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public 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 path = require('path');
+const { fontFamily } = require('tailwindcss/defaultTheme');
+const utilities = require('./tailwind-utilities');
+
+module.exports = {
+  prefix: 'sw-', // Prefix all tailwind classes with the sw- prefix to avoid collisions
+  theme: {
+    // Define cursors
+    cursor: {
+      auto: 'auto',
+      default: 'default',
+      pointer: 'pointer',
+      'not-allowed': 'not-allowed',
+    },
+    // Define font sizes
+    fontSize: {
+      sm: ['0.875rem', '1.25rem'], // 14px / 20px
+      base: ['1rem', '1.5rem'], // 16px / 24px
+      md: ['1.313rem', '1.75rem'], // 21px / 28px
+      lg: ['1.5rem', '1.75rem'], // 24px / 28px
+      xl: ['2.25rem', '3rem'], // 36px / 48px
+    },
+    // Define font weights
+    fontWeight: {
+      regular: 400,
+      semibold: 600,
+      bold: 700,
+    },
+    // Define font families
+    fontFamily: {
+      sans: ['Inter', ...fontFamily.sans],
+      mono: ['Ubuntu Mono', ...fontFamily.mono],
+    },
+    // Define less order properties than default
+    order: {
+      first: '-9999',
+      last: '9999',
+      none: '0',
+      1: '1',
+      2: '2',
+      3: '3',
+      4: '4',
+    },
+    // No responsive breakpoint for the webapp
+    screens: {},
+    // Defined spacing values based on our grid size
+    spacing: {
+      0: '0',
+      '1/2': '0.125rem', // 2px
+      1: '0.25rem', // 4px
+      2: '0.5rem', // 8px
+      3: '0.75rem', // 12px
+      4: '1rem', // 16px
+      6: '1.5rem', // 24px
+      8: '2rem', // 32px
+      10: '2.5rem', // 40px
+      12: '3rem', // 48px
+      16: '4rem', // 64px
+      24: '6rem', // 96px
+      40: '10rem', // 160px
+      64: '16rem', // 256px
+
+      page: '1.25rem', // 20px
+    },
+    maxHeight: (twTheme) => twTheme('height'),
+    maxWidth: (twTheme) => twTheme('width'),
+    minHeight: (twTheme) => twTheme('height'),
+    minWidth: (twTheme) => twTheme('width'),
+    borderRadius: {
+      0: '0',
+      '1/2': '0.125rem', // 2px
+      1: '0.25rem', // 4px
+      2: '0.5rem', // 8px
+      pill: '625rem',
+    },
+    zIndex: {
+      normal: '2',
+      filterbar: '50',
+      'content-popup': '52',
+      'filterbar-header': '55',
+      'top-navbar': '419',
+      popup: '420',
+      'global-navbar': '421',
+      sidebar: '421',
+      'core-concepts': '422',
+      'global-popup': '5000',
+      'dropdown-menu': '7500',
+      tooltip: '8000',
+    },
+    extend: {
+      width: {
+        'abs-150': '150px',
+        'abs-200': '200px',
+        'abs-250': '250px',
+        'abs-300': '300px',
+        'abs-350': '350px',
+        'abs-400': '400px',
+        'abs-500': '500px',
+        'abs-600': '600px',
+        'abs-800': '800px',
+        'input-small': '150px',
+        'input-medium': '250px',
+        'input-large': '350px',
+        icon: '1rem', // 16px
+      },
+      height: {
+        'abs-200': '200px',
+        icon: '1rem', // 16px
+        control: '2.25rem', // 36px
+      },
+    },
+  },
+  variants: {},
+  corePlugins: {
+    // Please respect the alphabetical order in the below plugins
+    alignItems: true, // .sw-items-x classes
+    alignSelf: true, // .sw-self-x classes
+    borderRadius: true, // .sw-rounded-x classes
+    boxSizing: true, // .sw-box-x classes
+    cursor: true, // .sw-cursor-not-allowed
+    display: true, // display classes .sw-grid .sw-flex
+    flex: true, // .sw-flex-1 .sw-flex-auto ... classes
+    flexDirection: true, // .sw-flex-row .sw-flex-col-reverse ... classes
+    flexGrow: true, // .sw-flex-grow .sw-flex-grow-0 classes
+    flexShrink: true, // .sw-flex-shrink .sw-flex-shrink-0 classes
+    flexWrap: true, // .sw-flex-wrap sw-flex-nowrap ... classes
+    fontFamily: true, // .sw-font-sans .sw-font-mono classes
+    fontSize: true, // .sw-text-sm and similar classes
+    fontWeight: true, // .sw-font-x classes
+    gap: true, // .sw-gap-x classes based on spacing
+    gridAutoFlow: true, // all css grid related classes: .sw-grid-cols-x .sw-col-span-x
+    gridColumn: true,
+    gridColumnEnd: true,
+    gridColumnStart: true,
+    gridRow: true,
+    gridRowEnd: true,
+    gridRowStart: true,
+    gridTemplateColumns: true,
+    gridTemplateRows: true,
+    height: true, // height classes .sw-h-x based on spacing + some more
+    inset: true, // placement classes .sw-top-x based on spacing + some more
+    justifyContent: true, // .sw-justify-x classes
+    lineHeight: true, // .sw-leading-x classes
+    margin: true, // .sw-m-x classes based on spacing
+    maxHeight: true, // sw-max-height classes .sw-max-h-x based on spacing + some more
+    maxWidth: true, // sw-max-width classes .sw-max-w-x based on spacing + some more
+    minHeight: true, // sw-min-height classes .sw-min-h-x based on spacing + some more
+    minWidth: true, // sw-min-width classes .sw-min-w-x based on spacing + some more
+    opacity: true, // sw-opacity-x classes
+    order: true, // .sw-order-x classes
+    overflow: true, // .sw-overflow-x classes
+    padding: true, // .sw-p-x classes based on spacing
+    pointerEvents: true, //.sw-pointer-events-none .sw-pointer-events-auto
+    position: true, // position classes .sw-relative .sw-absolute
+    preflight: false, // disable preflight
+    textAlign: true, // .sw-text-x classes
+    textOverflow: true, // .sw-overflow-ellipsis, .sw-truncate
+    textTransform: true, // sw-uppercase, .sw-capitalize
+    userSelect: true, // .sw-select-none classes
+    verticalAlign: true, // .sw-align-x classes
+    width: true, // .sw-w-x classes based on spacing + some more
+    whitespace: true, // sw-whitespace-x classes
+    wordBreak: true, // .sw-break-normal, sw-break-all, sw-break-words classes
+    zIndex: true, // .sw-z-x classes
+  },
+  plugins: [utilities],
+  // PurgeCss will look into those files to find unused tailwind classes and drop them
+  purge: {
+    content: [
+      path.resolve(__dirname, './src/**/!(__tests__|@types|api|pages|marketing)/*.{ts,tsx}'),
+    ],
+    options: {
+      safelist: [],
+    },
+  },
+};
index 53d685f82b611adf2ec6dd512e7e8435a34fac71..44679fdab521ecfad68015564d2f26eeff31b3a1 100644 (file)
 
 /** @type {import('tailwindcss').Config} */
 module.exports = {
-  content: ['./src/**/*.{js,ts,jsx,tsx}'],
-  corePlugins: {
-    preflight: false,
-  },
   important: true,
-  prefix: 'sw-',
+  presets: [require('./tailwind.base.config')],
 };
index 455f467f29b89e9636ea950802591c9b901034db..7491275997d8a9bef5341e0f88b8b006acb8c117 100644 (file)
@@ -75,6 +75,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/compat-data@npm:^7.17.7, @babel/compat-data@npm:^7.20.1":
+  version: 7.21.0
+  resolution: "@babel/compat-data@npm:7.21.0"
+  checksum: dbf632c532f9c75ba0be7d1dc9f6cd3582501af52f10a6b90415d634ec5878735bd46064c91673b10317af94d4cc99c4da5bd9d955978cdccb7905fc33291e4d
+  languageName: node
+  linkType: hard
+
 "@babel/compat-data@npm:^7.20.0":
   version: 7.20.5
   resolution: "@babel/compat-data@npm:7.20.5"
@@ -202,6 +209,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/generator@npm:^7.21.1":
+  version: 7.21.1
+  resolution: "@babel/generator@npm:7.21.1"
+  dependencies:
+    "@babel/types": ^7.21.0
+    "@jridgewell/gen-mapping": ^0.3.2
+    "@jridgewell/trace-mapping": ^0.3.17
+    jsesc: ^2.5.1
+  checksum: 69085a211ff91a7a608ee3f86e6fcb9cf5e724b756d792a713b0c328a671cd3e423e1ef1b12533f366baba0616caffe0a7ba9d328727eab484de5961badbef00
+  languageName: node
+  linkType: hard
+
 "@babel/helper-annotate-as-pure@npm:^7.18.6":
   version: 7.18.6
   resolution: "@babel/helper-annotate-as-pure@npm:7.18.6"
@@ -211,6 +230,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-builder-binary-assignment-operator-visitor@npm:^7.18.6":
+  version: 7.18.9
+  resolution: "@babel/helper-builder-binary-assignment-operator-visitor@npm:7.18.9"
+  dependencies:
+    "@babel/helper-explode-assignable-expression": ^7.18.6
+    "@babel/types": ^7.18.9
+  checksum: b4bc214cb56329daff6cc18a7f7a26aeafb55a1242e5362f3d47fe3808421f8c7cd91fff95d6b9b7ccb67e14e5a67d944e49dbe026942bfcbfda19b1c72a8e72
+  languageName: node
+  linkType: hard
+
 "@babel/helper-compilation-targets@npm:^7.16.7":
   version: 7.16.7
   resolution: "@babel/helper-compilation-targets@npm:7.16.7"
@@ -225,6 +254,21 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-compilation-targets@npm:^7.17.7, @babel/helper-compilation-targets@npm:^7.18.9, @babel/helper-compilation-targets@npm:^7.20.7":
+  version: 7.20.7
+  resolution: "@babel/helper-compilation-targets@npm:7.20.7"
+  dependencies:
+    "@babel/compat-data": ^7.20.5
+    "@babel/helper-validator-option": ^7.18.6
+    browserslist: ^4.21.3
+    lru-cache: ^5.1.1
+    semver: ^6.3.0
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: 8c32c873ba86e2e1805b30e0807abd07188acbe00ebb97576f0b09061cc65007f1312b589eccb4349c5a8c7f8bb9f2ab199d41da7030bf103d9f347dcd3a3cf4
+  languageName: node
+  linkType: hard
+
 "@babel/helper-compilation-targets@npm:^7.20.0":
   version: 7.20.0
   resolution: "@babel/helper-compilation-targets@npm:7.20.0"
@@ -239,18 +283,49 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/helper-compilation-targets@npm:^7.20.7":
-  version: 7.20.7
-  resolution: "@babel/helper-compilation-targets@npm:7.20.7"
+"@babel/helper-create-class-features-plugin@npm:^7.18.6, @babel/helper-create-class-features-plugin@npm:^7.21.0":
+  version: 7.21.0
+  resolution: "@babel/helper-create-class-features-plugin@npm:7.21.0"
   dependencies:
-    "@babel/compat-data": ^7.20.5
-    "@babel/helper-validator-option": ^7.18.6
-    browserslist: ^4.21.3
-    lru-cache: ^5.1.1
-    semver: ^6.3.0
+    "@babel/helper-annotate-as-pure": ^7.18.6
+    "@babel/helper-environment-visitor": ^7.18.9
+    "@babel/helper-function-name": ^7.21.0
+    "@babel/helper-member-expression-to-functions": ^7.21.0
+    "@babel/helper-optimise-call-expression": ^7.18.6
+    "@babel/helper-replace-supers": ^7.20.7
+    "@babel/helper-skip-transparent-expression-wrappers": ^7.20.0
+    "@babel/helper-split-export-declaration": ^7.18.6
   peerDependencies:
     "@babel/core": ^7.0.0
-  checksum: 8c32c873ba86e2e1805b30e0807abd07188acbe00ebb97576f0b09061cc65007f1312b589eccb4349c5a8c7f8bb9f2ab199d41da7030bf103d9f347dcd3a3cf4
+  checksum: 3e781d91d1056ea9b3a0395f3017492594a8b86899119b4a1645227c31727b8bec9bc8f6b72e86b1c5cf2dd6690893d2e8c5baff4974c429e616ead089552a21
+  languageName: node
+  linkType: hard
+
+"@babel/helper-create-regexp-features-plugin@npm:^7.18.6, @babel/helper-create-regexp-features-plugin@npm:^7.20.5":
+  version: 7.21.0
+  resolution: "@babel/helper-create-regexp-features-plugin@npm:7.21.0"
+  dependencies:
+    "@babel/helper-annotate-as-pure": ^7.18.6
+    regexpu-core: ^5.3.1
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: 63a6396a4e9444edc7e97617845583ea5cf059573d0b4cc566869f38576d543e37fde0edfcc21d6dfb7962ed241e909561714dc41c5213198bac04e0983b04f2
+  languageName: node
+  linkType: hard
+
+"@babel/helper-define-polyfill-provider@npm:^0.3.3":
+  version: 0.3.3
+  resolution: "@babel/helper-define-polyfill-provider@npm:0.3.3"
+  dependencies:
+    "@babel/helper-compilation-targets": ^7.17.7
+    "@babel/helper-plugin-utils": ^7.16.7
+    debug: ^4.1.1
+    lodash.debounce: ^4.0.8
+    resolve: ^1.14.2
+    semver: ^6.1.2
+  peerDependencies:
+    "@babel/core": ^7.4.0-0
+  checksum: 8e3fe75513302e34f6d92bd67b53890e8545e6c5bca8fe757b9979f09d68d7e259f6daea90dc9e01e332c4f8781bda31c5fe551c82a277f9bc0bec007aed497c
   languageName: node
   linkType: hard
 
@@ -270,6 +345,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-explode-assignable-expression@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/helper-explode-assignable-expression@npm:7.18.6"
+  dependencies:
+    "@babel/types": ^7.18.6
+  checksum: 225cfcc3376a8799023d15dc95000609e9d4e7547b29528c7f7111a0e05493ffb12c15d70d379a0bb32d42752f340233c4115bded6d299bc0c3ab7a12be3d30f
+  languageName: node
+  linkType: hard
+
 "@babel/helper-function-name@npm:^7.16.7":
   version: 7.16.7
   resolution: "@babel/helper-function-name@npm:7.16.7"
@@ -281,6 +365,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-function-name@npm:^7.18.9, @babel/helper-function-name@npm:^7.21.0":
+  version: 7.21.0
+  resolution: "@babel/helper-function-name@npm:7.21.0"
+  dependencies:
+    "@babel/template": ^7.20.7
+    "@babel/types": ^7.21.0
+  checksum: d63e63c3e0e3e8b3138fa47b0cd321148a300ef12b8ee951196994dcd2a492cc708aeda94c2c53759a5c9177fffaac0fd8778791286746f72a000976968daf4e
+  languageName: node
+  linkType: hard
+
 "@babel/helper-function-name@npm:^7.19.0":
   version: 7.19.0
   resolution: "@babel/helper-function-name@npm:7.19.0"
@@ -318,6 +412,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-member-expression-to-functions@npm:^7.20.7, @babel/helper-member-expression-to-functions@npm:^7.21.0":
+  version: 7.21.0
+  resolution: "@babel/helper-member-expression-to-functions@npm:7.21.0"
+  dependencies:
+    "@babel/types": ^7.21.0
+  checksum: 49cbb865098195fe82ba22da3a8fe630cde30dcd8ebf8ad5f9a24a2b685150c6711419879cf9d99b94dad24cff9244d8c2a890d3d7ec75502cd01fe58cff5b5d
+  languageName: node
+  linkType: hard
+
 "@babel/helper-module-imports@npm:^7.16.7":
   version: 7.16.7
   resolution: "@babel/helper-module-imports@npm:7.16.7"
@@ -352,6 +455,22 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-module-transforms@npm:^7.18.6, @babel/helper-module-transforms@npm:^7.21.2":
+  version: 7.21.2
+  resolution: "@babel/helper-module-transforms@npm:7.21.2"
+  dependencies:
+    "@babel/helper-environment-visitor": ^7.18.9
+    "@babel/helper-module-imports": ^7.18.6
+    "@babel/helper-simple-access": ^7.20.2
+    "@babel/helper-split-export-declaration": ^7.18.6
+    "@babel/helper-validator-identifier": ^7.19.1
+    "@babel/template": ^7.20.7
+    "@babel/traverse": ^7.21.2
+    "@babel/types": ^7.21.2
+  checksum: 8a1c129a4f90bdf97d8b6e7861732c9580f48f877aaaafbc376ce2482febebcb8daaa1de8bc91676d12886487603f8c62a44f9e90ee76d6cac7f9225b26a49e1
+  languageName: node
+  linkType: hard
+
 "@babel/helper-module-transforms@npm:^7.20.11":
   version: 7.20.11
   resolution: "@babel/helper-module-transforms@npm:7.20.11"
@@ -384,6 +503,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-optimise-call-expression@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/helper-optimise-call-expression@npm:7.18.6"
+  dependencies:
+    "@babel/types": ^7.18.6
+  checksum: e518fe8418571405e21644cfb39cf694f30b6c47b10b006609a92469ae8b8775cbff56f0b19732343e2ea910641091c5a2dc73b56ceba04e116a33b0f8bd2fbd
+  languageName: node
+  linkType: hard
+
 "@babel/helper-plugin-utils@npm:^7.0.0":
   version: 7.0.0
   resolution: "@babel/helper-plugin-utils@npm:7.0.0"
@@ -412,7 +540,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/helper-plugin-utils@npm:^7.19.0, @babel/helper-plugin-utils@npm:^7.20.2":
+"@babel/helper-plugin-utils@npm:^7.18.9, @babel/helper-plugin-utils@npm:^7.19.0, @babel/helper-plugin-utils@npm:^7.20.2, @babel/helper-plugin-utils@npm:^7.8.3":
   version: 7.20.2
   resolution: "@babel/helper-plugin-utils@npm:7.20.2"
   checksum: f6cae53b7fdb1bf3abd50fa61b10b4470985b400cc794d92635da1e7077bb19729f626adc0741b69403d9b6e411cddddb9c0157a709cc7c4eeb41e663be5d74b
@@ -426,6 +554,34 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-remap-async-to-generator@npm:^7.18.9":
+  version: 7.18.9
+  resolution: "@babel/helper-remap-async-to-generator@npm:7.18.9"
+  dependencies:
+    "@babel/helper-annotate-as-pure": ^7.18.6
+    "@babel/helper-environment-visitor": ^7.18.9
+    "@babel/helper-wrap-function": ^7.18.9
+    "@babel/types": ^7.18.9
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: 4be6076192308671b046245899b703ba090dbe7ad03e0bea897bb2944ae5b88e5e85853c9d1f83f643474b54c578d8ac0800b80341a86e8538264a725fbbefec
+  languageName: node
+  linkType: hard
+
+"@babel/helper-replace-supers@npm:^7.18.6, @babel/helper-replace-supers@npm:^7.20.7":
+  version: 7.20.7
+  resolution: "@babel/helper-replace-supers@npm:7.20.7"
+  dependencies:
+    "@babel/helper-environment-visitor": ^7.18.9
+    "@babel/helper-member-expression-to-functions": ^7.20.7
+    "@babel/helper-optimise-call-expression": ^7.18.6
+    "@babel/template": ^7.20.7
+    "@babel/traverse": ^7.20.7
+    "@babel/types": ^7.20.7
+  checksum: b8e0087c9b0c1446e3c6f3f72b73b7e03559c6b570e2cfbe62c738676d9ebd8c369a708cf1a564ef88113b4330750a50232ee1131d303d478b7a5e65e46fbc7c
+  languageName: node
+  linkType: hard
+
 "@babel/helper-simple-access@npm:^7.16.7":
   version: 7.16.7
   resolution: "@babel/helper-simple-access@npm:7.16.7"
@@ -444,6 +600,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-skip-transparent-expression-wrappers@npm:^7.20.0":
+  version: 7.20.0
+  resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.20.0"
+  dependencies:
+    "@babel/types": ^7.20.0
+  checksum: 34da8c832d1c8a546e45d5c1d59755459ffe43629436707079989599b91e8c19e50e73af7a4bd09c95402d389266731b0d9c5f69e372d8ebd3a709c05c80d7dd
+  languageName: node
+  linkType: hard
+
 "@babel/helper-split-export-declaration@npm:^7.16.7":
   version: 7.16.7
   resolution: "@babel/helper-split-export-declaration@npm:7.16.7"
@@ -504,6 +669,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-wrap-function@npm:^7.18.9":
+  version: 7.20.5
+  resolution: "@babel/helper-wrap-function@npm:7.20.5"
+  dependencies:
+    "@babel/helper-function-name": ^7.19.0
+    "@babel/template": ^7.18.10
+    "@babel/traverse": ^7.20.5
+    "@babel/types": ^7.20.5
+  checksum: 11a6fc28334368a193a9cb3ad16f29cd7603bab958433efc82ebe59fa6556c227faa24f07ce43983f7a85df826f71d441638442c4315e90a554fe0a70ca5005b
+  languageName: node
+  linkType: hard
+
 "@babel/helpers@npm:^7.17.0":
   version: 7.17.0
   resolution: "@babel/helpers@npm:7.17.0"
@@ -590,6 +767,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/parser@npm:^7.12.5, @babel/parser@npm:^7.20.15, @babel/parser@npm:^7.21.2":
+  version: 7.21.2
+  resolution: "@babel/parser@npm:7.21.2"
+  bin:
+    parser: ./bin/babel-parser.js
+  checksum: e2b89de2c63d4cdd2cafeaea34f389bba729727eec7a8728f736bc472a59396059e3e9fe322c9bed8fd126d201fb609712949dc8783f4cae4806acd9a73da6ff
+  languageName: node
+  linkType: hard
+
 "@babel/parser@npm:^7.14.5":
   version: 7.15.3
   resolution: "@babel/parser@npm:7.15.3"
@@ -635,194 +821,985 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-async-generators@npm:^7.8.4":
-  version: 7.8.4
-  resolution: "@babel/plugin-syntax-async-generators@npm:7.8.4"
+"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: 845bd280c55a6a91d232cfa54eaf9708ec71e594676fe705794f494bb8b711d833b752b59d1a5c154695225880c23dbc9cab0e53af16fd57807976cd3ff41b8d
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:^7.18.9":
+  version: 7.20.7
+  resolution: "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:7.20.7"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/helper-skip-transparent-expression-wrappers": ^7.20.0
+    "@babel/plugin-proposal-optional-chaining": ^7.20.7
+  peerDependencies:
+    "@babel/core": ^7.13.0
+  checksum: d610f532210bee5342f5b44a12395ccc6d904e675a297189bc1e401cc185beec09873da523466d7fec34ae1574f7a384235cba1ccc9fe7b89ba094167897c845
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-async-generator-functions@npm:^7.20.1":
+  version: 7.20.7
+  resolution: "@babel/plugin-proposal-async-generator-functions@npm:7.20.7"
+  dependencies:
+    "@babel/helper-environment-visitor": ^7.18.9
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/helper-remap-async-to-generator": ^7.18.9
+    "@babel/plugin-syntax-async-generators": ^7.8.4
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 111109ee118c9e69982f08d5e119eab04190b36a0f40e22e873802d941956eee66d2aa5a15f5321e51e3f9aa70a91136451b987fe15185ef8cc547ac88937723
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-class-properties@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-proposal-class-properties@npm:7.18.6"
+  dependencies:
+    "@babel/helper-create-class-features-plugin": ^7.18.6
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 49a78a2773ec0db56e915d9797e44fd079ab8a9b2e1716e0df07c92532f2c65d76aeda9543883916b8e0ff13606afeffa67c5b93d05b607bc87653ad18a91422
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-class-static-block@npm:^7.18.6":
+  version: 7.21.0
+  resolution: "@babel/plugin-proposal-class-static-block@npm:7.21.0"
+  dependencies:
+    "@babel/helper-create-class-features-plugin": ^7.21.0
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/plugin-syntax-class-static-block": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.12.0
+  checksum: 236c0ad089e7a7acab776cc1d355330193314bfcd62e94e78f2df35817c6144d7e0e0368976778afd6b7c13e70b5068fa84d7abbf967d4f182e60d03f9ef802b
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-dynamic-import@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-proposal-dynamic-import@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+    "@babel/plugin-syntax-dynamic-import": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 96b1c8a8ad8171d39e9ab106be33bde37ae09b22fb2c449afee9a5edf3c537933d79d963dcdc2694d10677cb96da739cdf1b53454e6a5deab9801f28a818bb2f
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-export-namespace-from@npm:^7.18.9":
+  version: 7.18.9
+  resolution: "@babel/plugin-proposal-export-namespace-from@npm:7.18.9"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.9
+    "@babel/plugin-syntax-export-namespace-from": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 84ff22bacc5d30918a849bfb7e0e90ae4c5b8d8b65f2ac881803d1cf9068dffbe53bd657b0e4bc4c20b4db301b1c85f1e74183cf29a0dd31e964bd4e97c363ef
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-json-strings@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-proposal-json-strings@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+    "@babel/plugin-syntax-json-strings": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 25ba0e6b9d6115174f51f7c6787e96214c90dd4026e266976b248a2ed417fe50fddae72843ffb3cbe324014a18632ce5648dfac77f089da858022b49fd608cb3
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-logical-assignment-operators@npm:^7.18.9":
+  version: 7.20.7
+  resolution: "@babel/plugin-proposal-logical-assignment-operators@npm:7.20.7"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/plugin-syntax-logical-assignment-operators": ^7.10.4
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: cdd7b8136cc4db3f47714d5266f9e7b592a2ac5a94a5878787ce08890e97c8ab1ca8e94b27bfeba7b0f2b1549a026d9fc414ca2196de603df36fb32633bbdc19
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-nullish-coalescing-operator@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-proposal-nullish-coalescing-operator@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+    "@babel/plugin-syntax-nullish-coalescing-operator": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 949c9ddcdecdaec766ee610ef98f965f928ccc0361dd87cf9f88cf4896a6ccd62fce063d4494778e50da99dea63d270a1be574a62d6ab81cbe9d85884bf55a7d
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-numeric-separator@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-proposal-numeric-separator@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+    "@babel/plugin-syntax-numeric-separator": ^7.10.4
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: f370ea584c55bf4040e1f78c80b4eeb1ce2e6aaa74f87d1a48266493c33931d0b6222d8cee3a082383d6bb648ab8d6b7147a06f974d3296ef3bc39c7851683ec
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-object-rest-spread@npm:^7.20.2":
+  version: 7.20.7
+  resolution: "@babel/plugin-proposal-object-rest-spread@npm:7.20.7"
+  dependencies:
+    "@babel/compat-data": ^7.20.5
+    "@babel/helper-compilation-targets": ^7.20.7
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/plugin-syntax-object-rest-spread": ^7.8.3
+    "@babel/plugin-transform-parameters": ^7.20.7
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 1329db17009964bc644484c660eab717cb3ca63ac0ab0f67c651a028d1bc2ead51dc4064caea283e46994f1b7221670a35cbc0b4beb6273f55e915494b5aa0b2
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-optional-catch-binding@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-proposal-optional-catch-binding@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+    "@babel/plugin-syntax-optional-catch-binding": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 7b5b39fb5d8d6d14faad6cb68ece5eeb2fd550fb66b5af7d7582402f974f5bc3684641f7c192a5a57e0f59acfae4aada6786be1eba030881ddc590666eff4d1e
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-optional-chaining@npm:^7.18.9, @babel/plugin-proposal-optional-chaining@npm:^7.20.7":
+  version: 7.21.0
+  resolution: "@babel/plugin-proposal-optional-chaining@npm:7.21.0"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/helper-skip-transparent-expression-wrappers": ^7.20.0
+    "@babel/plugin-syntax-optional-chaining": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 11c5449e01b18bb8881e8e005a577fa7be2fe5688e2382c8822d51f8f7005342a301a46af7b273b1f5645f9a7b894c428eee8526342038a275ef6ba4c8d8d746
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-private-methods@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-proposal-private-methods@npm:7.18.6"
+  dependencies:
+    "@babel/helper-create-class-features-plugin": ^7.18.6
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 22d8502ee96bca99ad2c8393e8493e2b8d4507576dd054490fd8201a36824373440106f5b098b6d821b026c7e72b0424ff4aeca69ed5f42e48f029d3a156d5ad
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-private-property-in-object@npm:^7.18.6":
+  version: 7.21.0
+  resolution: "@babel/plugin-proposal-private-property-in-object@npm:7.21.0"
+  dependencies:
+    "@babel/helper-annotate-as-pure": ^7.18.6
+    "@babel/helper-create-class-features-plugin": ^7.21.0
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/plugin-syntax-private-property-in-object": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: add881a6a836635c41d2710551fdf777e2c07c0b691bf2baacc5d658dd64107479df1038680d6e67c468bfc6f36fb8920025d6bac2a1df0a81b867537d40ae78
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-proposal-unicode-property-regex@npm:^7.18.6, @babel/plugin-proposal-unicode-property-regex@npm:^7.4.4":
+  version: 7.18.6
+  resolution: "@babel/plugin-proposal-unicode-property-regex@npm:7.18.6"
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin": ^7.18.6
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: a8575ecb7ff24bf6c6e94808d5c84bb5a0c6dd7892b54f09f4646711ba0ee1e1668032b3c43e3e1dfec2c5716c302e851ac756c1645e15882d73df6ad21ae951
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-async-generators@npm:^7.8.4":
+  version: 7.8.4
+  resolution: "@babel/plugin-syntax-async-generators@npm:7.8.4"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 7ed1c1d9b9e5b64ef028ea5e755c0be2d4e5e4e3d6cf7df757b9a8c4cfa4193d268176d0f1f7fbecdda6fe722885c7fda681f480f3741d8a2d26854736f05367
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-bigint@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-bigint@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 3a10849d83e47aec50f367a9e56a6b22d662ddce643334b087f9828f4c3dd73bdc5909aaeabe123fed78515767f9ca43498a0e621c438d1cd2802d7fae3c9648
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-class-properties@npm:^7.12.13, @babel/plugin-syntax-class-properties@npm:^7.8.3":
+  version: 7.12.13
+  resolution: "@babel/plugin-syntax-class-properties@npm:7.12.13"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.12.13
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 24f34b196d6342f28d4bad303612d7ff566ab0a013ce89e775d98d6f832969462e7235f3e7eaf17678a533d4be0ba45d3ae34ab4e5a9dcbda5d98d49e5efa2fc
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-class-static-block@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-syntax-class-static-block@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 3e80814b5b6d4fe17826093918680a351c2d34398a914ce6e55d8083d72a9bdde4fbaf6a2dcea0e23a03de26dc2917ae3efd603d27099e2b98380345703bf948
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-dynamic-import@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-dynamic-import@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: ce307af83cf433d4ec42932329fad25fa73138ab39c7436882ea28742e1c0066626d224e0ad2988724c82644e41601cef607b36194f695cb78a1fcdc959637bd
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-export-namespace-from@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-export-namespace-from@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 85740478be5b0de185228e7814451d74ab8ce0a26fcca7613955262a26e99e8e15e9da58f60c754b84515d4c679b590dbd3f2148f0f58025f4ae706f1c5a5d4a
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-import-assertions@npm:^7.20.0":
+  version: 7.20.0
+  resolution: "@babel/plugin-syntax-import-assertions@npm:7.20.0"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.19.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 6a86220e0aae40164cd3ffaf80e7c076a1be02a8f3480455dddbae05fda8140f429290027604df7a11b3f3f124866e8a6d69dbfa1dda61ee7377b920ad144d5b
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-import-meta@npm:^7.8.3":
+  version: 7.10.4
+  resolution: "@babel/plugin-syntax-import-meta@npm:7.10.4"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.10.4
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 166ac1125d10b9c0c430e4156249a13858c0366d38844883d75d27389621ebe651115cb2ceb6dc011534d5055719fa1727b59f39e1ab3ca97820eef3dcab5b9b
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-json-strings@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-json-strings@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: bf5aea1f3188c9a507e16efe030efb996853ca3cadd6512c51db7233cc58f3ac89ff8c6bdfb01d30843b161cfe7d321e1bf28da82f7ab8d7e6bc5464666f354a
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-jsx@npm:^7.17.12, @babel/plugin-syntax-jsx@npm:^7.18.6, @babel/plugin-syntax-jsx@npm:^7.7.2":
+  version: 7.18.6
+  resolution: "@babel/plugin-syntax-jsx@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 6d37ea972970195f1ffe1a54745ce2ae456e0ac6145fae9aa1480f297248b262ea6ebb93010eddb86ebfacb94f57c05a1fc5d232b9a67325b09060299d515c67
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-logical-assignment-operators@npm:^7.10.4, @babel/plugin-syntax-logical-assignment-operators@npm:^7.8.3":
+  version: 7.10.4
+  resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.10.4
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: aff33577037e34e515911255cdbb1fd39efee33658aa00b8a5fd3a4b903585112d037cce1cc9e4632f0487dc554486106b79ccd5ea63a2e00df4363f6d4ff886
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-nullish-coalescing-operator@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-nullish-coalescing-operator@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 87aca4918916020d1fedba54c0e232de408df2644a425d153be368313fdde40d96088feed6c4e5ab72aac89be5d07fef2ddf329a15109c5eb65df006bf2580d1
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-numeric-separator@npm:^7.10.4, @babel/plugin-syntax-numeric-separator@npm:^7.8.3":
+  version: 7.10.4
+  resolution: "@babel/plugin-syntax-numeric-separator@npm:7.10.4"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.10.4
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 01ec5547bd0497f76cc903ff4d6b02abc8c05f301c88d2622b6d834e33a5651aa7c7a3d80d8d57656a4588f7276eba357f6b7e006482f5b564b7a6488de493a1
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-object-rest-spread@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-object-rest-spread@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: fddcf581a57f77e80eb6b981b10658421bc321ba5f0a5b754118c6a92a5448f12a0c336f77b8abf734841e102e5126d69110a306eadb03ca3e1547cab31f5cbf
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-optional-catch-binding@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-optional-catch-binding@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 910d90e72bc90ea1ce698e89c1027fed8845212d5ab588e35ef91f13b93143845f94e2539d831dc8d8ededc14ec02f04f7bd6a8179edd43a326c784e7ed7f0b9
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-optional-chaining@npm:^7.8.3":
+  version: 7.8.3
+  resolution: "@babel/plugin-syntax-optional-chaining@npm:7.8.3"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.8.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: eef94d53a1453361553c1f98b68d17782861a04a392840341bc91780838dd4e695209c783631cf0de14c635758beafb6a3a65399846ffa4386bff90639347f30
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-private-property-in-object@npm:^7.14.5":
+  version: 7.14.5
+  resolution: "@babel/plugin-syntax-private-property-in-object@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: b317174783e6e96029b743ccff2a67d63d38756876e7e5d0ba53a322e38d9ca452c13354a57de1ad476b4c066dbae699e0ca157441da611117a47af88985ecda
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-top-level-await@npm:^7.14.5, @babel/plugin-syntax-top-level-await@npm:^7.8.3":
+  version: 7.14.5
+  resolution: "@babel/plugin-syntax-top-level-await@npm:7.14.5"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.14.5
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: bbd1a56b095be7820029b209677b194db9b1d26691fe999856462e66b25b281f031f3dfd91b1619e9dcf95bebe336211833b854d0fb8780d618e35667c2d0d7e
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-typescript@npm:^7.20.0":
+  version: 7.20.0
+  resolution: "@babel/plugin-syntax-typescript@npm:7.20.0"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.19.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 6189c0b5c32ba3c9a80a42338bd50719d783b20ef29b853d4f03929e971913d3cefd80184e924ae98ad6db09080be8fe6f1ffde9a6db8972523234f0274d36f7
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-syntax-typescript@npm:^7.7.2":
+  version: 7.16.7
+  resolution: "@babel/plugin-syntax-typescript@npm:7.16.7"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.16.7
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 661e636060609ede9a402e22603b01784c21fabb0a637e65f561c8159351fe0130bbc11fdefe31902107885e3332fc34d95eb652ac61d3f61f2d61f5da20609e
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-arrow-functions@npm:^7.18.6":
+  version: 7.20.7
+  resolution: "@babel/plugin-transform-arrow-functions@npm:7.20.7"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.20.2
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: b43cabe3790c2de7710abe32df9a30005eddb2050dadd5d122c6872f679e5710e410f1b90c8f99a2aff7b614cccfecf30e7fd310236686f60d3ed43fd80b9847
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-async-to-generator@npm:^7.18.6":
+  version: 7.20.7
+  resolution: "@babel/plugin-transform-async-to-generator@npm:7.20.7"
+  dependencies:
+    "@babel/helper-module-imports": ^7.18.6
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/helper-remap-async-to-generator": ^7.18.9
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: fe9ee8a5471b4317c1b9ea92410ace8126b52a600d7cfbfe1920dcac6fb0fad647d2e08beb4fd03c630eb54430e6c72db11e283e3eddc49615c68abd39430904
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-block-scoped-functions@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-block-scoped-functions@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 0a0df61f94601e3666bf39f2cc26f5f7b22a94450fb93081edbed967bd752ce3f81d1227fefd3799f5ee2722171b5e28db61379234d1bb85b6ec689589f99d7e
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-block-scoping@npm:^7.20.2":
+  version: 7.21.0
+  resolution: "@babel/plugin-transform-block-scoping@npm:7.21.0"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.20.2
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 15aacaadbecf96b53a750db1be4990b0d89c7f5bc3e1794b63b49fb219638c1fd25d452d15566d7e5ddf5b5f4e1a0a0055c35c1c7aee323c7b114bf49f66f4b0
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-classes@npm:^7.20.2":
+  version: 7.21.0
+  resolution: "@babel/plugin-transform-classes@npm:7.21.0"
+  dependencies:
+    "@babel/helper-annotate-as-pure": ^7.18.6
+    "@babel/helper-compilation-targets": ^7.20.7
+    "@babel/helper-environment-visitor": ^7.18.9
+    "@babel/helper-function-name": ^7.21.0
+    "@babel/helper-optimise-call-expression": ^7.18.6
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/helper-replace-supers": ^7.20.7
+    "@babel/helper-split-export-declaration": ^7.18.6
+    globals: ^11.1.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 088ae152074bd0e90f64659169255bfe50393e637ec8765cb2a518848b11b0299e66b91003728fd0a41563a6fdc6b8d548ece698a314fd5447f5489c22e466b7
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-computed-properties@npm:^7.18.9":
+  version: 7.20.7
+  resolution: "@babel/plugin-transform-computed-properties@npm:7.20.7"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/template": ^7.20.7
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: be70e54bda8b469146459f429e5f2bd415023b87b2d5af8b10e48f465ffb02847a3ed162ca60378c004b82db848e4d62e90010d41ded7e7176b6d8d1c2911139
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-destructuring@npm:^7.20.2":
+  version: 7.20.7
+  resolution: "@babel/plugin-transform-destructuring@npm:7.20.7"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.20.2
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: bd8affdb142c77662037215e37128b2110a786c92a67e1f00b38223c438c1610bd84cbc0386e9cd3479245ea811c5ca6c9838f49be4729b592159a30ce79add2
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-dotall-regex@npm:^7.18.6, @babel/plugin-transform-dotall-regex@npm:^7.4.4":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-dotall-regex@npm:7.18.6"
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin": ^7.18.6
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: cbe5d7063eb8f8cca24cd4827bc97f5641166509e58781a5f8aa47fb3d2d786ce4506a30fca2e01f61f18792783a5cb5d96bf5434c3dd1ad0de8c9cc625a53da
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-duplicate-keys@npm:^7.18.9":
+  version: 7.18.9
+  resolution: "@babel/plugin-transform-duplicate-keys@npm:7.18.9"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.9
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 220bf4a9fec5c4d4a7b1de38810350260e8ea08481bf78332a464a21256a95f0df8cd56025f346238f09b04f8e86d4158fafc9f4af57abaef31637e3b58bd4fe
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-exponentiation-operator@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-exponentiation-operator@npm:7.18.6"
+  dependencies:
+    "@babel/helper-builder-binary-assignment-operator-visitor": ^7.18.6
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 7f70222f6829c82a36005508d34ddbe6fd0974ae190683a8670dd6ff08669aaf51fef2209d7403f9bd543cb2d12b18458016c99a6ed0332ccedb3ea127b01229
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-for-of@npm:^7.18.8":
+  version: 7.21.0
+  resolution: "@babel/plugin-transform-for-of@npm:7.21.0"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.20.2
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 2f3f86ca1fab2929fcda6a87e4303d5c635b5f96dc9a45fd4ca083308a3020c79ac33b9543eb4640ef2b79f3586a00ab2d002a7081adb9e9d7440dce30781034
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-function-name@npm:^7.18.9":
+  version: 7.18.9
+  resolution: "@babel/plugin-transform-function-name@npm:7.18.9"
+  dependencies:
+    "@babel/helper-compilation-targets": ^7.18.9
+    "@babel/helper-function-name": ^7.18.9
+    "@babel/helper-plugin-utils": ^7.18.9
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 62dd9c6cdc9714704efe15545e782ee52d74dc73916bf954b4d3bee088fb0ec9e3c8f52e751252433656c09f744b27b757fc06ed99bcde28e8a21600a1d8e597
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-literals@npm:^7.18.9":
+  version: 7.18.9
+  resolution: "@babel/plugin-transform-literals@npm:7.18.9"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.9
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 3458dd2f1a47ac51d9d607aa18f3d321cbfa8560a985199185bed5a906bb0c61ba85575d386460bac9aed43fdd98940041fae5a67dff286f6f967707cff489f8
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-member-expression-literals@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-member-expression-literals@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 35a3d04f6693bc6b298c05453d85ee6e41cc806538acb6928427e0e97ae06059f97d2f07d21495fcf5f70d3c13a242e2ecbd09d5c1fcb1b1a73ff528dcb0b695
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-modules-amd@npm:^7.19.6":
+  version: 7.20.11
+  resolution: "@babel/plugin-transform-modules-amd@npm:7.20.11"
+  dependencies:
+    "@babel/helper-module-transforms": ^7.20.11
+    "@babel/helper-plugin-utils": ^7.20.2
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 23665c1c20c8f11c89382b588fb9651c0756d130737a7625baeaadbd3b973bc5bfba1303bedffa8fb99db1e6d848afb01016e1df2b69b18303e946890c790001
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-modules-commonjs@npm:^7.19.6":
+  version: 7.21.2
+  resolution: "@babel/plugin-transform-modules-commonjs@npm:7.21.2"
+  dependencies:
+    "@babel/helper-module-transforms": ^7.21.2
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/helper-simple-access": ^7.20.2
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 65aa06e3e3792f39b99eb5f807034693ff0ecf80438580f7ae504f4c4448ef04147b1889ea5e6f60f3ad4a12ebbb57c6f1f979a249dadbd8d11fe22f4441918b
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-modules-systemjs@npm:^7.19.6":
+  version: 7.20.11
+  resolution: "@babel/plugin-transform-modules-systemjs@npm:7.20.11"
+  dependencies:
+    "@babel/helper-hoist-variables": ^7.18.6
+    "@babel/helper-module-transforms": ^7.20.11
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/helper-validator-identifier": ^7.19.1
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 4546c47587f88156d66c7eb7808e903cf4bb3f6ba6ac9bc8e3af2e29e92eb9f0b3f44d52043bfd24eb25fa7827fd7b6c8bfeac0cac7584e019b87e1ecbd0e673
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-modules-umd@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-modules-umd@npm:7.18.6"
+  dependencies:
+    "@babel/helper-module-transforms": ^7.18.6
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: c3b6796c6f4579f1ba5ab0cdcc73910c1e9c8e1e773c507c8bb4da33072b3ae5df73c6d68f9126dab6e99c24ea8571e1563f8710d7c421fac1cde1e434c20153
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-named-capturing-groups-regex@npm:^7.19.1":
+  version: 7.20.5
+  resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.20.5"
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin": ^7.20.5
+    "@babel/helper-plugin-utils": ^7.20.2
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: 528c95fb1087e212f17e1c6456df041b28a83c772b9c93d2e407c9d03b72182b0d9d126770c1d6e0b23aab052599ceaf25ed6a2c0627f4249be34a83f6fae853
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-new-target@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-new-target@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: bd780e14f46af55d0ae8503b3cb81ca86dcc73ed782f177e74f498fff934754f9e9911df1f8f3bd123777eed7c1c1af4d66abab87c8daae5403e7719a6b845d1
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-object-super@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-object-super@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+    "@babel/helper-replace-supers": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 0fcb04e15deea96ae047c21cb403607d49f06b23b4589055993365ebd7a7d7541334f06bf9642e90075e66efce6ebaf1eb0ef066fbbab802d21d714f1aac3aef
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-parameters@npm:^7.20.1, @babel/plugin-transform-parameters@npm:^7.20.7":
+  version: 7.20.7
+  resolution: "@babel/plugin-transform-parameters@npm:7.20.7"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.20.2
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 6ffe0dd9afb2d2b9bc247381aa2e95dd9997ff5568a0a11900528919a4e073ac68f46409431455badb8809644d47cff180045bc2b9700e3f36e3b23554978947
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-property-literals@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-property-literals@npm:7.18.6"
+  dependencies:
+    "@babel/helper-plugin-utils": ^7.18.6
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 1c16e64de554703f4b547541de2edda6c01346dd3031d4d29e881aa7733785cd26d53611a4ccf5353f4d3e69097bb0111c0a93ace9e683edd94fea28c4484144
+  languageName: node
+  linkType: hard
+
+"@babel/plugin-transform-react-jsx-self@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-react-jsx-self@npm:7.18.6"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.8.0
+    "@babel/helper-plugin-utils": ^7.18.6
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 7ed1c1d9b9e5b64ef028ea5e755c0be2d4e5e4e3d6cf7df757b9a8c4cfa4193d268176d0f1f7fbecdda6fe722885c7fda681f480f3741d8a2d26854736f05367
+  checksum: 7d24e29c63869bb23495c163a92678c1c3341ecf74db420a20c6d3db74cbf5000fe908943f6106494e7225c0168945c150e528162274fd8fc7721966ad26930a
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-bigint@npm:^7.8.3":
-  version: 7.8.3
-  resolution: "@babel/plugin-syntax-bigint@npm:7.8.3"
+"@babel/plugin-transform-react-jsx-source@npm:^7.19.6":
+  version: 7.19.6
+  resolution: "@babel/plugin-transform-react-jsx-source@npm:7.19.6"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.8.0
+    "@babel/helper-plugin-utils": ^7.19.0
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 3a10849d83e47aec50f367a9e56a6b22d662ddce643334b087f9828f4c3dd73bdc5909aaeabe123fed78515767f9ca43498a0e621c438d1cd2802d7fae3c9648
+  checksum: 1e9e29a4efc5b79840bd4f68e404f5ab7765ce48c7bd22f12f2b185f9c782c66933bdf54a1b21879e4e56e6b50b4e88aca82789ecb1f61123af6dfa9ab16c555
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-class-properties@npm:^7.8.3":
-  version: 7.12.13
-  resolution: "@babel/plugin-syntax-class-properties@npm:7.12.13"
+"@babel/plugin-transform-react-jsx@npm:7.20.13":
+  version: 7.20.13
+  resolution: "@babel/plugin-transform-react-jsx@npm:7.20.13"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.12.13
+    "@babel/helper-annotate-as-pure": ^7.18.6
+    "@babel/helper-module-imports": ^7.18.6
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/plugin-syntax-jsx": ^7.18.6
+    "@babel/types": ^7.20.7
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 24f34b196d6342f28d4bad303612d7ff566ab0a013ce89e775d98d6f832969462e7235f3e7eaf17678a533d4be0ba45d3ae34ab4e5a9dcbda5d98d49e5efa2fc
+  checksum: b1daaa9b093ab59f71572dde7ad05ed3490433a47de103fc866f60347da55fa7fe84cf9b4c9fa22917517d52f70ab5e05ec631bba1c348733c0d8ebbd7de8c68
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-import-meta@npm:^7.8.3":
-  version: 7.10.4
-  resolution: "@babel/plugin-syntax-import-meta@npm:7.10.4"
+"@babel/plugin-transform-regenerator@npm:^7.18.6":
+  version: 7.20.5
+  resolution: "@babel/plugin-transform-regenerator@npm:7.20.5"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.10.4
+    "@babel/helper-plugin-utils": ^7.20.2
+    regenerator-transform: ^0.15.1
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 166ac1125d10b9c0c430e4156249a13858c0366d38844883d75d27389621ebe651115cb2ceb6dc011534d5055719fa1727b59f39e1ab3ca97820eef3dcab5b9b
+  checksum: 13164861e71fb23d84c6270ef5330b03c54d5d661c2c7468f28e21c4f8598558ca0c8c3cb1d996219352946e849d270a61372bc93c8fbe9676e78e3ffd0dea07
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-json-strings@npm:^7.8.3":
-  version: 7.8.3
-  resolution: "@babel/plugin-syntax-json-strings@npm:7.8.3"
+"@babel/plugin-transform-reserved-words@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-reserved-words@npm:7.18.6"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.8.0
+    "@babel/helper-plugin-utils": ^7.18.6
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: bf5aea1f3188c9a507e16efe030efb996853ca3cadd6512c51db7233cc58f3ac89ff8c6bdfb01d30843b161cfe7d321e1bf28da82f7ab8d7e6bc5464666f354a
+  checksum: 0738cdc30abdae07c8ec4b233b30c31f68b3ff0eaa40eddb45ae607c066127f5fa99ddad3c0177d8e2832e3a7d3ad115775c62b431ebd6189c40a951b867a80c
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-jsx@npm:^7.17.12, @babel/plugin-syntax-jsx@npm:^7.18.6, @babel/plugin-syntax-jsx@npm:^7.7.2":
+"@babel/plugin-transform-shorthand-properties@npm:^7.18.6":
   version: 7.18.6
-  resolution: "@babel/plugin-syntax-jsx@npm:7.18.6"
+  resolution: "@babel/plugin-transform-shorthand-properties@npm:7.18.6"
   dependencies:
     "@babel/helper-plugin-utils": ^7.18.6
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 6d37ea972970195f1ffe1a54745ce2ae456e0ac6145fae9aa1480f297248b262ea6ebb93010eddb86ebfacb94f57c05a1fc5d232b9a67325b09060299d515c67
+  checksum: b8e4e8acc2700d1e0d7d5dbfd4fdfb935651913de6be36e6afb7e739d8f9ca539a5150075a0f9b79c88be25ddf45abb912fe7abf525f0b80f5b9d9860de685d7
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-logical-assignment-operators@npm:^7.8.3":
-  version: 7.10.4
-  resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4"
+"@babel/plugin-transform-spread@npm:^7.19.0":
+  version: 7.20.7
+  resolution: "@babel/plugin-transform-spread@npm:7.20.7"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.10.4
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/helper-skip-transparent-expression-wrappers": ^7.20.0
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: aff33577037e34e515911255cdbb1fd39efee33658aa00b8a5fd3a4b903585112d037cce1cc9e4632f0487dc554486106b79ccd5ea63a2e00df4363f6d4ff886
+  checksum: 8ea698a12da15718aac7489d4cde10beb8a3eea1f66167d11ab1e625033641e8b328157fd1a0b55dd6531933a160c01fc2e2e61132a385cece05f26429fd0cc2
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-nullish-coalescing-operator@npm:^7.8.3":
-  version: 7.8.3
-  resolution: "@babel/plugin-syntax-nullish-coalescing-operator@npm:7.8.3"
+"@babel/plugin-transform-sticky-regex@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-sticky-regex@npm:7.18.6"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.8.0
+    "@babel/helper-plugin-utils": ^7.18.6
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 87aca4918916020d1fedba54c0e232de408df2644a425d153be368313fdde40d96088feed6c4e5ab72aac89be5d07fef2ddf329a15109c5eb65df006bf2580d1
+  checksum: 68ea18884ae9723443ffa975eb736c8c0d751265859cd3955691253f7fee37d7a0f7efea96c8a062876af49a257a18ea0ed5fea0d95a7b3611ce40f7ee23aee3
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-numeric-separator@npm:^7.8.3":
-  version: 7.10.4
-  resolution: "@babel/plugin-syntax-numeric-separator@npm:7.10.4"
+"@babel/plugin-transform-template-literals@npm:^7.18.9":
+  version: 7.18.9
+  resolution: "@babel/plugin-transform-template-literals@npm:7.18.9"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.10.4
+    "@babel/helper-plugin-utils": ^7.18.9
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 01ec5547bd0497f76cc903ff4d6b02abc8c05f301c88d2622b6d834e33a5651aa7c7a3d80d8d57656a4588f7276eba357f6b7e006482f5b564b7a6488de493a1
+  checksum: 3d2fcd79b7c345917f69b92a85bdc3ddd68ce2c87dc70c7d61a8373546ccd1f5cb8adc8540b49dfba08e1b82bb7b3bbe23a19efdb2b9c994db2db42906ca9fb2
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-object-rest-spread@npm:^7.8.3":
-  version: 7.8.3
-  resolution: "@babel/plugin-syntax-object-rest-spread@npm:7.8.3"
+"@babel/plugin-transform-typeof-symbol@npm:^7.18.9":
+  version: 7.18.9
+  resolution: "@babel/plugin-transform-typeof-symbol@npm:7.18.9"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.8.0
+    "@babel/helper-plugin-utils": ^7.18.9
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: fddcf581a57f77e80eb6b981b10658421bc321ba5f0a5b754118c6a92a5448f12a0c336f77b8abf734841e102e5126d69110a306eadb03ca3e1547cab31f5cbf
+  checksum: e754e0d8b8a028c52e10c148088606e3f7a9942c57bd648fc0438e5b4868db73c386a5ed47ab6d6f0594aae29ee5ffc2ffc0f7ebee7fae560a066d6dea811cd4
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-optional-catch-binding@npm:^7.8.3":
-  version: 7.8.3
-  resolution: "@babel/plugin-syntax-optional-catch-binding@npm:7.8.3"
+"@babel/plugin-transform-typescript@npm:^7.18.6":
+  version: 7.21.0
+  resolution: "@babel/plugin-transform-typescript@npm:7.21.0"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.8.0
+    "@babel/helper-create-class-features-plugin": ^7.21.0
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/plugin-syntax-typescript": ^7.20.0
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 910d90e72bc90ea1ce698e89c1027fed8845212d5ab588e35ef91f13b93143845f94e2539d831dc8d8ededc14ec02f04f7bd6a8179edd43a326c784e7ed7f0b9
+  checksum: 091931118eb515738a4bc8245875f985fc9759d3f85cdf08ee641779b41520241b369404e2bb86fc81907ad827678fdb704e8e5a995352def5dd3051ea2cd870
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-optional-chaining@npm:^7.8.3":
-  version: 7.8.3
-  resolution: "@babel/plugin-syntax-optional-chaining@npm:7.8.3"
+"@babel/plugin-transform-unicode-escapes@npm:^7.18.10":
+  version: 7.18.10
+  resolution: "@babel/plugin-transform-unicode-escapes@npm:7.18.10"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.8.0
+    "@babel/helper-plugin-utils": ^7.18.9
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: eef94d53a1453361553c1f98b68d17782861a04a392840341bc91780838dd4e695209c783631cf0de14c635758beafb6a3a65399846ffa4386bff90639347f30
+  checksum: f5baca55cb3c11bc08ec589f5f522d85c1ab509b4d11492437e45027d64ae0b22f0907bd1381e8d7f2a436384bb1f9ad89d19277314242c5c2671a0f91d0f9cd
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-top-level-await@npm:^7.8.3":
-  version: 7.14.5
-  resolution: "@babel/plugin-syntax-top-level-await@npm:7.14.5"
+"@babel/plugin-transform-unicode-regex@npm:^7.18.6":
+  version: 7.18.6
+  resolution: "@babel/plugin-transform-unicode-regex@npm:7.18.6"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.14.5
+    "@babel/helper-create-regexp-features-plugin": ^7.18.6
+    "@babel/helper-plugin-utils": ^7.18.6
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: bbd1a56b095be7820029b209677b194db9b1d26691fe999856462e66b25b281f031f3dfd91b1619e9dcf95bebe336211833b854d0fb8780d618e35667c2d0d7e
+  checksum: d9e18d57536a2d317fb0b7c04f8f55347f3cfacb75e636b4c6fa2080ab13a3542771b5120e726b598b815891fc606d1472ac02b749c69fd527b03847f22dc25e
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-typescript@npm:^7.7.2":
-  version: 7.16.7
-  resolution: "@babel/plugin-syntax-typescript@npm:7.16.7"
+"@babel/preset-env@npm:7.20.2":
+  version: 7.20.2
+  resolution: "@babel/preset-env@npm:7.20.2"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.16.7
+    "@babel/compat-data": ^7.20.1
+    "@babel/helper-compilation-targets": ^7.20.0
+    "@babel/helper-plugin-utils": ^7.20.2
+    "@babel/helper-validator-option": ^7.18.6
+    "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": ^7.18.6
+    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": ^7.18.9
+    "@babel/plugin-proposal-async-generator-functions": ^7.20.1
+    "@babel/plugin-proposal-class-properties": ^7.18.6
+    "@babel/plugin-proposal-class-static-block": ^7.18.6
+    "@babel/plugin-proposal-dynamic-import": ^7.18.6
+    "@babel/plugin-proposal-export-namespace-from": ^7.18.9
+    "@babel/plugin-proposal-json-strings": ^7.18.6
+    "@babel/plugin-proposal-logical-assignment-operators": ^7.18.9
+    "@babel/plugin-proposal-nullish-coalescing-operator": ^7.18.6
+    "@babel/plugin-proposal-numeric-separator": ^7.18.6
+    "@babel/plugin-proposal-object-rest-spread": ^7.20.2
+    "@babel/plugin-proposal-optional-catch-binding": ^7.18.6
+    "@babel/plugin-proposal-optional-chaining": ^7.18.9
+    "@babel/plugin-proposal-private-methods": ^7.18.6
+    "@babel/plugin-proposal-private-property-in-object": ^7.18.6
+    "@babel/plugin-proposal-unicode-property-regex": ^7.18.6
+    "@babel/plugin-syntax-async-generators": ^7.8.4
+    "@babel/plugin-syntax-class-properties": ^7.12.13
+    "@babel/plugin-syntax-class-static-block": ^7.14.5
+    "@babel/plugin-syntax-dynamic-import": ^7.8.3
+    "@babel/plugin-syntax-export-namespace-from": ^7.8.3
+    "@babel/plugin-syntax-import-assertions": ^7.20.0
+    "@babel/plugin-syntax-json-strings": ^7.8.3
+    "@babel/plugin-syntax-logical-assignment-operators": ^7.10.4
+    "@babel/plugin-syntax-nullish-coalescing-operator": ^7.8.3
+    "@babel/plugin-syntax-numeric-separator": ^7.10.4
+    "@babel/plugin-syntax-object-rest-spread": ^7.8.3
+    "@babel/plugin-syntax-optional-catch-binding": ^7.8.3
+    "@babel/plugin-syntax-optional-chaining": ^7.8.3
+    "@babel/plugin-syntax-private-property-in-object": ^7.14.5
+    "@babel/plugin-syntax-top-level-await": ^7.14.5
+    "@babel/plugin-transform-arrow-functions": ^7.18.6
+    "@babel/plugin-transform-async-to-generator": ^7.18.6
+    "@babel/plugin-transform-block-scoped-functions": ^7.18.6
+    "@babel/plugin-transform-block-scoping": ^7.20.2
+    "@babel/plugin-transform-classes": ^7.20.2
+    "@babel/plugin-transform-computed-properties": ^7.18.9
+    "@babel/plugin-transform-destructuring": ^7.20.2
+    "@babel/plugin-transform-dotall-regex": ^7.18.6
+    "@babel/plugin-transform-duplicate-keys": ^7.18.9
+    "@babel/plugin-transform-exponentiation-operator": ^7.18.6
+    "@babel/plugin-transform-for-of": ^7.18.8
+    "@babel/plugin-transform-function-name": ^7.18.9
+    "@babel/plugin-transform-literals": ^7.18.9
+    "@babel/plugin-transform-member-expression-literals": ^7.18.6
+    "@babel/plugin-transform-modules-amd": ^7.19.6
+    "@babel/plugin-transform-modules-commonjs": ^7.19.6
+    "@babel/plugin-transform-modules-systemjs": ^7.19.6
+    "@babel/plugin-transform-modules-umd": ^7.18.6
+    "@babel/plugin-transform-named-capturing-groups-regex": ^7.19.1
+    "@babel/plugin-transform-new-target": ^7.18.6
+    "@babel/plugin-transform-object-super": ^7.18.6
+    "@babel/plugin-transform-parameters": ^7.20.1
+    "@babel/plugin-transform-property-literals": ^7.18.6
+    "@babel/plugin-transform-regenerator": ^7.18.6
+    "@babel/plugin-transform-reserved-words": ^7.18.6
+    "@babel/plugin-transform-shorthand-properties": ^7.18.6
+    "@babel/plugin-transform-spread": ^7.19.0
+    "@babel/plugin-transform-sticky-regex": ^7.18.6
+    "@babel/plugin-transform-template-literals": ^7.18.9
+    "@babel/plugin-transform-typeof-symbol": ^7.18.9
+    "@babel/plugin-transform-unicode-escapes": ^7.18.10
+    "@babel/plugin-transform-unicode-regex": ^7.18.6
+    "@babel/preset-modules": ^0.1.5
+    "@babel/types": ^7.20.2
+    babel-plugin-polyfill-corejs2: ^0.3.3
+    babel-plugin-polyfill-corejs3: ^0.6.0
+    babel-plugin-polyfill-regenerator: ^0.4.1
+    core-js-compat: ^3.25.1
+    semver: ^6.3.0
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 661e636060609ede9a402e22603b01784c21fabb0a637e65f561c8159351fe0130bbc11fdefe31902107885e3332fc34d95eb652ac61d3f61f2d61f5da20609e
+  checksum: ece2d7e9c7789db6116e962b8e1a55eb55c110c44c217f0c8f6ffea4ca234954e66557f7bd019b7affadf7fbb3a53ccc807e93fc935aacd48146234b73b6947e
   languageName: node
   linkType: hard
 
-"@babel/plugin-transform-react-jsx-self@npm:^7.18.6":
-  version: 7.18.6
-  resolution: "@babel/plugin-transform-react-jsx-self@npm:7.18.6"
+"@babel/preset-modules@npm:^0.1.5":
+  version: 0.1.5
+  resolution: "@babel/preset-modules@npm:0.1.5"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.18.6
+    "@babel/helper-plugin-utils": ^7.0.0
+    "@babel/plugin-proposal-unicode-property-regex": ^7.4.4
+    "@babel/plugin-transform-dotall-regex": ^7.4.4
+    "@babel/types": ^7.4.4
+    esutils: ^2.0.2
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 7d24e29c63869bb23495c163a92678c1c3341ecf74db420a20c6d3db74cbf5000fe908943f6106494e7225c0168945c150e528162274fd8fc7721966ad26930a
+  checksum: 8430e0e9e9d520b53e22e8c4c6a5a080a12b63af6eabe559c2310b187bd62ae113f3da82ba33e9d1d0f3230930ca702843aae9dd226dec51f7d7114dc1f51c10
   languageName: node
   linkType: hard
 
-"@babel/plugin-transform-react-jsx-source@npm:^7.19.6":
-  version: 7.19.6
-  resolution: "@babel/plugin-transform-react-jsx-source@npm:7.19.6"
+"@babel/preset-typescript@npm:7.18.6":
+  version: 7.18.6
+  resolution: "@babel/preset-typescript@npm:7.18.6"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.19.0
+    "@babel/helper-plugin-utils": ^7.18.6
+    "@babel/helper-validator-option": ^7.18.6
+    "@babel/plugin-transform-typescript": ^7.18.6
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 1e9e29a4efc5b79840bd4f68e404f5ab7765ce48c7bd22f12f2b185f9c782c66933bdf54a1b21879e4e56e6b50b4e88aca82789ecb1f61123af6dfa9ab16c555
+  checksum: 7fe0da5103eb72d3cf39cf3e138a794c8cdd19c0b38e3e101507eef519c46a87a0d6d0e8bc9e28a13ea2364001ebe7430b9d75758aab4c3c3a8db9a487b9dc7c
   languageName: node
   linkType: hard
 
-"@babel/plugin-transform-react-jsx@npm:7.20.13":
-  version: 7.20.13
-  resolution: "@babel/plugin-transform-react-jsx@npm:7.20.13"
-  dependencies:
-    "@babel/helper-annotate-as-pure": ^7.18.6
-    "@babel/helper-module-imports": ^7.18.6
-    "@babel/helper-plugin-utils": ^7.20.2
-    "@babel/plugin-syntax-jsx": ^7.18.6
-    "@babel/types": ^7.20.7
-  peerDependencies:
-    "@babel/core": ^7.0.0-0
-  checksum: b1daaa9b093ab59f71572dde7ad05ed3490433a47de103fc866f60347da55fa7fe84cf9b4c9fa22917517d52f70ab5e05ec631bba1c348733c0d8ebbd7de8c68
+"@babel/regjsgen@npm:^0.8.0":
+  version: 0.8.0
+  resolution: "@babel/regjsgen@npm:0.8.0"
+  checksum: 89c338fee774770e5a487382170711014d49a68eb281e74f2b5eac88f38300a4ad545516a7786a8dd5702e9cf009c94c2f582d200f077ac5decd74c56b973730
   languageName: node
   linkType: hard
 
@@ -871,6 +1848,26 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4":
+  version: 7.21.0
+  resolution: "@babel/runtime@npm:7.21.0"
+  dependencies:
+    regenerator-runtime: ^0.13.11
+  checksum: 7b33e25bfa9e0e1b9e8828bb61b2d32bdd46b41b07ba7cb43319ad08efc6fda8eb89445193e67d6541814627df0ca59122c0ea795e412b99c5183a0540d338ab
+  languageName: node
+  linkType: hard
+
+"@babel/template@npm:^7.14.5, @babel/template@npm:^7.20.7":
+  version: 7.20.7
+  resolution: "@babel/template@npm:7.20.7"
+  dependencies:
+    "@babel/code-frame": ^7.18.6
+    "@babel/parser": ^7.20.7
+    "@babel/types": ^7.20.7
+  checksum: 2eb1a0ab8d415078776bceb3473d07ab746e6bb4c2f6ca46ee70efb284d75c4a32bb0cd6f4f4946dec9711f9c0780e8e5d64b743208deac6f8e9858afadc349e
+  languageName: node
+  linkType: hard
+
 "@babel/template@npm:^7.16.7":
   version: 7.16.7
   resolution: "@babel/template@npm:7.16.7"
@@ -893,17 +1890,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/template@npm:^7.20.7":
-  version: 7.20.7
-  resolution: "@babel/template@npm:7.20.7"
-  dependencies:
-    "@babel/code-frame": ^7.18.6
-    "@babel/parser": ^7.20.7
-    "@babel/types": ^7.20.7
-  checksum: 2eb1a0ab8d415078776bceb3473d07ab746e6bb4c2f6ca46ee70efb284d75c4a32bb0cd6f4f4946dec9711f9c0780e8e5d64b743208deac6f8e9858afadc349e
-  languageName: node
-  linkType: hard
-
 "@babel/template@npm:^7.3.3":
   version: 7.14.5
   resolution: "@babel/template@npm:7.14.5"
@@ -987,6 +1973,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/traverse@npm:^7.20.7, @babel/traverse@npm:^7.21.2":
+  version: 7.21.2
+  resolution: "@babel/traverse@npm:7.21.2"
+  dependencies:
+    "@babel/code-frame": ^7.18.6
+    "@babel/generator": ^7.21.1
+    "@babel/helper-environment-visitor": ^7.18.9
+    "@babel/helper-function-name": ^7.21.0
+    "@babel/helper-hoist-variables": ^7.18.6
+    "@babel/helper-split-export-declaration": ^7.18.6
+    "@babel/parser": ^7.21.2
+    "@babel/types": ^7.21.2
+    debug: ^4.1.0
+    globals: ^11.1.0
+  checksum: d851e3f5cfbdc2fac037a014eae7b0707709de50f7d2fbb82ffbf932d3eeba90a77431529371d6e544f8faaf8c6540eeb18fdd8d1c6fa2b61acea0fb47e18d4b
+  languageName: node
+  linkType: hard
+
 "@babel/types@npm:^7.0.0, @babel/types@npm:^7.3.0":
   version: 7.5.0
   resolution: "@babel/types@npm:7.5.0"
@@ -1039,6 +2043,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/types@npm:^7.18.9, @babel/types@npm:^7.20.0, @babel/types@npm:^7.21.0, @babel/types@npm:^7.21.2, @babel/types@npm:^7.4.4":
+  version: 7.21.2
+  resolution: "@babel/types@npm:7.21.2"
+  dependencies:
+    "@babel/helper-string-parser": ^7.19.4
+    "@babel/helper-validator-identifier": ^7.19.1
+    to-fast-properties: ^2.0.0
+  checksum: a45a52acde139e575502c6de42c994bdbe262bafcb92ae9381fb54cdf1a3672149086843fda655c7683ce9806e998fd002bbe878fa44984498d0fdc7935ce7ff
+  languageName: node
+  linkType: hard
+
 "@babel/types@npm:^7.20.7":
   version: 7.20.7
   resolution: "@babel/types@npm:7.20.7"
@@ -1079,6 +2094,25 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@emotion/babel-plugin@npm:11.10.6":
+  version: 11.10.6
+  resolution: "@emotion/babel-plugin@npm:11.10.6"
+  dependencies:
+    "@babel/helper-module-imports": ^7.16.7
+    "@babel/runtime": ^7.18.3
+    "@emotion/hash": ^0.9.0
+    "@emotion/memoize": ^0.8.0
+    "@emotion/serialize": ^1.1.1
+    babel-plugin-macros: ^3.1.0
+    convert-source-map: ^1.5.0
+    escape-string-regexp: ^4.0.0
+    find-root: ^1.1.0
+    source-map: ^0.5.7
+    stylis: 4.1.3
+  checksum: 3eed138932e8edf2598352e69ad949b9db3051a4d6fcff190dacbac9aa838d7ef708b9f3e6c48660625d9311dae82d73477ae4e7a31139feef5eb001a5528421
+  languageName: node
+  linkType: hard
+
 "@emotion/babel-plugin@npm:^11.10.5":
   version: 11.10.5
   resolution: "@emotion/babel-plugin@npm:11.10.5"
@@ -2100,7 +3134,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.15, @jridgewell/trace-mapping@npm:^0.3.9":
+"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.15, @jridgewell/trace-mapping@npm:^0.3.17, @jridgewell/trace-mapping@npm:^0.3.9":
   version: 0.3.17
   resolution: "@jridgewell/trace-mapping@npm:0.3.17"
   dependencies:
@@ -2236,6 +3270,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@primer/octicons-react@npm:17.11.1":
+  version: 17.11.1
+  resolution: "@primer/octicons-react@npm:17.11.1"
+  peerDependencies:
+    react: ">=15"
+  checksum: 21d99a0f4d86b27977a9845cca34c75d23cd3d057d3ab39e8b308bd45a35c4c8656a82c464d9b960e151dc05c0a2a40f2a9df5047f264da91b6bc7cbe0f70b33
+  languageName: node
+  linkType: hard
+
 "@remix-run/router@npm:1.3.0":
   version: 1.3.0
   resolution: "@remix-run/router@npm:1.3.0"
@@ -3405,6 +4448,7 @@ __metadata:
     "@emotion/jest": 11.10.5
     "@emotion/react": 11.10.5
     "@emotion/styled": 11.10.5
+    "@primer/octicons-react": 17.11.1
     "@swc/core": 1.3.28
     "@swc/jest": 0.2.24
     "@testing-library/dom": 8.20.0
@@ -3488,7 +4532,7 @@ __metadata:
     react-select-event: 5.5.1
     react-virtualized: 9.22.3
     regenerator-runtime: 0.13.11
-    tailwindcss: 3.2.6
+    tailwindcss: 2.2.19
     testing-library-selector: 0.2.1
     turbo: 1.7.4
     typescript: 4.9.4
@@ -3755,7 +4799,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"arg@npm:^5.0.2":
+"arg@npm:^5.0.1":
   version: 5.0.2
   resolution: "arg@npm:5.0.2"
   checksum: 6c69ada1a9943d332d9e5382393e897c500908d91d5cb735a01120d5f71daf1b339b7b8980cbeaba8fd1afc68e658a739746179e4315a26e8a28951ff9930078
@@ -3919,7 +4963,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"autoprefixer@npm:10.4.13":
+"autoprefixer@npm:10.4.13, autoprefixer@npm:^10.2.5":
   version: 10.4.13
   resolution: "autoprefixer@npm:10.4.13"
   dependencies:
@@ -4002,6 +5046,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"babel-plugin-macros@npm:^2.8.0":
+  version: 2.8.0
+  resolution: "babel-plugin-macros@npm:2.8.0"
+  dependencies:
+    "@babel/runtime": ^7.7.2
+    cosmiconfig: ^6.0.0
+    resolve: ^1.12.0
+  checksum: 59b09a21cf3ae1e14186c1b021917d004b49b953824b24953a54c6502da79e8051d4ac31cfd4a0ae7f6ea5ddf1f7edd93df4895dd3c3982a5b2431859c2889ac
+  languageName: node
+  linkType: hard
+
 "babel-plugin-macros@npm:^3.1.0":
   version: 3.1.0
   resolution: "babel-plugin-macros@npm:3.1.0"
@@ -4013,6 +5068,42 @@ __metadata:
   languageName: node
   linkType: hard
 
+"babel-plugin-polyfill-corejs2@npm:^0.3.3":
+  version: 0.3.3
+  resolution: "babel-plugin-polyfill-corejs2@npm:0.3.3"
+  dependencies:
+    "@babel/compat-data": ^7.17.7
+    "@babel/helper-define-polyfill-provider": ^0.3.3
+    semver: ^6.1.1
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 7db3044993f3dddb3cc3d407bc82e640964a3bfe22de05d90e1f8f7a5cb71460011ab136d3c03c6c1ba428359ebf635688cd6205e28d0469bba221985f5c6179
+  languageName: node
+  linkType: hard
+
+"babel-plugin-polyfill-corejs3@npm:^0.6.0":
+  version: 0.6.0
+  resolution: "babel-plugin-polyfill-corejs3@npm:0.6.0"
+  dependencies:
+    "@babel/helper-define-polyfill-provider": ^0.3.3
+    core-js-compat: ^3.25.1
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: 470bb8c59f7c0912bd77fe1b5a2e72f349b3f65bbdee1d60d6eb7e1f4a085c6f24b2dd5ab4ac6c2df6444a96b070ef6790eccc9edb6a2668c60d33133bfb62c6
+  languageName: node
+  linkType: hard
+
+"babel-plugin-polyfill-regenerator@npm:^0.4.1":
+  version: 0.4.1
+  resolution: "babel-plugin-polyfill-regenerator@npm:0.4.1"
+  dependencies:
+    "@babel/helper-define-polyfill-provider": ^0.3.3
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  checksum: ab0355efbad17d29492503230387679dfb780b63b25408990d2e4cf421012dae61d6199ddc309f4d2409ce4e9d3002d187702700dd8f4f8770ebbba651ed066c
+  languageName: node
+  linkType: hard
+
 "babel-preset-current-node-syntax@npm:^1.0.0":
   version: 1.0.1
   resolution: "babel-preset-current-node-syntax@npm:1.0.1"
@@ -4087,7 +5178,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"braces@npm:^3.0.1, braces@npm:^3.0.2, braces@npm:~3.0.2":
+"braces@npm:^3.0.1, braces@npm:~3.0.2":
   version: 3.0.2
   resolution: "braces@npm:3.0.2"
   dependencies:
@@ -4125,6 +5216,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"browserslist@npm:^4.21.5":
+  version: 4.21.5
+  resolution: "browserslist@npm:4.21.5"
+  dependencies:
+    caniuse-lite: ^1.0.30001449
+    electron-to-chromium: ^1.4.284
+    node-releases: ^2.0.8
+    update-browserslist-db: ^1.0.10
+  bin:
+    browserslist: cli.js
+  checksum: 9755986b22e73a6a1497fd8797aedd88e04270be33ce66ed5d85a1c8a798292a65e222b0f251bafa1c2522261e237d73b08b58689d4920a607e5a53d56dc4706
+  languageName: node
+  linkType: hard
+
 "bser@npm:^2.0.0":
   version: 2.1.0
   resolution: "bser@npm:2.1.0"
@@ -4141,6 +5246,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"bytes@npm:^3.0.0":
+  version: 3.1.2
+  resolution: "bytes@npm:3.1.2"
+  checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e
+  languageName: node
+  linkType: hard
+
 "cacache@npm:^15.0.5":
   version: 15.3.0
   resolution: "cacache@npm:15.3.0"
@@ -4226,7 +5338,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"chalk@npm:4.1.2, chalk@npm:^4.1.0":
+"caniuse-lite@npm:^1.0.30001449":
+  version: 1.0.30001457
+  resolution: "caniuse-lite@npm:1.0.30001457"
+  checksum: f311a7c5098681962402a86a0a367014ee91c3135395ee68bbfaf45caf0e36d581e42d7c5b1526ce99484a228e6cf5cf0e400678292c65f5a21512a3fc7a5fb6
+  languageName: node
+  linkType: hard
+
+"chalk@npm:4.1.2, chalk@npm:^4.1.0, chalk@npm:^4.1.2":
   version: 4.1.2
   resolution: "chalk@npm:4.1.2"
   dependencies:
@@ -4288,7 +5407,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"chokidar@npm:^3.5.3":
+"chokidar@npm:^3.5.2":
   version: 3.5.3
   resolution: "chokidar@npm:3.5.3"
   dependencies:
@@ -4342,6 +5461,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"clean-set@npm:^1.1.1":
+  version: 1.1.2
+  resolution: "clean-set@npm:1.1.2"
+  checksum: 1bd86cc20a1f5834b2e081f96b4336c4619b8eb842392b496b748d63e1ee58aa1959aa13cd392e96988600e105b9067d29082a13ae80f3b92d55c0474c0fda08
+  languageName: node
+  linkType: hard
+
 "clean-stack@npm:^2.0.0":
   version: 2.2.0
   resolution: "clean-stack@npm:2.2.0"
@@ -4406,7 +5532,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"color-convert@npm:^1.9.0":
+"color-convert@npm:^1.9.0, color-convert@npm:^1.9.3":
   version: 1.9.3
   resolution: "color-convert@npm:1.9.3"
   dependencies:
@@ -4431,13 +5557,43 @@ __metadata:
   languageName: node
   linkType: hard
 
-"color-name@npm:^1.1.4, color-name@npm:~1.1.4":
+"color-name@npm:^1.0.0, color-name@npm:~1.1.4":
   version: 1.1.4
   resolution: "color-name@npm:1.1.4"
   checksum: b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610
   languageName: node
   linkType: hard
 
+"color-string@npm:^1.6.0, color-string@npm:^1.9.0":
+  version: 1.9.1
+  resolution: "color-string@npm:1.9.1"
+  dependencies:
+    color-name: ^1.0.0
+    simple-swizzle: ^0.2.2
+  checksum: c13fe7cff7885f603f49105827d621ce87f4571d78ba28ef4a3f1a104304748f620615e6bf065ecd2145d0d9dad83a3553f52bb25ede7239d18e9f81622f1cc5
+  languageName: node
+  linkType: hard
+
+"color@npm:^3.1.3":
+  version: 3.2.1
+  resolution: "color@npm:3.2.1"
+  dependencies:
+    color-convert: ^1.9.3
+    color-string: ^1.6.0
+  checksum: f81220e8b774d35865c2561be921f5652117638dcda7ca4029262046e37fc2444ac7bbfdd110cf1fd9c074a4ee5eda8f85944ffbdda26186b602dd9bb05f6400
+  languageName: node
+  linkType: hard
+
+"color@npm:^4.0.1":
+  version: 4.2.3
+  resolution: "color@npm:4.2.3"
+  dependencies:
+    color-convert: ^2.0.1
+    color-string: ^1.9.0
+  checksum: 0579629c02c631b426780038da929cca8e8d80a40158b09811a0112a107c62e10e4aad719843b791b1e658ab4e800558f2e87ca4522c8b32349d497ecb6adeb4
+  languageName: node
+  linkType: hard
+
 "colors@npm:~1.2.1":
   version: 1.2.5
   resolution: "colors@npm:1.2.5"
@@ -4461,6 +5617,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"commander@npm:^8.0.0":
+  version: 8.3.0
+  resolution: "commander@npm:8.3.0"
+  checksum: 0f82321821fc27b83bd409510bb9deeebcfa799ff0bf5d102128b500b7af22872c0c92cb6a0ebc5a4cf19c6b550fba9cedfa7329d18c6442a625f851377bacf0
+  languageName: node
+  linkType: hard
+
 "commander@npm:^9.4.1":
   version: 9.5.0
   resolution: "commander@npm:9.5.0"
@@ -4507,6 +5670,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"core-js-compat@npm:^3.25.1":
+  version: 3.28.0
+  resolution: "core-js-compat@npm:3.28.0"
+  dependencies:
+    browserslist: ^4.21.5
+  checksum: 41d1d58c99ce7ee7abd8cf070f4c07a8f2655dbed1777d90a26246dddd7fac68315d53d2192584c8621a5328e6fe1a10da39b6bf2666e90fd5c2ff3b8f24e874
+  languageName: node
+  linkType: hard
+
 "core-js@npm:3.27.2":
   version: 3.27.2
   resolution: "core-js@npm:3.27.2"
@@ -4521,6 +5693,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"cosmiconfig@npm:^6.0.0":
+  version: 6.0.0
+  resolution: "cosmiconfig@npm:6.0.0"
+  dependencies:
+    "@types/parse-json": ^4.0.0
+    import-fresh: ^3.1.0
+    parse-json: ^5.0.0
+    path-type: ^4.0.0
+    yaml: ^1.7.2
+  checksum: 8eed7c854b91643ecb820767d0deb038b50780ecc3d53b0b19e03ed8aabed4ae77271198d1ae3d49c3b110867edf679f5faad924820a8d1774144a87cb6f98fc
+  languageName: node
+  linkType: hard
+
 "cosmiconfig@npm:^7.0.0":
   version: 7.0.1
   resolution: "cosmiconfig@npm:7.0.1"
@@ -4534,6 +5719,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"cosmiconfig@npm:^7.0.1":
+  version: 7.1.0
+  resolution: "cosmiconfig@npm:7.1.0"
+  dependencies:
+    "@types/parse-json": ^4.0.0
+    import-fresh: ^3.2.1
+    parse-json: ^5.0.0
+    path-type: ^4.0.0
+    yaml: ^1.10.0
+  checksum: c53bf7befc1591b2651a22414a5e786cd5f2eeaa87f3678a3d49d6069835a9d8d1aef223728e98aa8fec9a95bf831120d245096db12abe019fecb51f5696c96f
+  languageName: node
+  linkType: hard
+
 "cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3":
   version: 7.0.3
   resolution: "cross-spawn@npm:7.0.3"
@@ -4545,6 +5743,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"css-color-names@npm:^0.0.4":
+  version: 0.0.4
+  resolution: "css-color-names@npm:0.0.4"
+  checksum: 9c6106320430a9da3a13daab8d8b4def39113edbfb68042444585d9a214af5fd5cb384b9be45124bc75f88261d461b517e00e278f4d2e0ab5a619b182f9f0e2d
+  languageName: node
+  linkType: hard
+
 "css-select@npm:~1.2.0":
   version: 1.2.0
   resolution: "css-select@npm:1.2.0"
@@ -4557,6 +5762,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"css-unit-converter@npm:^1.1.1":
+  version: 1.1.2
+  resolution: "css-unit-converter@npm:1.1.2"
+  checksum: 07888033346a5128f34dbe2f72884c966d24e9f29db24416dcde92860242490617ef9a178ac193a92f730834bbeea026cdc7027701d92ba9bbbe59db7a37eb2a
+  languageName: node
+  linkType: hard
+
 "css-what@npm:2.1":
   version: 2.1.3
   resolution: "css-what@npm:2.1.3"
@@ -4979,22 +6191,44 @@ __metadata:
   dependencies:
     "@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
   languageName: unknown
   linkType: soft
 
@@ -5005,7 +6239,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"detective@npm:^5.2.1":
+"detective@npm:^5.2.0":
   version: 5.2.1
   resolution: "detective@npm:5.2.1"
   dependencies:
@@ -5166,6 +6400,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"dset@npm:^2.0.1":
+  version: 2.1.0
+  resolution: "dset@npm:2.1.0"
+  checksum: 9fdb325dde5cedb264e37a1bece8aeefb88023e9f2bbc9c38d656cd06b2322a050ba5b361e1c8047b84e482d6e50d0d6b10a6bf439afdd8ab131fd039c103483
+  languageName: node
+  linkType: hard
+
 "electron-to-chromium@npm:^1.4.17":
   version: 1.4.64
   resolution: "electron-to-chromium@npm:1.4.64"
@@ -5180,6 +6421,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"electron-to-chromium@npm:^1.4.284":
+  version: 1.4.308
+  resolution: "electron-to-chromium@npm:1.4.308"
+  checksum: 6e49a6c0e0ae2c4be3d5acd76ba6d497383a1ceb224cada6cdbba83aa95952336f96aa742cbf3697dd39d091624b53a912c0015fe5a30a8d6138e14287a0a9ad
+  languageName: node
+  linkType: hard
+
 "emittery@npm:^0.13.1":
   version: 0.13.1
   resolution: "emittery@npm:0.13.1"
@@ -6152,7 +7400,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"fast-glob@npm:^3.2.12":
+"fast-glob@npm:^3.2.12, fast-glob@npm:^3.2.7":
   version: 3.2.12
   resolution: "fast-glob@npm:3.2.12"
   dependencies:
@@ -6343,7 +7591,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"fs-extra@npm:^10.1.0":
+"fs-extra@npm:^10.0.0, fs-extra@npm:^10.1.0":
   version: 10.1.0
   resolution: "fs-extra@npm:10.1.0"
   dependencies:
@@ -6533,7 +7781,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"glob-parent@npm:^6.0.2":
+"glob-parent@npm:^6.0.1, glob-parent@npm:^6.0.2":
   version: 6.0.2
   resolution: "glob-parent@npm:6.0.2"
   dependencies:
@@ -6556,6 +7804,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"glob@npm:^7.1.7":
+  version: 7.2.3
+  resolution: "glob@npm:7.2.3"
+  dependencies:
+    fs.realpath: ^1.0.0
+    inflight: ^1.0.4
+    inherits: 2
+    minimatch: ^3.1.1
+    once: ^1.3.0
+    path-is-absolute: ^1.0.0
+  checksum: 29452e97b38fa704dabb1d1045350fb2467cf0277e155aa9ff7077e90ad81d1ea9d53d3ee63bd37c05b09a065e90f16aec4a65f5b8de401d1dac40bc5605d133
+  languageName: node
+  linkType: hard
+
 "globals@npm:^11.1.0":
   version: 11.12.0
   resolution: "globals@npm:11.12.0"
@@ -6722,6 +7984,22 @@ __metadata:
   languageName: node
   linkType: hard
 
+"hex-color-regex@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "hex-color-regex@npm:1.1.0"
+  checksum: 44fa1b7a26d745012f3bfeeab8015f60514f72d2fcf10dce33068352456b8d71a2e6bc5a17f933ab470da2c5ab1e3e04b05caf3fefe3c1cabd7e02e516fc8784
+  languageName: node
+  linkType: hard
+
+"history@npm:5.3.0":
+  version: 5.3.0
+  resolution: "history@npm:5.3.0"
+  dependencies:
+    "@babel/runtime": ^7.7.6
+  checksum: d73c35df49d19ac172f9547d30a21a26793e83f16a78386d99583b5bf1429cc980799fcf1827eb215d31816a6600684fba9686ce78104e23bd89ec239e7c726f
+  languageName: node
+  linkType: hard
+
 "hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.1, hoist-non-react-statics@npm:^3.3.2":
   version: 3.3.2
   resolution: "hoist-non-react-statics@npm:3.3.2"
@@ -6731,6 +8009,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"hsl-regex@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "hsl-regex@npm:1.0.0"
+  checksum: de9ee1bf39de1b83cc3fa0fa1cc337f29f14911e79411d66347365c54fab6b109eea2dd741eaa02486e24de31627ad7bf4453f22224fb55a2fe2b58166fa63b8
+  languageName: node
+  linkType: hard
+
+"hsla-regex@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "hsla-regex@npm:1.0.0"
+  checksum: 9aa6eb9ff6c102d2395435aa5d1d91eae20043c4b1497c543d8db501c05f3edacd9a07fb34a987059d7902dba415af4cb4e610f751859ae8e7525df4ffcd085f
+  languageName: node
+  linkType: hard
+
 "html-element-map@npm:^1.2.0":
   version: 1.2.0
   resolution: "html-element-map@npm:1.2.0"
@@ -6756,6 +8048,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"html-tags@npm:^3.1.0":
+  version: 3.2.0
+  resolution: "html-tags@npm:3.2.0"
+  checksum: a0c9e96ac26c84adad9cc66d15d6711a17f60acda8d987218f1d4cbaacd52864939b230e635cce5a1179f3ddab2a12b9231355617dfbae7945fcfec5e96d2041
+  languageName: node
+  linkType: hard
+
 "htmlparser2@npm:^3.9.1":
   version: 3.10.1
   resolution: "htmlparser2@npm:3.10.1"
@@ -6872,7 +8171,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"import-fresh@npm:^3.2.1":
+"import-fresh@npm:^3.1.0, import-fresh@npm:^3.2.1":
   version: 3.3.0
   resolution: "import-fresh@npm:3.3.0"
   dependencies:
@@ -7024,6 +8323,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-arrayish@npm:^0.3.1":
+  version: 0.3.2
+  resolution: "is-arrayish@npm:0.3.2"
+  checksum: 977e64f54d91c8f169b59afcd80ff19227e9f5c791fa28fa2e5bce355cbaf6c2c356711b734656e80c9dd4a854dd7efcf7894402f1031dfc5de5d620775b4d5f
+  languageName: node
+  linkType: hard
+
 "is-bigint@npm:^1.0.1":
   version: 1.0.4
   resolution: "is-bigint@npm:1.0.4"
@@ -7094,6 +8400,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-color-stop@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "is-color-stop@npm:1.1.0"
+  dependencies:
+    css-color-names: ^0.0.4
+    hex-color-regex: ^1.1.0
+    hsl-regex: ^1.0.0
+    hsla-regex: ^1.0.0
+    rgb-regex: ^1.0.1
+    rgba-regex: ^1.0.0
+  checksum: 778dd52a603ab8da827925aa4200fe6733b667b216495a04110f038b925dc5ef58babe759b94ffc4e44fcf439328695770873937f59d6045f676322b97f3f92d
+  languageName: node
+  linkType: hard
+
 "is-core-module@npm:^2.1.0, is-core-module@npm:^2.11.0":
   version: 2.11.0
   resolution: "is-core-module@npm:2.11.0"
@@ -8091,6 +9411,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"jsesc@npm:~0.5.0":
+  version: 0.5.0
+  resolution: "jsesc@npm:0.5.0"
+  bin:
+    jsesc: bin/jsesc
+  checksum: b8b44cbfc92f198ad972fba706ee6a1dfa7485321ee8c0b25f5cedd538dcb20cde3197de16a7265430fce8277a12db066219369e3d51055038946039f6e20e17
+  languageName: node
+  linkType: hard
+
 "json-parse-even-better-errors@npm:^2.3.0":
   version: 2.3.1
   resolution: "json-parse-even-better-errors@npm:2.3.1"
@@ -8268,7 +9597,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"lilconfig@npm:^2.0.5, lilconfig@npm:^2.0.6":
+"lilconfig@npm:^2.0.5":
   version: 2.0.6
   resolution: "lilconfig@npm:2.0.6"
   checksum: 40a3cd72f103b1be5975f2ac1850810b61d4053e20ab09be8d3aeddfe042187e1ba70b4651a7e70f95efa1642e7dc8b2ae395b317b7d7753b241b43cef7c0f7d
@@ -8307,6 +9636,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"lodash.debounce@npm:^4.0.8":
+  version: 4.0.8
+  resolution: "lodash.debounce@npm:4.0.8"
+  checksum: a3f527d22c548f43ae31c861ada88b2637eb48ac6aa3eb56e82d44917971b8aa96fbb37aa60efea674dc4ee8c42074f90f7b1f772e9db375435f6c83a19b3bc6
+  languageName: node
+  linkType: hard
+
 "lodash.escape@npm:^4.0.1":
   version: 4.0.1
   resolution: "lodash.escape@npm:4.0.1"
@@ -8314,6 +9650,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"lodash.flatmap@npm:^4.5.0":
+  version: 4.5.0
+  resolution: "lodash.flatmap@npm:4.5.0"
+  checksum: c01a47d32e99f8fce75409f0a4a9bd12fbb2d3a46519a0dde14deedb1e527b5ddccc2bf997705c67bdecb915f47749e8a9ffefa7a91c41f0c448e06348ec81c7
+  languageName: node
+  linkType: hard
+
 "lodash.flattendeep@npm:^4.4.0":
   version: 4.4.0
   resolution: "lodash.flattendeep@npm:4.4.0"
@@ -8342,6 +9685,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"lodash.topath@npm:^4.5.2":
+  version: 4.5.2
+  resolution: "lodash.topath@npm:4.5.2"
+  checksum: 04583e220f4bb1c4ac0008ff8f46d9cb4ddce0ea1090085790da30a41f4cb1b904d885cb73257fca619fa825cd96f9bb97c67d039635cb76056e18f5e08bfdee
+  languageName: node
+  linkType: hard
+
 "lodash@npm:4.17.21, lodash@npm:^4.15.0, lodash@npm:^4.17.11, lodash@npm:^4.17.13, lodash@npm:^4.17.15, lodash@npm:^4.17.21, lodash@npm:~4.17.15":
   version: 4.17.21
   resolution: "lodash@npm:4.17.21"
@@ -8403,6 +9753,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"magic-string@npm:^0.29.0":
+  version: 0.29.0
+  resolution: "magic-string@npm:0.29.0"
+  dependencies:
+    "@jridgewell/sourcemap-codec": ^1.4.13
+  checksum: 19e5398fcfc44804917127c72ad622c68a19a0a10cbdb8d4f9f9417584a087fe9e117140bfb2463d86743cf1ed9cf4182ae0b0ad1a7536f7fdda257ee4449ffb
+  languageName: node
+  linkType: hard
+
 "make-dir@npm:^3.0.0":
   version: 3.0.2
   resolution: "make-dir@npm:3.0.2"
@@ -8475,16 +9834,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"micromatch@npm:^4.0.5":
-  version: 4.0.5
-  resolution: "micromatch@npm:4.0.5"
-  dependencies:
-    braces: ^3.0.2
-    picomatch: ^2.3.1
-  checksum: 02a17b671c06e8fefeeb6ef996119c1e597c942e632a21ef589154f23898c9c6a9858526246abb14f8bca6e77734aa9dcf65476fca47cedfb80d9577d52843fc
-  languageName: node
-  linkType: hard
-
 "mime-db@npm:1.40.0":
   version: 1.40.0
   resolution: "mime-db@npm:1.40.0"
@@ -8524,7 +9873,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"minimatch@npm:^3.0.5, minimatch@npm:^3.1.2":
+"minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2":
   version: 3.1.2
   resolution: "minimatch@npm:3.1.2"
   dependencies:
@@ -8635,6 +9984,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"modern-normalize@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "modern-normalize@npm:1.1.0"
+  checksum: edfd40650bd7250eb4761651886a02ca3c524effca41b9832932eab9ccf9f2cfa7e5da8491c7c8bc2d58e1696e5e765adebeaf90cd9d3376444bd6bc0b0f2c99
+  languageName: node
+  linkType: hard
+
 "moo@npm:^0.4.3":
   version: 0.4.3
   resolution: "moo@npm:0.4.3"
@@ -8697,6 +10053,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"node-emoji@npm:^1.11.0":
+  version: 1.11.0
+  resolution: "node-emoji@npm:1.11.0"
+  dependencies:
+    lodash: ^4.17.21
+  checksum: e8c856c04a1645062112a72e59a98b203505ed5111ff84a3a5f40611afa229b578c7d50f1e6a7f17aa62baeea4a640d2e2f61f63afc05423aa267af10977fb2b
+  languageName: node
+  linkType: hard
+
 "node-gyp@npm:latest":
   version: 8.2.0
   resolution: "node-gyp@npm:8.2.0"
@@ -8738,6 +10103,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"node-releases@npm:^2.0.8":
+  version: 2.0.10
+  resolution: "node-releases@npm:2.0.10"
+  checksum: d784ecde25696a15d449c4433077f5cce620ed30a1656c4abf31282bfc691a70d9618bae6868d247a67914d1be5cc4fde22f65a05f4398cdfb92e0fc83cadfbc
+  languageName: node
+  linkType: hard
+
 "nopt@npm:^5.0.0":
   version: 5.0.0
   resolution: "nopt@npm:5.0.0"
@@ -8814,10 +10186,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"object-hash@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "object-hash@npm:3.0.0"
-  checksum: 80b4904bb3857c52cc1bfd0b52c0352532ca12ed3b8a6ff06a90cd209dfda1b95cee059a7625eb9da29537027f68ac4619363491eedb2f5d3dddbba97494fd6c
+"object-hash@npm:^2.2.0":
+  version: 2.2.0
+  resolution: "object-hash@npm:2.2.0"
+  checksum: 55ba841e3adce9c4f1b9b46b41983eda40f854e0d01af2802d3ae18a7085a17168d6b81731d43fdf1d6bcbb3c9f9c56d22c8fea992203ad90a38d7d919bc28f1
   languageName: node
   linkType: hard
 
@@ -9224,13 +10596,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"pify@npm:^2.3.0":
-  version: 2.3.0
-  resolution: "pify@npm:2.3.0"
-  checksum: 9503aaeaf4577acc58642ad1d25c45c6d90288596238fb68f82811c08104c800e5a7870398e9f015d82b44ecbcbef3dc3d4251a1cbb582f6e5959fe09884b2ba
-  languageName: node
-  linkType: hard
-
 "pirates@npm:^4.0.4":
   version: 4.0.5
   resolution: "pirates@npm:4.0.5"
@@ -9270,31 +10635,17 @@ __metadata:
   languageName: node
   linkType: hard
 
-"postcss-import@npm:^14.1.0":
-  version: 14.1.0
-  resolution: "postcss-import@npm:14.1.0"
-  dependencies:
-    postcss-value-parser: ^4.0.0
-    read-cache: ^1.0.0
-    resolve: ^1.1.7
-  peerDependencies:
-    postcss: ^8.0.0
-  checksum: cd45d406e90f67cdab9524352e573cc6b4462b790934a05954e929a6653ebd31288ceebc8ce3c3ed7117ae672d9ebbec57df0bceec0a56e9b259c2e71d47ca86
-  languageName: node
-  linkType: hard
-
-"postcss-js@npm:^4.0.0":
-  version: 4.0.1
-  resolution: "postcss-js@npm:4.0.1"
+"postcss-js@npm:^3.0.3":
+  version: 3.0.3
+  resolution: "postcss-js@npm:3.0.3"
   dependencies:
     camelcase-css: ^2.0.1
-  peerDependencies:
-    postcss: ^8.4.21
-  checksum: 5c1e83efeabeb5a42676193f4357aa9c88f4dc1b3c4a0332c132fe88932b33ea58848186db117cf473049fc233a980356f67db490bd0a7832ccba9d0b3fd3491
+    postcss: ^8.1.6
+  checksum: cc17f59f2b9bb22ed1cf9daab1f9944635b0713dce923ff7d9fd10b89393fc9aa1fab43a97f9a71295827fa32c9676d52661d7d6a693ecc0c41541ee928c781e
   languageName: node
   linkType: hard
 
-"postcss-load-config@npm:^3.1.4":
+"postcss-load-config@npm:^3.1.0":
   version: 3.1.4
   resolution: "postcss-load-config@npm:3.1.4"
   dependencies:
@@ -9312,18 +10663,18 @@ __metadata:
   languageName: node
   linkType: hard
 
-"postcss-nested@npm:6.0.0":
-  version: 6.0.0
-  resolution: "postcss-nested@npm:6.0.0"
+"postcss-nested@npm:5.0.6":
+  version: 5.0.6
+  resolution: "postcss-nested@npm:5.0.6"
   dependencies:
-    postcss-selector-parser: ^6.0.10
+    postcss-selector-parser: ^6.0.6
   peerDependencies:
     postcss: ^8.2.14
-  checksum: 2105dc52cd19747058f1a46862c9e454b5a365ac2e7135fc1015d67a8fe98ada2a8d9ee578e90f7a093bd55d3994dd913ba5ff1d5e945b4ed9a8a2992ecc8f10
+  checksum: dbcbfd11e514f485ac0d2b649b32bcbd855665a88a76f697f8be6c5017aa0260954ecccd2475bbd5865a5d248eae9a4e6e10d2d51927621d05430381aa37e43b
   languageName: node
   linkType: hard
 
-"postcss-selector-parser@npm:^6.0.10, postcss-selector-parser@npm:^6.0.11, postcss-selector-parser@npm:^6.0.9":
+"postcss-selector-parser@npm:^6.0.6, postcss-selector-parser@npm:^6.0.9":
   version: 6.0.11
   resolution: "postcss-selector-parser@npm:6.0.11"
   dependencies:
@@ -9333,14 +10684,21 @@ __metadata:
   languageName: node
   linkType: hard
 
-"postcss-value-parser@npm:^4.0.0, postcss-value-parser@npm:^4.2.0":
+"postcss-value-parser@npm:^3.3.0":
+  version: 3.3.1
+  resolution: "postcss-value-parser@npm:3.3.1"
+  checksum: 62cd26e1cdbcf2dcc6bcedf3d9b409c9027bc57a367ae20d31dd99da4e206f730689471fd70a2abe866332af83f54dc1fa444c589e2381bf7f8054c46209ce16
+  languageName: node
+  linkType: hard
+
+"postcss-value-parser@npm:^4.1.0, postcss-value-parser@npm:^4.2.0":
   version: 4.2.0
   resolution: "postcss-value-parser@npm:4.2.0"
   checksum: 819ffab0c9d51cf0acbabf8996dffbfafbafa57afc0e4c98db88b67f2094cb44488758f06e5da95d7036f19556a4a732525e84289a425f4f6fd8e412a9d7442f
   languageName: node
   linkType: hard
 
-"postcss@npm:8.4.21, postcss@npm:^8.0.9, postcss@npm:^8.4.21":
+"postcss@npm:8.4.21, postcss@npm:^8.1.6, postcss@npm:^8.1.8, postcss@npm:^8.3.5, postcss@npm:^8.4.21":
   version: 8.4.21
   resolution: "postcss@npm:8.4.21"
   dependencies:
@@ -9407,6 +10765,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"pretty-hrtime@npm:^1.0.3":
+  version: 1.0.3
+  resolution: "pretty-hrtime@npm:1.0.3"
+  checksum: bae0e6832fe13c3de43d1a3d43df52bf6090499d74dc65a17f5552cb1a94f1f8019a23284ddf988c3c408a09678d743901e1d8f5b7a71bec31eeeac445bef371
+  languageName: node
+  linkType: hard
+
 "process-nextick-args@npm:~2.0.0":
   version: 2.0.1
   resolution: "process-nextick-args@npm:2.0.1"
@@ -9488,6 +10853,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"purgecss@npm:^4.0.3":
+  version: 4.1.3
+  resolution: "purgecss@npm:4.1.3"
+  dependencies:
+    commander: ^8.0.0
+    glob: ^7.1.7
+    postcss: ^8.3.5
+    postcss-selector-parser: ^6.0.6
+  bin:
+    purgecss: bin/purgecss.js
+  checksum: 508613f904b130401f2a403d3383533f703c6bcd56e1254c1e8f57818a5337db3a667f66f48355f86271b20dd576691357752f460eb2edd94c095e4178391c5f
+  languageName: node
+  linkType: hard
+
 "querystringify@npm:^2.1.1":
   version: 2.2.0
   resolution: "querystringify@npm:2.2.0"
@@ -9793,15 +11172,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"read-cache@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "read-cache@npm:1.0.0"
-  dependencies:
-    pify: ^2.3.0
-  checksum: cffc728b9ede1e0667399903f9ecaf3789888b041c46ca53382fa3a06303e5132774dc0a96d0c16aa702dbac1ea0833d5a868d414f5ab2af1e1438e19e6657c6
-  languageName: node
-  linkType: hard
-
 "readable-stream@npm:^2.0.6":
   version: 2.3.6
   resolution: "readable-stream@npm:2.3.6"
@@ -9847,6 +11217,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"reduce-css-calc@npm:^2.1.8":
+  version: 2.1.8
+  resolution: "reduce-css-calc@npm:2.1.8"
+  dependencies:
+    css-unit-converter: ^1.1.1
+    postcss-value-parser: ^3.3.0
+  checksum: 8fd27c06c4b443b84749a69a8b97d10e6ec7d142b625b41923a8807abb22b9e37e44df14e26cc606a802957be07bdce5e8ee2976a6952a7b438a7727007101e9
+  languageName: node
+  linkType: hard
+
 "reflect.ownkeys@npm:^0.2.0":
   version: 0.2.0
   resolution: "reflect.ownkeys@npm:0.2.0"
@@ -9854,6 +11234,22 @@ __metadata:
   languageName: node
   linkType: hard
 
+"regenerate-unicode-properties@npm:^10.1.0":
+  version: 10.1.0
+  resolution: "regenerate-unicode-properties@npm:10.1.0"
+  dependencies:
+    regenerate: ^1.4.2
+  checksum: b1a8929588433ab8b9dc1a34cf3665b3b472f79f2af6ceae00d905fc496b332b9af09c6718fb28c730918f19a00dc1d7310adbaa9b72a2ec7ad2f435da8ace17
+  languageName: node
+  linkType: hard
+
+"regenerate@npm:^1.4.2":
+  version: 1.4.2
+  resolution: "regenerate@npm:1.4.2"
+  checksum: 3317a09b2f802da8db09aa276e469b57a6c0dd818347e05b8862959c6193408242f150db5de83c12c3fa99091ad95fb42a6db2c3329bfaa12a0ea4cbbeb30cb0
+  languageName: node
+  linkType: hard
+
 "regenerator-runtime@npm:0.13.11, regenerator-runtime@npm:^0.13.11":
   version: 0.13.11
   resolution: "regenerator-runtime@npm:0.13.11"
@@ -9875,6 +11271,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"regenerator-transform@npm:^0.15.1":
+  version: 0.15.1
+  resolution: "regenerator-transform@npm:0.15.1"
+  dependencies:
+    "@babel/runtime": ^7.8.4
+  checksum: 2d15bdeadbbfb1d12c93f5775493d85874dbe1d405bec323da5c61ec6e701bc9eea36167483e1a5e752de9b2df59ab9a2dfff6bf3784f2b28af2279a673d29a4
+  languageName: node
+  linkType: hard
+
 "regexp.prototype.flags@npm:^1.4.3":
   version: 1.4.3
   resolution: "regexp.prototype.flags@npm:1.4.3"
@@ -9893,6 +11298,31 @@ __metadata:
   languageName: node
   linkType: hard
 
+"regexpu-core@npm:^5.3.1":
+  version: 5.3.1
+  resolution: "regexpu-core@npm:5.3.1"
+  dependencies:
+    "@babel/regjsgen": ^0.8.0
+    regenerate: ^1.4.2
+    regenerate-unicode-properties: ^10.1.0
+    regjsparser: ^0.9.1
+    unicode-match-property-ecmascript: ^2.0.0
+    unicode-match-property-value-ecmascript: ^2.1.0
+  checksum: 446fbbb79059afcd64d11ea573276e2df97ee7ad45aa452834d3b2aef7edf7bfe206c310f57f9345d8c95bfedbf9c16a9529f9219a05ae6a6b0d6f0dbe523b33
+  languageName: node
+  linkType: hard
+
+"regjsparser@npm:^0.9.1":
+  version: 0.9.1
+  resolution: "regjsparser@npm:0.9.1"
+  dependencies:
+    jsesc: ~0.5.0
+  bin:
+    regjsparser: bin/parser
+  checksum: 5e1b76afe8f1d03c3beaf9e0d935dd467589c3625f6d65fb8ffa14f224d783a0fed4bf49c2c1b8211043ef92b6117313419edf055a098ed8342e340586741afc
+  languageName: node
+  linkType: hard
+
 "require-directory@npm:^2.1.1":
   version: 2.1.1
   resolution: "require-directory@npm:2.1.1"
@@ -9944,7 +11374,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"resolve@npm:^1.1.7, resolve@npm:^1.19.0, resolve@npm:^1.22.1, resolve@npm:~1.22.1":
+"resolve@npm:^1.12.0, resolve@npm:^1.14.2, resolve@npm:^1.19.0, resolve@npm:^1.22.1, resolve@npm:~1.22.1":
   version: 1.22.1
   resolution: "resolve@npm:1.22.1"
   dependencies:
@@ -9993,7 +11423,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"resolve@patch:resolve@^1.1.7#~builtin<compat/resolve>, resolve@patch:resolve@^1.19.0#~builtin<compat/resolve>, resolve@patch:resolve@^1.22.1#~builtin<compat/resolve>, resolve@patch:resolve@~1.22.1#~builtin<compat/resolve>":
+"resolve@patch:resolve@^1.12.0#~builtin<compat/resolve>, resolve@patch:resolve@^1.14.2#~builtin<compat/resolve>, resolve@patch:resolve@^1.19.0#~builtin<compat/resolve>, resolve@patch:resolve@^1.22.1#~builtin<compat/resolve>, resolve@patch:resolve@~1.22.1#~builtin<compat/resolve>":
   version: 1.22.1
   resolution: "resolve@patch:resolve@npm%3A1.22.1#~builtin<compat/resolve>::version=1.22.1&hash=c3c19d"
   dependencies:
@@ -10063,7 +11493,21 @@ __metadata:
   languageName: node
   linkType: hard
 
-"rimraf@npm:^3.0.2":
+"rgb-regex@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "rgb-regex@npm:1.0.1"
+  checksum: b270ce8bc14782d2d21d3184c1e6c65b465476d8f03e72b93ef57c95710a452b2fe280e1d516c88873aec06efd7f71373e673f114b9d99f3a4f9a0393eb00126
+  languageName: node
+  linkType: hard
+
+"rgba-regex@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "rgba-regex@npm:1.0.0"
+  checksum: 7f2cd271572700faea50753d82524cb2b98f17a5b9722965c7076f6cd674fe545f28145b7ef2cccabc9eca2475c793db16862cd5e7b3784a9f4b8d6496431057
+  languageName: node
+  linkType: hard
+
+"rimraf@npm:^3.0.0, rimraf@npm:^3.0.2":
   version: 3.0.2
   resolution: "rimraf@npm:3.0.2"
   dependencies:
@@ -10193,7 +11637,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"semver@npm:^6.3.0":
+"semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.3.0":
   version: 6.3.0
   resolution: "semver@npm:6.3.0"
   bin:
@@ -10279,6 +11723,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"simple-swizzle@npm:^0.2.2":
+  version: 0.2.2
+  resolution: "simple-swizzle@npm:0.2.2"
+  dependencies:
+    is-arrayish: ^0.3.1
+  checksum: a7f3f2ab5c76c4472d5c578df892e857323e452d9f392e1b5cf74b74db66e6294a1e1b8b390b519fa1b96b5b613f2a37db6cffef52c3f1f8f3c5ea64eb2d54c0
+  languageName: node
+  linkType: hard
+
 "sisteransi@npm:^1.0.0":
   version: 1.0.2
   resolution: "sisteransi@npm:1.0.2"
@@ -10419,6 +11872,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"string-similarity@npm:^4.0.3":
+  version: 4.0.4
+  resolution: "string-similarity@npm:4.0.4"
+  checksum: 797b41b24e1eb6b3b0ab896950b58c295a19a82933479c75f7b5279ffb63e0b456a8c8d10329c02f607ca1a50370e961e83d552aa468ff3b0fa15809abc9eff7
+  languageName: node
+  linkType: hard
+
 "string-width@npm:^1.0.1":
   version: 1.0.2
   resolution: "string-width@npm:1.0.2"
@@ -10739,39 +12199,49 @@ __metadata:
   languageName: node
   linkType: hard
 
-"tailwindcss@npm:3.2.6":
-  version: 3.2.6
-  resolution: "tailwindcss@npm:3.2.6"
+"tailwindcss@npm:2.2.19, tailwindcss@npm:^2.2.7":
+  version: 2.2.19
+  resolution: "tailwindcss@npm:2.2.19"
   dependencies:
-    arg: ^5.0.2
-    chokidar: ^3.5.3
-    color-name: ^1.1.4
-    detective: ^5.2.1
+    arg: ^5.0.1
+    bytes: ^3.0.0
+    chalk: ^4.1.2
+    chokidar: ^3.5.2
+    color: ^4.0.1
+    cosmiconfig: ^7.0.1
+    detective: ^5.2.0
     didyoumean: ^1.2.2
     dlv: ^1.1.3
-    fast-glob: ^3.2.12
-    glob-parent: ^6.0.2
-    is-glob: ^4.0.3
-    lilconfig: ^2.0.6
-    micromatch: ^4.0.5
+    fast-glob: ^3.2.7
+    fs-extra: ^10.0.0
+    glob-parent: ^6.0.1
+    html-tags: ^3.1.0
+    is-color-stop: ^1.1.0
+    is-glob: ^4.0.1
+    lodash: ^4.17.21
+    lodash.topath: ^4.5.2
+    modern-normalize: ^1.1.0
+    node-emoji: ^1.11.0
     normalize-path: ^3.0.0
-    object-hash: ^3.0.0
-    picocolors: ^1.0.0
-    postcss: ^8.0.9
-    postcss-import: ^14.1.0
-    postcss-js: ^4.0.0
-    postcss-load-config: ^3.1.4
-    postcss-nested: 6.0.0
-    postcss-selector-parser: ^6.0.11
-    postcss-value-parser: ^4.2.0
+    object-hash: ^2.2.0
+    postcss-js: ^3.0.3
+    postcss-load-config: ^3.1.0
+    postcss-nested: 5.0.6
+    postcss-selector-parser: ^6.0.6
+    postcss-value-parser: ^4.1.0
+    pretty-hrtime: ^1.0.3
+    purgecss: ^4.0.3
     quick-lru: ^5.1.1
-    resolve: ^1.22.1
+    reduce-css-calc: ^2.1.8
+    resolve: ^1.20.0
+    tmp: ^0.2.1
   peerDependencies:
+    autoprefixer: ^10.0.2
     postcss: ^8.0.9
   bin:
     tailwind: lib/cli.js
     tailwindcss: lib/cli.js
-  checksum: 908451ff7b334b2aec2a0ba5bf426a786a3f190b440a1f8ede206d889448ffda3b77349829f06dc297336fd0b5edc696ae5f23b808d6444bb5c689f218e95323
+  checksum: 660e8086fa2758f273b7ec87067c041185454374c5c916c236f9691b1c60c48166b2556b6327b3d912f018f48712105fa979b7f717b2db3111ea0850059a2b62
   languageName: node
   linkType: hard
 
@@ -10816,6 +12286,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"timsort@npm:^0.3.0":
+  version: 0.3.0
+  resolution: "timsort@npm:0.3.0"
+  checksum: 1a66cb897dacabd7dd7c91b7e2301498ca9e224de2edb9e42d19f5b17c4b6dc62a8d4cbc64f28be82aaf1541cb5a78ab49aa818f42a2989ebe049a64af731e2a
+  languageName: node
+  linkType: hard
+
 "tiny-emitter@npm:^2.0.0":
   version: 2.1.0
   resolution: "tiny-emitter@npm:2.1.0"
@@ -10830,6 +12307,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"tmp@npm:^0.2.1":
+  version: 0.2.1
+  resolution: "tmp@npm:0.2.1"
+  dependencies:
+    rimraf: ^3.0.0
+  checksum: 8b1214654182575124498c87ca986ac53dc76ff36e8f0e0b67139a8d221eaecfdec108c0e6ec54d76f49f1f72ab9325500b246f562b926f85bcdfca8bf35df9e
+  languageName: node
+  linkType: hard
+
 "tmpl@npm:1.0.5":
   version: 1.0.5
   resolution: "tmpl@npm:1.0.5"
@@ -10999,19 +12485,26 @@ __metadata:
   languageName: node
   linkType: hard
 
-"twin.macro@npm:3.1.0":
-  version: 3.1.0
-  resolution: "twin.macro@npm:3.1.0"
+"twin.macro@npm:2.8.2":
+  version: 2.8.2
+  resolution: "twin.macro@npm:2.8.2"
   dependencies:
-    "@babel/template": ^7.18.10
-    babel-plugin-macros: ^3.1.0
-    chalk: 4.1.2
+    "@babel/parser": ^7.12.5
+    "@babel/template": ^7.14.5
+    autoprefixer: ^10.2.5
+    babel-plugin-macros: ^2.8.0
+    chalk: ^4.1.0
+    clean-set: ^1.1.1
+    color: ^3.1.3
+    dset: ^2.0.1
+    lodash.flatmap: ^4.5.0
     lodash.get: ^4.4.2
     lodash.merge: ^4.6.2
-    postcss-selector-parser: ^6.0.10
-  peerDependencies:
-    tailwindcss: ^3.2.4
-  checksum: 716695be03456adb023dea016044d56fa7bc84d86654153b410748cdcd15b285471191251f8c2354d930499a42fd4d520336c882c81974f55962939f634f06e0
+    postcss: ^8.1.8
+    string-similarity: ^4.0.3
+    tailwindcss: ^2.2.7
+    timsort: ^0.3.0
+  checksum: d5af8fbac429e06968b61ce1f7cb2ced53f0d23d88fc680a42e743ddf47263b95db651c804424e7535f765d3ace3e347c2dc564f851f1f495383a83d176e8d05
   languageName: node
   linkType: hard
 
@@ -11118,6 +12611,37 @@ __metadata:
   languageName: node
   linkType: hard
 
+"unicode-canonical-property-names-ecmascript@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0"
+  checksum: 39be078afd014c14dcd957a7a46a60061bc37c4508ba146517f85f60361acf4c7539552645ece25de840e17e293baa5556268d091ca6762747fdd0c705001a45
+  languageName: node
+  linkType: hard
+
+"unicode-match-property-ecmascript@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "unicode-match-property-ecmascript@npm:2.0.0"
+  dependencies:
+    unicode-canonical-property-names-ecmascript: ^2.0.0
+    unicode-property-aliases-ecmascript: ^2.0.0
+  checksum: 1f34a7434a23df4885b5890ac36c5b2161a809887000be560f56ad4b11126d433c0c1c39baf1016bdabed4ec54829a6190ee37aa24919aa116dc1a5a8a62965a
+  languageName: node
+  linkType: hard
+
+"unicode-match-property-value-ecmascript@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "unicode-match-property-value-ecmascript@npm:2.1.0"
+  checksum: 8d6f5f586b9ce1ed0e84a37df6b42fdba1317a05b5df0c249962bd5da89528771e2d149837cad11aa26bcb84c35355cb9f58a10c3d41fa3b899181ece6c85220
+  languageName: node
+  linkType: hard
+
+"unicode-property-aliases-ecmascript@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "unicode-property-aliases-ecmascript@npm:2.1.0"
+  checksum: 243524431893649b62cc674d877bd64ef292d6071dd2fd01ab4d5ad26efbc104ffcd064f93f8a06b7e4ec54c172bf03f6417921a0d8c3a9994161fe1f88f815b
+  languageName: node
+  linkType: hard
+
 "unique-filename@npm:^1.1.1":
   version: 1.1.1
   resolution: "unique-filename@npm:1.1.1"
@@ -11157,7 +12681,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"update-browserslist-db@npm:^1.0.9":
+"update-browserslist-db@npm:^1.0.10, update-browserslist-db@npm:^1.0.9":
   version: 1.0.10
   resolution: "update-browserslist-db@npm:1.0.10"
   dependencies:
@@ -11243,10 +12767,11 @@ __metadata:
   languageName: node
   linkType: hard
 
-"vite-plugin-dts@npm:1.7.2":
-  version: 1.7.2
-  resolution: "vite-plugin-dts@npm:1.7.2"
+"vite-plugin-dts@npm:2.0.2":
+  version: 2.0.2
+  resolution: "vite-plugin-dts@npm:2.0.2"
   dependencies:
+    "@babel/parser": ^7.20.15
     "@microsoft/api-extractor": ^7.33.5
     "@rollup/pluginutils": ^5.0.2
     "@rushstack/node-core-library": ^3.53.2
@@ -11254,10 +12779,11 @@ __metadata:
     fast-glob: ^3.2.12
     fs-extra: ^10.1.0
     kolorist: ^1.6.0
+    magic-string: ^0.29.0
     ts-morph: 17.0.1
   peerDependencies:
     vite: ">=2.9.0"
-  checksum: 2445cc131481eddcac8fdff7feabf49018cabf745b0f1e955b45c9ce98313e5e7d9e9bb838af78577866404ed9070d915b4b6bc80c9f6eb91ec95608ce456f2d
+  checksum: d8bf9a8066d6db088f967379139bc54f78aef495a7827f0680a5c261e9b62b17cfc7b4f8cc4ad94cf3ef08b1aa4740040d471d6557641fbab2174ce4ad059430
   languageName: node
   linkType: hard
 
@@ -11531,7 +13057,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"yaml@npm:^1.10.0, yaml@npm:^1.10.2":
+"yaml@npm:^1.10.0, yaml@npm:^1.10.2, yaml@npm:^1.7.2":
   version: 1.10.2
   resolution: "yaml@npm:1.10.2"
   checksum: ce4ada136e8a78a0b08dc10b4b900936912d15de59905b2bf415b4d33c63df1d555d23acb2a41b23cf9fb5da41c256441afca3d6509de7247daa062fd2c5ea5f
index 5732248f08b78a904a34589e5e2226bb465d5bec..ff17baed98e94d3260038c4b20a1f663afc89f0a 100644 (file)
@@ -32,6 +32,7 @@ include 'server:sonar-main'
 include 'server:sonar-process'
 include 'server:sonar-server-common'
 include 'server:sonar-web'
+include 'server:sonar-web:design-system'
 include 'server:sonar-webserver'
 include 'server:sonar-webserver-api'
 include 'server:sonar-webserver-auth'
index 15bf247b56c3e2b98194620125eaa4d9dd47ce88..f8259c2f9684c1d4d5041fc47941db3fa00bcc7a 100644 (file)
@@ -509,6 +509,13 @@ event.definition_change.branch_added={project} {branch} added
 event.definition_change.branch_removed={project} {branch} removed
 event.definition_change.branch_replaced={project} {oldBranch} replaced with {newBranch}
 
+#------------------------------------------------------------------------------
+#
+# GLOBAL NAVIGATION
+#
+#------------------------------------------------------------------------------
+
+global_nav.account.tooltip=Account
 
 #------------------------------------------------------------------------------
 #
@@ -1413,6 +1420,7 @@ search.search_for_files=Search for files...
 search.search_for_modules=Search for modules...
 search.search_for_metrics=Search for metrics...
 
+global_search.shortcut_hint=Hint: Press 'S' from anywhere to open this search bar.
 
 #------------------------------------------------------------------------------
 #