]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22453 Use DropdownMenu from Echoes
authorDavid Cho-Lerat <david.cho-lerat@sonarsource.com>
Thu, 11 Jul 2024 12:50:44 +0000 (14:50 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 12 Jul 2024 20:02:41 +0000 (20:02 +0000)
17 files changed:
server/sonar-web/design-system/src/components/Dropdown.tsx
server/sonar-web/design-system/src/components/DropdownMenu.tsx
server/sonar-web/design-system/src/components/DropdownToggler.tsx
server/sonar-web/design-system/src/components/MainMenuItem.tsx
server/sonar-web/design-system/src/components/NavBarTabs.tsx
server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx
server/sonar-web/src/main/js/app/components/nav/component/__tests__/Menu-test.tsx
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMore.tsx
server/sonar-web/src/main/js/app/components/nav/settings/SettingsNav.tsx
server/sonar-web/src/main/js/apps/issues/components/IssueOpenInIdeButton.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx
server/sonar-web/src/main/js/components/embed-docs-modal/DocItemLink.tsx
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/helpers/keyboardEventHelpers.ts

index d79f7d011bccbac1e185ef859b828d678d88a725..ab273c08f0ce37b737990d2f8a72cc74affa7b67 100644 (file)
@@ -61,6 +61,9 @@ interface State {
   open: boolean;
 }
 
+/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide}
+ */
 export class Dropdown extends React.PureComponent<Readonly<Props>, State> {
   state: State = { open: false };
 
@@ -142,6 +145,9 @@ interface ActionsDropdownProps extends Omit<Props, 'children' | 'overlay'> {
   toggleClassName?: string;
 }
 
+/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide}
+ */
 export function ActionsDropdown(props: Readonly<ActionsDropdownProps>) {
   const { children, buttonSize, ariaLabel, toggleClassName, ...dropdownProps } = props;
 
index 335f28f434e4e3deb810903bada6f757d495d86c..c8027c1fc244babec70aa2e6c2479b7327cf23e9 100644 (file)
@@ -39,6 +39,9 @@ interface Props extends React.HtmlHTMLAttributes<HTMLMenuElement> {
   size?: InputSizeKeys;
 }
 
+/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide}
+ */
 export function DropdownMenu({
   children,
   className,
@@ -75,6 +78,9 @@ type ItemLinkProps = Omit<ListItemProps, 'innerRef'> &
     innerRef?: React.Ref<HTMLAnchorElement>;
   };
 
+/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide}
+ */
 export function ItemLink(props: ItemLinkProps) {
   const {
     children,
@@ -111,6 +117,9 @@ interface ItemNavLinkProps extends ItemLinkProps {
   end?: boolean;
 }
 
+/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide}
+ */
 export function ItemNavLink(props: ItemNavLinkProps) {
   const { children, className, disabled, end, icon, onClick, selected, innerRef, to, ...liProps } =
     props;
@@ -138,6 +147,9 @@ interface ItemButtonProps extends ListItemProps {
   onClick: React.MouseEventHandler<HTMLButtonElement>;
 }
 
+/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide}
+ */
 export const ItemButton = forwardRef(
   (props: ItemButtonProps, ref: ForwardedRef<HTMLButtonElement>) => {
     const { children, className, disabled, icon, innerRef, onClick, selected, ...liProps } = props;
@@ -258,6 +270,9 @@ export const ItemHeaderHighlight = styled.span`
   font-weight: 600;
 `;
 
+/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide}
+ */
 export const ItemHeader = styled.li`
   background-color: ${themeColor('dropdownMenuHeader')};
   color: ${themeContrast('dropdownMenuHeader')};
@@ -266,6 +281,9 @@ export const ItemHeader = styled.li`
 `;
 ItemHeader.defaultProps = { className: 'dropdown-menu-header', role: 'menuitem' };
 
+/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide}
+ */
 export const ItemDivider = styled.li`
   height: 1px;
   background-color: ${themeColor('popupBorder')};
index 10d959b71282f620f39b95d1008d700d316a3bb2..c41ea91ab8b3b6fc56601ed5983a3884af32217d 100644 (file)
@@ -31,6 +31,9 @@ interface Props extends PopupProps {
   withFocusOutHandler?: boolean;
 }
 
+/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead.
+ * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide}
+ */
 export function DropdownToggler(props: Props) {
   const {
     children,
index cef7db8ed9ed850a29a10c857c4768d2b26b753b..c2f33f554bfa887f8598f63d4913080e97a5c3c7 100644 (file)
@@ -49,10 +49,14 @@ export const MainMenuItem = styled.li`
     }
 
     &:hover,
-    &.hover,
-    &[aria-expanded='true'] {
+    &.hover {
       border-bottom: ${themeBorder('active', 'menuBorder', 1)};
       color: ${themeContrast('mainBarHover')};
     }
   }
+
+  &[aria-expanded='true'] a {
+    border-bottom: ${themeBorder('active', 'menuBorder', 1)};
+    color: ${themeContrast('mainBarHover')};
+  }
 `;
index 590cb913605785e2592c188455f9eeb9a4fa3778..7d2aecf4447a9ef541008c70d6a6679c788ef226 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 styled from '@emotion/styled';
 import classNames from 'classnames';
-import React from 'react';
+import React, { forwardRef } from 'react';
 import tw, { theme } from 'twin.macro';
 import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
 import { isDefined } from '../helpers/types';
@@ -48,29 +49,36 @@ interface NavBarTabLinkProps extends Omit<NavLinkProps, 'children'> {
   withChevron?: boolean;
 }
 
-export function NavBarTabLink(props: NavBarTabLinkProps) {
-  const { active, children, className, text, withChevron = false, ...linkProps } = props;
-  return (
-    <NavBarTabLinkWrapper>
-      <NavLink
-        className={({ isActive }) =>
-          classNames(
-            'sw-flex sw-items-center',
-            { active: isDefined(active) ? active : isActive },
-            className,
-          )
-        }
-        {...linkProps}
-      >
-        <span className="sw-inline-block sw-text-center" data-text={text}>
-          {text}
-        </span>
-        {children}
-        {withChevron && <ChevronDownIcon className="sw-ml-1" />}
-      </NavLink>
-    </NavBarTabLinkWrapper>
-  );
-}
+export const NavBarTabLink = forwardRef<HTMLAnchorElement, NavBarTabLinkProps>(
+  (props: NavBarTabLinkProps, ref) => {
+    const { active, children, className, text, withChevron = false, ...linkProps } = props;
+    return (
+      <NavBarTabLinkWrapper>
+        <NavLink
+          className={({ isActive }) =>
+            classNames(
+              'sw-flex sw-items-center',
+              { active: isDefined(active) ? active : isActive },
+              className,
+            )
+          }
+          ref={ref}
+          {...linkProps}
+        >
+          <span className="sw-inline-block sw-text-center" data-text={text}>
+            {text}
+          </span>
+
+          {children}
+
+          {withChevron && <ChevronDownIcon className="sw-ml-1" />}
+        </NavLink>
+      </NavBarTabLinkWrapper>
+    );
+  },
+);
+
+NavBarTabLink.displayName = 'NavBarTabLink';
 
 export function DisabledTabLink(props: { label: string; overlay: React.ReactNode }) {
   return (
@@ -102,11 +110,13 @@ const NavBarTabLinkWrapper = styled.li`
   & > a.active,
   & > a:active,
   & > a:hover,
-  & > a:focus {
+  & > a:focus,
+  & > a[aria-expanded='true'] {
     border-bottom-color: ${themeColor('tabBorder')};
   }
 
   & > a.active > span[data-text],
+  & > a[aria-expanded='true'] > span[data-text],
   & > a:active > span {
     ${tw`sw-body-md-highlight`};
   }
index bc8bd1e1bdea797bc24edc6775cb4cfa3da55c27..55cc488429d3ed48d47bfe94111e5e5bf2eb5fc7 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 {
-  DisabledTabLink,
-  Dropdown,
-  ItemNavLink,
-  NavBarTabLink,
-  NavBarTabs,
-  PopupZLevel,
-} from 'design-system';
+
+import { DropdownMenu } from '@sonarsource/echoes-react';
+import { DisabledTabLink, NavBarTabLink, NavBarTabs } from 'design-system';
 import * as React from 'react';
 import { useLocation } from '~sonar-aligned/components/hoc/withRouter';
 import { getBranchLikeQuery, isPullRequest } from '~sonar-aligned/helpers/branch-like';
@@ -245,33 +240,28 @@ export function Menu(props: Readonly<Props>) {
       isApplication(qualifier),
       isPortfolioLike(qualifier),
     );
+
     if (!adminLinks.some((link) => link != null)) {
       return null;
     }
 
     return (
-      <Dropdown
+      <DropdownMenu.Root
         data-test="administration"
         id="component-navigation-admin"
-        size="auto"
-        zLevel={PopupZLevel.Global}
-        overlay={adminLinks}
+        items={adminLinks}
       >
-        {({ onToggleClick, open, a11yAttrs }) => (
-          <NavBarTabLink
-            active={isSettingsActive || open}
-            onClick={onToggleClick}
-            text={
-              hasMessage('layout.settings', component.qualifier)
-                ? translate('layout.settings', component.qualifier)
-                : translate('layout.settings')
-            }
-            withChevron
-            to={{}}
-            {...a11yAttrs}
-          />
-        )}
-      </Dropdown>
+        <NavBarTabLink
+          active={isSettingsActive}
+          text={
+            hasMessage('layout.settings', component.qualifier)
+              ? translate('layout.settings', component.qualifier)
+              : translate('layout.settings')
+          }
+          withChevron
+          to={{}}
+        />
+      </DropdownMenu.Root>
     );
   };
 
@@ -325,12 +315,12 @@ export function Menu(props: Readonly<Props>) {
       return null;
     }
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="settings"
         to={{ pathname: '/project/settings', search: new URLSearchParams(query).toString() }}
       >
         {translate('project_settings.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -340,12 +330,12 @@ export function Menu(props: Readonly<Props>) {
     }
 
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="branches"
         to={{ pathname: '/project/branches', search: new URLSearchParams(query).toString() }}
       >
         {translate('project_branch_pull_request.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -354,12 +344,12 @@ export function Menu(props: Readonly<Props>) {
       return null;
     }
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="baseline"
         to={{ pathname: '/project/baseline', search: new URLSearchParams(query).toString() }}
       >
         {translate('project_baseline.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -368,7 +358,7 @@ export function Menu(props: Readonly<Props>) {
       return null;
     }
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="import-export"
         to={{
           pathname: '/project/import_export',
@@ -376,7 +366,7 @@ export function Menu(props: Readonly<Props>) {
         }}
       >
         {translate('project_dump.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -385,7 +375,7 @@ export function Menu(props: Readonly<Props>) {
       return null;
     }
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="profiles"
         to={{
           pathname: '/project/quality_profiles',
@@ -393,7 +383,7 @@ export function Menu(props: Readonly<Props>) {
         }}
       >
         {translate('project_quality_profiles.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -402,12 +392,12 @@ export function Menu(props: Readonly<Props>) {
       return null;
     }
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="quality_gate"
         to={{ pathname: '/project/quality_gate', search: new URLSearchParams(query).toString() }}
       >
         {translate('project_quality_gate.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -416,12 +406,12 @@ export function Menu(props: Readonly<Props>) {
       return null;
     }
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="links"
         to={{ pathname: '/project/links', search: new URLSearchParams(query).toString() }}
       >
         {translate('project_links.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -430,12 +420,12 @@ export function Menu(props: Readonly<Props>) {
       return null;
     }
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="permissions"
         to={{ pathname: '/project_roles', search: new URLSearchParams(query).toString() }}
       >
         {translate('permissions.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -444,7 +434,7 @@ export function Menu(props: Readonly<Props>) {
       return null;
     }
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="background_tasks"
         to={{
           pathname: '/project/background_tasks',
@@ -452,7 +442,7 @@ export function Menu(props: Readonly<Props>) {
         }}
       >
         {translate('background_tasks.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -461,12 +451,12 @@ export function Menu(props: Readonly<Props>) {
       return null;
     }
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="update_key"
         to={{ pathname: '/project/key', search: new URLSearchParams(query).toString() }}
       >
         {translate('update_key.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -475,12 +465,12 @@ export function Menu(props: Readonly<Props>) {
       return null;
     }
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="webhooks"
         to={{ pathname: '/project/webhooks', search: new URLSearchParams(query).toString() }}
       >
         {translate('webhooks.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -500,12 +490,12 @@ export function Menu(props: Readonly<Props>) {
     }
 
     return (
-      <ItemNavLink
+      <DropdownMenu.ItemLink
         key="project_delete"
         to={{ pathname: '/project/deletion', search: new URLSearchParams(query).toString() }}
       >
         {translate('deletion.page')}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -513,9 +503,12 @@ export function Menu(props: Readonly<Props>) {
     const pathname = isAdmin ? `/project/admin/extension/${key}` : `/project/extension/${key}`;
     const query = { ...baseQuery, qualifier };
     return (
-      <ItemNavLink key={key} to={{ pathname, search: new URLSearchParams(query).toString() }}>
+      <DropdownMenu.ItemLink
+        key={key}
+        to={{ pathname, search: new URLSearchParams(query).toString() }}
+      >
         {name}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -538,25 +531,13 @@ export function Menu(props: Readonly<Props>) {
     }
 
     return (
-      <Dropdown
+      <DropdownMenu.Root
         data-test="extensions"
         id="component-navigation-more"
-        size="auto"
-        zLevel={PopupZLevel.Global}
-        overlay={withoutSecurityExtension.map((e) => renderExtension(e, false, query))}
+        items={withoutSecurityExtension.map((e) => renderExtension(e, false, query))}
       >
-        {({ onToggleClick, open, a11yAttrs }) => (
-          <NavBarTabLink
-            active={open}
-            onClick={onToggleClick}
-            preventDefault
-            text={translate('more')}
-            withChevron
-            to={{}}
-            {...a11yAttrs}
-          />
-        )}
-      </Dropdown>
+        <NavBarTabLink preventDefault text={translate('more')} withChevron to={{}} />
+      </DropdownMenu.Root>
     );
   };
 
index 2d954bb0bf9e090e9f38954a744997df0b8a005a..a8b1519b245eaef0080193849b596ae194a35e8c 100644 (file)
@@ -17,7 +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 { screen } from '@testing-library/react';
+
+import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
 import { ComponentQualifier } from '~sonar-aligned/types/component';
@@ -62,7 +63,7 @@ it('should render correctly', async () => {
   expect(screen.getByRole('link', { name: 'layout.security_reports' })).toBeInTheDocument();
 
   // Check the dropdown.
-  const button = screen.getByRole('button', { name: 'more' });
+  const button = screen.getByRole('link', { name: 'more' });
   expect(button).toBeInTheDocument();
   await user.click(button);
   expect(screen.getByRole('menuitem', { name: 'ComponentFoo' })).toBeInTheDocument();
@@ -101,7 +102,7 @@ it('should render correctly when on a branch', async () => {
         extensions: [{ key: 'component-foo', name: 'ComponentFoo' }],
       },
     },
-    'branch=normal-branch',
+    'id=foo&branch=normal-branch',
   );
 
   expect(await screen.findByRole('link', { name: 'overview.page' })).toBeInTheDocument();
@@ -119,16 +120,19 @@ it('should render correctly when on a pull request', async () => {
         extensions: [{ key: 'component-foo', name: 'ComponentFoo' }],
       },
     },
-    'pullRequest=01',
+    'id=foo&pullRequest=01',
   );
 
   expect(await screen.findByRole('link', { name: 'overview.page' })).toBeInTheDocument();
   expect(screen.getByRole('link', { name: 'issues.page' })).toBeInTheDocument();
   expect(screen.getByRole('link', { name: 'layout.measures' })).toBeInTheDocument();
 
-  expect(
-    screen.queryByRole('link', { name: `layout.settings.${ComponentQualifier.Project}` }),
-  ).not.toBeInTheDocument();
+  await waitFor(() => {
+    expect(
+      screen.queryByRole('link', { name: `layout.settings.${ComponentQualifier.Project}` }),
+    ).not.toBeInTheDocument();
+  });
+
   expect(screen.queryByRole('button', { name: 'project.info.title' })).not.toBeInTheDocument();
 });
 
index eb91e17a2e86eabe9ce924b00c23f0263185b693..84de3342ce84e1bdbd7e2c27b6723e1600c4c0de 100644 (file)
@@ -17,7 +17,9 @@
  * 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, PopupZLevel } from 'design-system';
+
+import { DropdownMenu } from '@sonarsource/echoes-react';
+import { MainMenuItem } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../../../helpers/l10n';
 import { AppState } from '../../../../types/appstate';
@@ -26,13 +28,13 @@ import withAppStateContext from '../../app-state/withAppStateContext';
 
 const renderGlobalPageLink = ({ key, name }: Extension) => {
   return (
-    <ItemNavLink key={key} to={`/extension/${key}`}>
+    <DropdownMenu.ItemLink key={key} to={`/extension/${key}`}>
       {name}
-    </ItemNavLink>
+    </DropdownMenu.ItemLink>
   );
 };
 
-function GlobalNavMore({ appState: { globalPages = [] } }: { appState: AppState }) {
+function GlobalNavMore({ appState: { globalPages = [] } }: Readonly<{ appState: AppState }>) {
   const withoutPortfolios = globalPages.filter((page) => page.key !== 'governance/portfolios');
 
   if (withoutPortfolios.length === 0) {
@@ -40,29 +42,17 @@ function GlobalNavMore({ appState: { globalPages = [] } }: { appState: AppState
   }
 
   return (
-    <Dropdown
+    <DropdownMenu.Root
+      align="start"
       id="moreMenuDropdown"
-      overlay={<ul>{withoutPortfolios.map(renderGlobalPageLink)}</ul>}
-      placement={PopupPlacement.BottomLeft}
-      zLevel={PopupZLevel.Global}
+      items={withoutPortfolios.map(renderGlobalPageLink)}
     >
-      {({ 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>
+      <MainMenuItem>
+        <a aria-haspopup="menu" href="#" id="global-navigation-more" role="button">
+          {translate('more')}
+        </a>
+      </MainMenuItem>
+    </DropdownMenu.Root>
   );
 }
 
index 6b63fecf74f99a274ac9b8baffb8e62dffac9f7f..e562c6016c72fbd5c9e698dbdb9d3e4fc9f8a335 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,
-  ItemNavLink,
-  LightLabel,
-  NavBarTabLink,
-  NavBarTabs,
-  PopupZLevel,
-  TopBar,
-} from 'design-system';
+
+import { DropdownMenu } from '@sonarsource/echoes-react';
+import { LightLabel, NavBarTabLink, NavBarTabs, TopBar } from 'design-system';
 import * as React from 'react';
 import { Location } from 'react-router-dom';
 import withLocation from '../../../../components/hoc/withLocation';
@@ -88,9 +82,9 @@ export class SettingsNav extends React.PureComponent<Props> {
 
   renderExtension = ({ key, name }: Extension) => {
     return (
-      <ItemNavLink key={key} to={`/admin/extension/${key}`}>
+      <DropdownMenu.ItemLink isMatchingFullPath key={key} to={`/admin/extension/${key}`}>
         {name}
-      </ItemNavLink>
+      </DropdownMenu.ItemLink>
     );
   };
 
@@ -98,134 +92,125 @@ export class SettingsNav extends React.PureComponent<Props> {
     const extensionsWithoutSupport = this.props.extensions.filter(
       (extension) => extension.key !== 'license/support',
     );
+
     return (
-      <Dropdown
+      <DropdownMenu.Root
+        align="start"
         id="settings-navigation-configuration-dropdown"
-        overlay={
+        items={
           <>
-            <ItemNavLink end to="/admin/settings">
+            <DropdownMenu.ItemLink isMatchingFullPath to="/admin/settings">
               {translate('settings.page')}
-            </ItemNavLink>
+            </DropdownMenu.ItemLink>
 
-            <ItemNavLink end to="/admin/settings/encryption">
+            <DropdownMenu.ItemLink isMatchingFullPath to="/admin/settings/encryption">
               {translate('property.category.security.encryption')}
-            </ItemNavLink>
+            </DropdownMenu.ItemLink>
 
-            <ItemNavLink end to="/admin/webhooks">
+            <DropdownMenu.ItemLink isMatchingFullPath to="/admin/webhooks">
               {translate('webhooks.page')}
-            </ItemNavLink>
+            </DropdownMenu.ItemLink>
 
             {extensionsWithoutSupport.map(this.renderExtension)}
           </>
         }
-        size="auto"
-        zLevel={PopupZLevel.Global}
       >
-        {({ onToggleClick, open }) => (
-          <NavBarTabLink
-            aria-expanded={open}
-            aria-haspopup="menu"
-            active={
-              open ||
-              (!this.isSecurityActive() &&
-                !this.isProjectsActive() &&
-                !this.isSystemActive() &&
-                !this.isSomethingActive(['/admin/extension/license/support']) &&
-                !this.isMarketplace() &&
-                !this.isAudit())
-            }
-            to={{}}
-            id="settings-navigation-configuration"
-            onClick={onToggleClick}
-            text={translate('sidebar.project_settings')}
-            withChevron
-          />
-        )}
-      </Dropdown>
+        <NavBarTabLink
+          aria-haspopup="menu"
+          active={
+            !this.isSecurityActive() &&
+            !this.isProjectsActive() &&
+            !this.isSystemActive() &&
+            !this.isSomethingActive(['/admin/extension/license/support']) &&
+            !this.isMarketplace() &&
+            !this.isAudit()
+          }
+          id="settings-navigation-configuration"
+          text={translate('sidebar.project_settings')}
+          to={{}}
+          withChevron
+        />
+      </DropdownMenu.Root>
     );
   }
 
   renderProjectsTab() {
     return (
-      <Dropdown
+      <DropdownMenu.Root
         id="settings-navigation-projects-dropdown"
-        overlay={
+        items={
           <>
-            <ItemNavLink end to="/admin/projects_management">
+            <DropdownMenu.ItemLink isMatchingFullPath to="/admin/projects_management">
               {translate('management')}
-            </ItemNavLink>
+            </DropdownMenu.ItemLink>
 
-            <ItemNavLink end to="/admin/background_tasks">
+            <DropdownMenu.ItemLink isMatchingFullPath to="/admin/background_tasks">
               {translate('background_tasks.page')}
-            </ItemNavLink>
+            </DropdownMenu.ItemLink>
           </>
         }
-        size="auto"
-        zLevel={PopupZLevel.Global}
       >
-        {({ onToggleClick, open }) => (
-          <NavBarTabLink
-            aria-expanded={open}
-            aria-haspopup="menu"
-            active={open || this.isProjectsActive()}
-            to={{}}
-            onClick={onToggleClick}
-            text={translate('sidebar.projects')}
-            withChevron
-          />
-        )}
-      </Dropdown>
+        <NavBarTabLink
+          aria-haspopup="menu"
+          active={this.isProjectsActive()}
+          to={{}}
+          text={translate('sidebar.projects')}
+          withChevron
+        />
+      </DropdownMenu.Root>
     );
   }
 
   renderSecurityTab() {
     return (
-      <Dropdown
+      <DropdownMenu.Root
         id="settings-navigation-security-dropdown"
-        overlay={
+        items={
           <>
-            <ItemNavLink to="/admin/users">{translate('users.page')}</ItemNavLink>
+            <DropdownMenu.ItemLink isMatchingFullPath to="/admin/users">
+              {translate('users.page')}
+            </DropdownMenu.ItemLink>
 
-            <ItemNavLink to="/admin/groups">{translate('user_groups.page')}</ItemNavLink>
+            <DropdownMenu.ItemLink isMatchingFullPath to="/admin/groups">
+              {translate('user_groups.page')}
+            </DropdownMenu.ItemLink>
 
-            <ItemNavLink to="/admin/permissions">
+            <DropdownMenu.ItemLink isMatchingFullPath to="/admin/permissions">
               {translate('global_permissions.page')}
-            </ItemNavLink>
+            </DropdownMenu.ItemLink>
 
-            <ItemNavLink to="/admin/permission_templates">
+            <DropdownMenu.ItemLink isMatchingFullPath to="/admin/permission_templates">
               {translate('permission_templates')}
-            </ItemNavLink>
+            </DropdownMenu.ItemLink>
           </>
         }
-        size="auto"
-        zLevel={PopupZLevel.Global}
       >
-        {({ onToggleClick, open }) => (
-          <NavBarTabLink
-            aria-expanded={open}
-            aria-haspopup="menu"
-            active={open || this.isSecurityActive()}
-            to={{}}
-            onClick={onToggleClick}
-            text={translate('sidebar.security')}
-            withChevron
-          />
-        )}
-      </Dropdown>
+        <NavBarTabLink
+          aria-haspopup="menu"
+          active={this.isSecurityActive()}
+          to={{}}
+          text={translate('sidebar.security')}
+          withChevron
+        />
+      </DropdownMenu.Root>
     );
   }
 
   render() {
     const { extensions, pendingPlugins } = this.props;
     const hasSupportExtension = extensions.find((extension) => extension.key === 'license/support');
+
     const hasGovernanceExtension = extensions.find(
       (e) => e.key === AdminPageExtension.GovernanceConsole,
     );
+
     const totalPendingPlugins =
       pendingPlugins.installing.length +
       pendingPlugins.removing.length +
       pendingPlugins.updating.length;
+
     let notifComponent;
+
     if (this.props.systemStatus === 'RESTARTING') {
       notifComponent = <SystemRestartNotif />;
     } else if (totalPendingPlugins > 0) {
@@ -266,6 +251,7 @@ export class SettingsNav extends React.PureComponent<Props> {
             )}
           </NavBarTabs>
         </TopBar>
+
         {notifComponent}
       </>
     );
index 63852374650875f993bebd0d5c8c04b9de97607b..bfa6befdcdd4b85328a8060ba77be8f046b2397f 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import {
-  ButtonSecondary,
-  DropdownMenu,
-  DropdownToggler,
-  ItemButton,
-  PopupPlacement,
-  PopupZLevel,
-  addGlobalErrorMessage,
-  addGlobalSuccessMessage,
-} from 'design-system';
+import { DropdownMenu } from '@sonarsource/echoes-react';
+import { addGlobalErrorMessage, addGlobalSuccessMessage, ButtonSecondary } from 'design-system';
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import DocumentationLink from '../../../components/common/DocumentationLink';
@@ -49,11 +41,6 @@ export interface Props {
   pullRequestID?: string;
 }
 
-interface State {
-  disabled?: boolean;
-  ides: Ide[];
-}
-
 const showError = () =>
   addGlobalErrorMessage(
     <FormattedMessage
@@ -79,14 +66,19 @@ export function IssueOpenInIdeButton({
   projectKey,
   pullRequestID,
 }: Readonly<Props>) {
-  const [state, setState] = React.useState<State>({ disabled: false, ides: [] });
+  const [isDisabled, setIsDisabled] = React.useState(false);
+  const [ides, setIdes] = React.useState<Ide[] | undefined>(undefined);
+  const ref = React.useRef<HTMLButtonElement>(null);
 
-  const cleanState = () => {
-    setState({ ...state, ides: [] });
-  };
+  // to give focus back to the trigger button once it is re-rendered as a single button
+  const focusTriggerButton = React.useCallback(() => {
+    setTimeout(() => {
+      ref.current?.focus();
+    });
+  }, []);
 
   const openIssue = async (ide: Ide) => {
-    setState({ ...state, disabled: true, ides: [] }); // close the dropdown, disable the button
+    setIsDisabled(true);
 
     let token: { name?: string; token?: string } = {};
 
@@ -112,14 +104,15 @@ export function IssueOpenInIdeButton({
 
     setTimeout(
       () => {
-        setState({ ...state, disabled: false });
+        setIsDisabled(false);
+        focusTriggerButton();
       },
       ide.needsToken ? DELAY_AFTER_TOKEN_CREATION : 0,
     );
   };
 
-  const onClick = async () => {
-    setState({ ...state, ides: [] });
+  const findIDEs = async () => {
+    setIdes(undefined);
 
     const ides = (await probeSonarLintServers()) ?? [];
 
@@ -128,47 +121,51 @@ export function IssueOpenInIdeButton({
     } else if (ides.length === 1) {
       openIssue(ides[0]);
     } else {
-      setState({ ...state, ides });
+      setIdes(ides);
     }
   };
 
-  return (
-    <div>
-      <DropdownToggler
-        allowResizing
-        onRequestClose={cleanState}
-        open={state.ides.length > 1}
-        overlay={
-          <DropdownMenu size="auto">
-            {state.ides.map((ide) => {
-              const { ideName, description } = ide;
-
-              const label = ideName + (description ? ` - ${description}` : '');
-
-              return (
-                <ItemButton
-                  key={ide.port}
-                  onClick={() => {
-                    openIssue(ide);
-                  }}
-                >
-                  {label}
-                </ItemButton>
-              );
-            })}
-          </DropdownMenu>
-        }
-        placement={PopupPlacement.BottomLeft}
-        zLevel={PopupZLevel.Global}
-      >
-        <ButtonSecondary
-          className="sw-whitespace-nowrap"
-          disabled={state.disabled}
-          onClick={onClick}
-        >
-          {translate('open_in_ide')}
-        </ButtonSecondary>
-      </DropdownToggler>
-    </div>
+  const onClick = ides === undefined ? findIDEs : undefined;
+
+  const triggerButton = (
+    <ButtonSecondary
+      className="sw-whitespace-nowrap"
+      disabled={isDisabled}
+      onClick={onClick}
+      ref={ref}
+    >
+      {translate('open_in_ide')}
+    </ButtonSecondary>
+  );
+
+  return ides === undefined ? (
+    triggerButton
+  ) : (
+    <DropdownMenu.Root
+      isOpenOnMount
+      items={ides.map((ide) => {
+        const { ideName, description } = ide;
+
+        const label = ideName + (description ? ` - ${description}` : '');
+
+        return (
+          <DropdownMenu.ItemButton
+            key={ide.port}
+            onClick={() => {
+              openIssue(ide);
+            }}
+          >
+            {label}
+          </DropdownMenu.ItemButton>
+        );
+      })}
+      onClose={() => {
+        setIdes(undefined);
+        focusTriggerButton();
+      }}
+      onOpen={findIDEs}
+    >
+      {triggerButton}
+    </DropdownMenu.Root>
   );
 }
index 2cfa5efd2c78975658fb0355d7d0ed4750fa5011..7a7383db6b97b39444ebec65d0ca759f8479b6a2 100644 (file)
@@ -58,7 +58,7 @@ import { DEFAULT_ISSUES_QUERY } from '../../../components/shared/utils';
 import { fillBranchLike, isSameBranchLike } from '../../../helpers/branch-like';
 import handleRequiredAuthentication from '../../../helpers/handleRequiredAuthentication';
 import { parseIssueFromResponse } from '../../../helpers/issues';
-import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
+import { isDropdown, isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
 import { KeyboardKeys } from '../../../helpers/keycodes';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { serializeDate } from '../../../helpers/query';
@@ -280,7 +280,7 @@ export class App extends React.PureComponent<Props, State> {
       return;
     }
 
-    if (isInput(event) || isShortcut(event)) {
+    if (isInput(event) || isShortcut(event) || isDropdown(event)) {
       return;
     }
 
index 2997e847dd863428b223bd644e6d6926acf5e846..008c47f2cd378c6d87437ef5b9617b0d02f1328c 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 {
-  ButtonSecondary,
-  ChevronDownIcon,
-  Dropdown,
-  ItemButton,
-  PopupPlacement,
-  PopupZLevel,
-  TextMuted,
-} from 'design-system';
+
+import { DropdownMenu } from '@sonarsource/echoes-react';
+import { ButtonSecondary, ChevronDownIcon, TextMuted } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../helpers/l10n';
 import { GraphType } from '../../types/project-activity';
@@ -73,9 +67,9 @@ export default function GraphsHeader(props: Props) {
       const label = translate('project_activity.graphs', type);
 
       return (
-        <ItemButton key={label} onClick={() => handleGraphChange(type)}>
+        <DropdownMenu.ItemButton key={label} onClick={() => handleGraphChange(type)}>
           {label}
-        </ItemButton>
+        </DropdownMenu.ItemButton>
       );
     });
   }, [noCustomGraph, handleGraphChange]);
@@ -83,13 +77,7 @@ export default function GraphsHeader(props: Props) {
   return (
     <div className={className}>
       <div className="sw-flex">
-        <Dropdown
-          id="activity-graph-type"
-          size="auto"
-          placement={PopupPlacement.BottomLeft}
-          zLevel={PopupZLevel.Content}
-          overlay={options}
-        >
+        <DropdownMenu.Root id="activity-graph-type" align="start" items={options}>
           <ButtonSecondary
             aria-label={translate('project_activity.graphs.choose_type')}
             className={
@@ -100,7 +88,7 @@ export default function GraphsHeader(props: Props) {
             <TextMuted text={translate('project_activity.graphs', graph)} />
             <ChevronDownIcon className="sw-ml-1 sw-mr-0 sw-pr-0" />
           </ButtonSecondary>
-        </Dropdown>
+        </DropdownMenu.Root>
 
         {isCustomGraph(graph) &&
           props.onAddCustomMetric !== undefined &&
index 0ca48ab3ac70cc59f771c42146469cc337588cfa..7e795522211b3486a2c22fef3cdfea509821b0fb 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { ItemLink, OpenNewTabIcon } from 'design-system';
+import { DropdownMenu } from '@sonarsource/echoes-react';
 import * as React from 'react';
 import { DocLink } from '../../helpers/doc-links';
 import { useDocUrl } from '../../helpers/docs';
 
 interface Props {
   children: React.ReactNode;
-  innerRef?: React.Ref<HTMLAnchorElement>;
   to: DocLink;
 }
 
-export function DocItemLink({ to, innerRef, children }: Props) {
+export function DocItemLink({ to, children }: Readonly<Props>) {
   const toStatic = useDocUrl(to);
 
-  return (
-    <ItemLink innerRef={innerRef} to={toStatic}>
-      <OpenNewTabIcon />
-      {children}
-    </ItemLink>
-  );
+  return <DropdownMenu.ItemLink to={toStatic}>{children}</DropdownMenu.ItemLink>;
 }
index 152767937702135a3397c34c3fcfea867da93a62..2707b60a7c115e660cd271a809884cbea06b3221 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { ItemDivider, ItemHeader, ItemLink, OpenNewTabIcon } from 'design-system';
+import { DropdownMenu } from '@sonarsource/echoes-react';
 import * as React from 'react';
 import { DocLink } from '../../helpers/doc-links';
 import { translate } from '../../helpers/l10n';
@@ -37,40 +37,36 @@ function IconLink({
   text: string;
 }) {
   return (
-    <ItemLink to={link}>
-      <Image
-        alt={text}
-        aria-hidden
-        className="sw-mr-2"
-        height="18"
-        src={`/images/${icon}`}
-        width="18"
-      />
+    <DropdownMenu.ItemLink
+      prefix={
+        <Image
+          alt={text}
+          aria-hidden
+          className="sw-mr-2"
+          height="18"
+          src={`/images/${icon}`}
+          width="18"
+        />
+      }
+      to={link}
+    >
       {text}
-    </ItemLink>
+    </DropdownMenu.ItemLink>
   );
 }
 
-function Suggestions({
-  firstItemRef,
-  suggestions,
-}: {
-  firstItemRef: React.RefObject<HTMLAnchorElement>;
-  suggestions: SuggestionLink[];
-}) {
+function Suggestions({ suggestions }: Readonly<{ 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}
-        >
+      <DropdownMenu.GroupLabel>{translate('docs.suggestion')}</DropdownMenu.GroupLabel>
+
+      {suggestions.map((suggestion) => (
+        <DocItemLink key={suggestion.link} to={suggestion.link}>
           {suggestion.text}
         </DocItemLink>
       ))}
-      <ItemDivider />
+
+      <DropdownMenu.Separator />
     </>
   );
 }
@@ -85,29 +81,38 @@ export function EmbedDocsPopup() {
 
   return (
     <>
-      {suggestions.length !== 0 && (
-        <Suggestions firstItemRef={firstItemRef} suggestions={suggestions} />
-      )}
-      <DocItemLink innerRef={suggestions.length === 0 ? firstItemRef : undefined} to={DocLink.Root}>
-        {translate('docs.documentation')}
-      </DocItemLink>
-      <ItemLink to="/web_api">{translate('api_documentation.page')}</ItemLink>
-      <ItemLink to="/web_api_v2">{translate('api_documentation.page.v2')}</ItemLink>
-      <ItemDivider />
-      <ItemLink to="https://community.sonarsource.com/">
-        <OpenNewTabIcon />
+      {suggestions.length !== 0 && <Suggestions suggestions={suggestions} />}
+
+      <DocItemLink to={DocLink.Root}>{translate('docs.documentation')}</DocItemLink>
+
+      <DropdownMenu.ItemLink to="/web_api">
+        {translate('api_documentation.page')}
+      </DropdownMenu.ItemLink>
+
+      <DropdownMenu.ItemLink to="/web_api_v2">
+        {translate('api_documentation.page.v2')}
+      </DropdownMenu.ItemLink>
+
+      <DropdownMenu.Separator />
+
+      <DropdownMenu.ItemLink to="https://community.sonarsource.com/">
         {translate('docs.get_help')}
-      </ItemLink>
-      <ItemDivider />
-      <ItemHeader id="stay_connected">{translate('docs.stay_connected')}</ItemHeader>
+      </DropdownMenu.ItemLink>
+
+      <DropdownMenu.Separator />
+
+      <DropdownMenu.GroupLabel>{translate('docs.stay_connected')}</DropdownMenu.GroupLabel>
+
       <IconLink
         link="https://www.sonarsource.com/products/sonarqube/whats-new/?referrer=sonarqube"
         text={translate('docs.news')}
       />
+
       <IconLink
         link="https://www.sonarsource.com/products/sonarqube/roadmap/?referrer=sonarqube"
         text={translate('docs.roadmap')}
       />
+
       <IconLink
         icon="embed-doc/x-icon-black.svg"
         link="https://twitter.com/SonarQube"
index 39fd69449afedcf0ba40211bf32714e2188feacf..b929c57fd6c6a9b6d98db8c90e8ab1da783b292c 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { IconQuestionMark } from '@sonarsource/echoes-react';
-import { Dropdown, InteractiveIcon, PopupPlacement, PopupZLevel } from 'design-system';
+import { DropdownMenu, IconQuestionMark, Tooltip } from '@sonarsource/echoes-react';
+import { InteractiveIcon } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../helpers/l10n';
-import Tooltip from '../controls/Tooltip';
 import { EmbedDocsPopup } from './EmbedDocsPopup';
 
 export default function EmbedDocsPopupHelper() {
   return (
     <div className="dropdown">
-      <Dropdown
-        id="help-menu-dropdown"
-        placement={PopupPlacement.BottomRight}
-        overlay={<EmbedDocsPopup />}
-        allowResizing
-        zLevel={PopupZLevel.Global}
-      >
-        {({ onToggleClick, open }) => (
-          <Tooltip mouseLeaveDelay={0.2} content={!open ? translate('help') : undefined}>
-            <InteractiveIcon
-              Icon={IconQuestionMark}
-              aria-expanded={open}
-              data-guiding-id="issue-5"
-              aria-controls="help-menu-dropdown"
-              aria-haspopup
-              aria-label={translate('help')}
-              currentColor
-              onClick={onToggleClick}
-              size="medium"
-              stopPropagation={false}
-            />
-          </Tooltip>
-        )}
-      </Dropdown>
+      <DropdownMenu.Root align="end" id="help-menu-dropdown" items={<EmbedDocsPopup />}>
+        <Tooltip content={translate('help')}>
+          <InteractiveIcon
+            Icon={IconQuestionMark}
+            data-guiding-id="issue-5"
+            aria-controls="help-menu-dropdown"
+            aria-haspopup
+            aria-label={translate('help')}
+            currentColor
+            size="medium"
+            stopPropagation={false}
+          />
+        </Tooltip>
+      </DropdownMenu.Root>
     </div>
   );
 }
index ef09bf7b4b0ab35d0497bee2b48cf803afe8a5a1..95b64a1af0e51dfbd7e54964d29db2b8245e2204 100644 (file)
@@ -35,8 +35,6 @@ it('should render with no suggestions', async () => {
 
   expect(screen.getByText('docs.documentation')).toBeInTheDocument();
   expect(screen.queryByText('docs.suggestion')).not.toBeInTheDocument();
-
-  expect(screen.getByText('docs.documentation')).toHaveFocus();
 });
 
 it('should be able to render with suggestions and remove them', async () => {
@@ -51,13 +49,9 @@ it('should be able to render with suggestions and remove them', async () => {
   expect(screen.getByText('docs.suggestion')).toBeInTheDocument();
   expect(screen.getByText('About Background Tasks')).toBeInTheDocument();
 
-  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();
 });
 
 function renderEmbedDocsPopup() {
index 65ec4ba0fee5d82761f2d625ec8806032c469599..6a73fad10a02782722bc8e297784ffc4c2e15423 100644 (file)
@@ -36,3 +36,9 @@ export function isInput(
     event.target instanceof HTMLTextAreaElement
   );
 }
+
+export function isDropdown(event: KeyboardEvent) {
+  const role = (event.target as HTMLElement | null)?.role ?? '';
+
+  return ['menu', 'menuitem'].includes(role);
+}