Browse Source

Merge pull request #24965 from overleaf/td-downshift-9-upgrade

Upgrade Downshift to version 9

GitOrigin-RevId: b36904ab0c82c09a633a25cd6fed651d7c8b19f7
Tim Down 1 year ago
parent
commit
fb50d429b4

+ 32 - 15
package-lock.json

@@ -3290,9 +3290,10 @@
       "dev": true
     },
     "node_modules/@babel/runtime": {
-      "version": "7.23.2",
-      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
-      "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
+      "version": "7.27.0",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
+      "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
+      "license": "MIT",
       "dependencies": {
         "regenerator-runtime": "^0.14.0"
       },
@@ -20400,21 +20401,36 @@
       "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA=="
     },
     "node_modules/downshift": {
-      "version": "6.1.7",
-      "resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.7.tgz",
-      "integrity": "sha512-cVprZg/9Lvj/uhYRxELzlu1aezRcgPWBjTvspiGTVEU64gF5pRdSRKFVLcxqsZC637cLAGMbL40JavEfWnqgNg==",
+      "version": "9.0.9",
+      "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.0.9.tgz",
+      "integrity": "sha512-ygOT8blgiz5liDuEFAIaPeU4dDEa+w9p6PHVUisPIjrkF5wfR59a52HpGWAVVMoWnoFO8po2mZSScKZueihS7g==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
-        "@babel/runtime": "^7.14.8",
-        "compute-scroll-into-view": "^1.0.17",
-        "prop-types": "^15.7.2",
-        "react-is": "^17.0.2",
-        "tslib": "^2.3.0"
+        "@babel/runtime": "^7.24.5",
+        "compute-scroll-into-view": "^3.1.0",
+        "prop-types": "^15.8.1",
+        "react-is": "18.2.0",
+        "tslib": "^2.6.2"
       },
       "peerDependencies": {
         "react": ">=16.12.0"
       }
     },
+    "node_modules/downshift/node_modules/compute-scroll-into-view": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
+      "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/downshift/node_modules/react-is": {
+      "version": "18.2.0",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+      "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/dropbox": {
       "version": "10.34.0",
       "resolved": "https://registry.npmjs.org/dropbox/-/dropbox-10.34.0.tgz",
@@ -39925,9 +39941,10 @@
       }
     },
     "node_modules/tslib": {
-      "version": "2.5.2",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz",
-      "integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA=="
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
     },
     "node_modules/tsscmp": {
       "version": "1.0.6",
@@ -45355,7 +45372,7 @@
         "daterangepicker": "2.1.27",
         "diff": "^5.1.0",
         "dompurify": "^3.2.4",
-        "downshift": "^6.1.0",
+        "downshift": "^9.0.9",
         "es6-promise": "^4.2.8",
         "escodegen": "^2.0.0",
         "eslint-config-standard-jsx": "^11.0.0",

+ 1 - 8
services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx

@@ -24,10 +24,8 @@ function Downshift({ setValue, inputRef }: CountryInputProps) {
     getLabelProps,
     getMenuProps,
     getInputProps,
-    getComboboxProps,
     getItemProps,
     highlightedIndex,
-    openMenu,
     selectedItem,
   } = useCombobox({
     inputValue,
@@ -50,7 +48,7 @@ function Downshift({ setValue, inputRef }: CountryInputProps) {
 
   return (
     <div className={classnames('dropdown', 'd-block')}>
-      <div {...getComboboxProps()}>
+      <div>
         {/* eslint-disable-next-line jsx-a11y/label-has-for */}
         <label {...getLabelProps()} className="visually-hidden">
           {t('country')}
@@ -60,11 +58,6 @@ function Downshift({ setValue, inputRef }: CountryInputProps) {
             onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
               setInputValue(event.target.value)
             },
-            onFocus: () => {
-              if (!isOpen) {
-                openMenu()
-              }
-            },
             ref: inputRef,
           })}
           placeholder={t('country')}

+ 1 - 9
services/web/frontend/js/features/settings/components/emails/downshift-input.tsx

@@ -47,10 +47,8 @@ function Downshift({
     getLabelProps,
     getMenuProps,
     getInputProps,
-    getComboboxProps,
     getItemProps,
     highlightedIndex,
-    openMenu,
     selectedItem,
   } = useCombobox({
     inputValue,
@@ -82,8 +80,7 @@ function Downshift({
 
   return (
     <div className={classnames('dropdown', 'd-block')}>
-      <div {...getComboboxProps()}>
-        {/* eslint-disable-next-line jsx-a11y/label-has-for */}
+      <div>
         <OLFormLabel
           {...getLabelProps()}
           className={showLabel ? '' : 'visually-hidden'}
@@ -95,11 +92,6 @@ function Downshift({
             onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
               setValue(event.target.value)
             },
-            onFocus: () => {
-              if (!isOpen) {
-                openMenu()
-              }
-            },
             ref: inputRef,
           })}
           placeholder={placeholder}

+ 1 - 6
services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx

@@ -94,7 +94,6 @@ export default function SelectCollaborators({
     getLabelProps,
     getMenuProps,
     getInputProps,
-    getComboboxProps,
     highlightedIndex,
     getItemProps,
     reset,
@@ -171,11 +170,7 @@ export default function SelectCollaborators({
 
       <div className="host">
         {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
-        <div
-          {...getComboboxProps()}
-          className="tags form-control"
-          onClick={focusInput}
-        >
+        <div className="tags form-control" onClick={focusInput}>
           {selectedItems.map((selectedItem, index) => (
             <SelectedItem
               key={`selected-item-${index}`}

+ 1 - 0
services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts

@@ -30,6 +30,7 @@ export type DropdownProps = {
 export type DropdownItemProps = PropsWithChildren<{
   active?: boolean
   as?: ElementType
+  type?: string
   description?: ReactNode
   disabled?: boolean
   eventKey?: string | number

+ 16 - 14
services/web/frontend/js/shared/components/select.tsx

@@ -1,5 +1,3 @@
-/* eslint-disable jsx-a11y/label-has-for */
-/* eslint-disable jsx-a11y/label-has-associated-control */
 import {
   useRef,
   useEffect,
@@ -89,6 +87,7 @@ export const Select = <T,>({
   } = useSelect({
     items: items ?? [],
     itemToString,
+    isItemDisabled: item => itemToDisabled?.(item) || false,
     selectedItem: selected || defaultItem,
     onSelectedItemChange: changes => {
       if (onSelectedItemChanged) {
@@ -124,22 +123,18 @@ export const Select = <T,>({
     }
   }, [name, itemToString, selectedItem, defaultItem])
 
-  const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
-    if (event.key === 'Escape' && isOpen) {
-      event.stopPropagation()
-      closeMenu()
-    }
-  }
-
-  const onKeyDown: KeyboardEventHandler<HTMLButtonElement> = useCallback(
+  const onKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
     event => {
       if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
         event.preventDefault()
         ;(event.nativeEvent as any).preventDownshiftDefault = true
         openMenu()
+      } else if (event.key === 'Escape' && isOpen) {
+        event.stopPropagation()
+        closeMenu()
       }
     },
-    [isOpen, openMenu]
+    [closeMenu, isOpen, openMenu]
   )
 
   let value: string | undefined
@@ -187,16 +182,22 @@ export const Select = <T,>({
         }
       />
       <ul
-        {...getMenuProps({ disabled, onKeyDown: handleMenuKeyDown })}
+        {...getMenuProps({ disabled })}
         className={classNames('dropdown-menu w-100', { show: isOpen })}
       >
         {isOpen &&
           items?.map((item, index) => {
-            const isDisabled = itemToDisabled && itemToDisabled(item)
+            // We're using an actual disabled button so we don't need the
+            // aria-disabled prop
+            const { 'aria-disabled': disabled, ...itemProps } = getItemProps({
+              item,
+              index,
+            })
             return (
               <li role="none" key={itemToKey(item)}>
                 <DropdownItem
                   as="button"
+                  type="button"
                   className={classNames({
                     'select-highlighted': highlightedIndex === index,
                   })}
@@ -207,7 +208,8 @@ export const Select = <T,>({
                   description={
                     itemToSubtitle ? itemToSubtitle(item) : undefined
                   }
-                  {...getItemProps({ item, index, disabled: isDisabled })}
+                  {...itemProps}
+                  disabled={disabled}
                 >
                   {itemToString(item)}
                 </DropdownItem>

+ 1 - 1
services/web/package.json

@@ -283,7 +283,7 @@
     "daterangepicker": "2.1.27",
     "diff": "^5.1.0",
     "dompurify": "^3.2.4",
-    "downshift": "^6.1.0",
+    "downshift": "^9.0.9",
     "es6-promise": "^4.2.8",
     "escodegen": "^2.0.0",
     "eslint-config-standard-jsx": "^11.0.0",

+ 27 - 28
services/web/test/frontend/components/shared/select.spec.tsx

@@ -64,17 +64,17 @@ describe('<Select />', function () {
     it('renders default text', function () {
       render({ defaultText: 'Choose an item' })
       cy.findByTestId('spinner').should('not.exist')
-      cy.findByRole('textbox', { name: 'Choose an item' })
+      cy.findByRole('combobox').should('have.value', 'Choose an item')
     })
 
     it('renders default item', function () {
       render({ defaultItem: testData[2] })
-      cy.findByRole('textbox', { name: 'Demo item 3' })
+      cy.findByRole('combobox').should('have.value', 'Demo item 3')
     })
 
     it('default item takes precedence over default text', function () {
       render({ defaultText: 'Choose an item', defaultItem: testData[2] })
-      cy.findByRole('textbox', { name: 'Demo item 3' })
+      cy.findByRole('combobox').should('have.value', 'Demo item 3')
     })
 
     it('renders label', function () {
@@ -83,8 +83,8 @@ describe('<Select />', function () {
         label: 'test label',
         optionalLabel: false,
       })
-      cy.findByRole('textbox', { name: 'test label' })
-      cy.findByRole('textbox', { name: '(Optional)' }).should('not.exist')
+      cy.findByRole('combobox', { name: 'test label' })
+      cy.findByRole('combobox', { name: '(Optional)' }).should('not.exist')
     })
 
     it('renders optional label', function () {
@@ -93,7 +93,7 @@ describe('<Select />', function () {
         label: 'test label',
         optionalLabel: true,
       })
-      cy.findByRole('textbox', { name: 'test label (Optional)' })
+      cy.findByRole('combobox', { name: 'test label (Optional)' })
     })
 
     it('renders a spinner while loading when there is a label', function () {
@@ -117,7 +117,7 @@ describe('<Select />', function () {
   describe('items rendering', function () {
     it('renders all items', function () {
       render({ defaultText: 'Choose an item' })
-      cy.findByRole('textbox', { name: 'Choose an item' }).click()
+      cy.findByRole('combobox').click()
 
       cy.findByRole('option', { name: 'Demo item 1' })
       cy.findByRole('option', { name: 'Demo item 2' })
@@ -129,7 +129,7 @@ describe('<Select />', function () {
         defaultText: 'Choose an item',
         itemToSubtitle: x => String(x?.sub),
       })
-      cy.findByRole('textbox', { name: 'Choose an item' }).click()
+      cy.findByRole('combobox').click()
 
       cy.findByRole('option', { name: 'Demo item 1 Subtitle 1' })
       cy.findByRole('option', { name: 'Demo item 2 Subtitle 2' })
@@ -140,27 +140,27 @@ describe('<Select />', function () {
   describe('item selection', function () {
     it('cannot select an item when disabled', function () {
       render({ defaultText: 'Choose an item', disabled: true })
-      cy.findByRole('textbox', { name: 'Choose an item' }).click({
+      cy.findByRole('combobox').click({
         force: true,
       })
       cy.findByRole('option', { name: 'Demo item 1' }).should('not.exist')
       cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
       cy.findByRole('option', { name: 'Demo item 3' }).should('not.exist')
-      cy.findByRole('textbox', { name: 'Choose an item' })
+      cy.findByRole('combobox').should('have.value', 'Choose an item')
     })
 
     it('renders only the selected item after selection', function () {
       render({ defaultText: 'Choose an item' })
-      cy.findByRole('textbox', { name: 'Choose an item' }).click()
+      cy.findByRole('combobox').click()
 
       cy.findByRole('option', { name: 'Demo item 1' })
       cy.findByRole('option', { name: 'Demo item 2' })
       cy.findByRole('option', { name: 'Demo item 3' }).click()
 
-      cy.findByRole('textbox', { name: 'Choose an item' }).should('not.exist')
+      cy.findByRole('combobox').should('not.have.value', 'Choose an item')
       cy.findByRole('option', { name: 'Demo item 1' }).should('not.exist')
       cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
-      cy.findByRole('textbox', { name: 'Demo item 3' })
+      cy.findByRole('combobox').should('have.value', 'Demo item 3')
     })
 
     it('invokes callback after selection', function () {
@@ -170,7 +170,7 @@ describe('<Select />', function () {
         defaultText: 'Choose an item',
         onSelectedItemChanged: selectionHandler,
       })
-      cy.findByRole('textbox', { name: 'Choose an item' }).click()
+      cy.findByRole('combobox').click()
       cy.findByRole('option', { name: 'Demo item 2' }).click()
 
       cy.get('@selectionHandler').should(
@@ -195,7 +195,7 @@ describe('<Select />', function () {
       const submitHandler = cy.stub().as('submitHandler')
       render({ defaultItem: testData[1], onSubmit: submitHandler })
 
-      cy.findByRole('textbox', { name: 'Demo item 2' }).click() // open dropdown
+      cy.findByRole('combobox').click() // open dropdown
       cy.findByText('Demo item 3').click() // choose a different item
 
       cy.findByText('submit').click()
@@ -262,7 +262,7 @@ describe('<Select />', function () {
         </div>
       )
 
-      cy.findByRole('textbox', { name: 'Demo item 1' }).click() // open dropdown
+      cy.findByRole('combobox').click() // open dropdown
       cy.findByRole('option', { name: 'Demo item 3' }).click() // choose a different item
 
       cy.findByText('submit').click()
@@ -275,11 +275,10 @@ describe('<Select />', function () {
   describe('keyboard navigation', function () {
     it('can select an item using the keyboard', function () {
       render({ defaultText: 'Choose an item' })
-      cy.findByRole('textbox', { name: 'Choose an item' }).type(
-        '{Enter}{downArrow}{Enter}',
-        { force: true }
-      )
-      cy.findByRole('textbox', { name: 'Demo item 1' }).should('exist')
+      cy.findByRole('combobox').type('{Enter}{downArrow}{Enter}', {
+        force: true,
+      })
+      cy.findByRole('combobox').should('exist')
       cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
     })
   })
@@ -290,9 +289,9 @@ describe('<Select />', function () {
         defaultText: 'Choose an item',
         selectedIcon: true,
       })
-      cy.findByRole('textbox', { name: 'Choose an item' }).click()
+      cy.findByRole('combobox').click()
       cy.findByRole('option', { name: 'Demo item 1' }).click()
-      cy.findByRole('textbox', { name: 'Demo item 1' }).click()
+      cy.findByRole('combobox').click()
 
       cy.findByText('check').should('exist')
     })
@@ -301,9 +300,9 @@ describe('<Select />', function () {
         defaultText: 'Choose an item',
         selectedIcon: false,
       })
-      cy.findByRole('textbox', { name: 'Choose an item' }).click()
+      cy.findByRole('combobox').click()
       cy.findByRole('option', { name: 'Demo item 1' }).click()
-      cy.findByRole('textbox', { name: 'Demo item 1' }).click()
+      cy.findByRole('combobox').click()
 
       cy.findByText('check').should('not.exist')
     })
@@ -315,7 +314,7 @@ describe('<Select />', function () {
         defaultText: 'Choose an item',
         itemToDisabled: x => x?.key === 2,
       })
-      cy.findByRole('textbox', { name: 'Choose an item' }).click()
+      cy.findByRole('combobox').click()
       cy.findByRole('option', { name: 'Demo item 2' }).click({ force: true })
       // still showing other list items
       cy.findByRole('option', { name: 'Demo item 3' }).should('exist')
@@ -331,7 +330,7 @@ describe('<Select />', function () {
         defaultText: 'Choose an item',
         selected: testData[1],
       })
-      cy.findByRole('textbox', { name: 'Demo item 2' }).should('exist')
+      cy.findByRole('combobox').should('have.value', 'Demo item 2')
     })
 
     it('should show default text when selected is null', function () {
@@ -339,7 +338,7 @@ describe('<Select />', function () {
         selected: null,
         defaultText: 'Choose an item',
       })
-      cy.findByRole('textbox', { name: 'Choose an item' }).should('exist')
+      cy.findByRole('combobox').should('have.value', 'Choose an item')
     })
   })
 })

+ 24 - 20
services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx

@@ -304,7 +304,7 @@ describe('<EmailsSection />', function () {
 
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
 
-    const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
+    const universityInput = screen.getByRole<HTMLInputElement>('combobox', {
       name: /university/i,
     })
 
@@ -321,7 +321,7 @@ describe('<EmailsSection />', function () {
 
     // Select the country from dropdown
     await userEvent.type(
-      screen.getByRole('textbox', {
+      screen.getByRole('combobox', {
         name: /country/i,
       }),
       country
@@ -339,9 +339,11 @@ describe('<EmailsSection />', function () {
       await screen.findByText(userEmailData.affiliation.institution.name)
     )
 
-    const roleInput = screen.getByRole('textbox', { name: /role/i })
+    const roleInput = screen.getByRole('combobox', { name: /role/i })
     await userEvent.type(roleInput, userEmailData.affiliation.role!)
-    const departmentInput = screen.getByRole('textbox', { name: /department/i })
+    const departmentInput = screen.getByRole('combobox', {
+      name: /department/i,
+    })
     await userEvent.click(departmentInput)
     await userEvent.click(screen.getByText(customDepartment))
 
@@ -415,7 +417,7 @@ describe('<EmailsSection />', function () {
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
 
     // select a country
-    const countryInput = screen.getByRole<HTMLInputElement>('textbox', {
+    const countryInput = screen.getByRole<HTMLInputElement>('combobox', {
       name: /country/i,
     })
     await userEvent.click(countryInput)
@@ -423,7 +425,7 @@ describe('<EmailsSection />', function () {
     await userEvent.click(await screen.findByText('Germany'))
 
     // match several universities on initial typing
-    const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
+    const universityInput = screen.getByRole<HTMLInputElement>('combobox', {
       name: /university/i,
     })
     await userEvent.click(universityInput)
@@ -457,7 +459,7 @@ describe('<EmailsSection />', function () {
 
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
 
-    const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
+    const universityInput = screen.getByRole<HTMLInputElement>('combobox', {
       name: /university/i,
     })
 
@@ -474,7 +476,7 @@ describe('<EmailsSection />', function () {
 
     // Select the country from dropdown
     await userEvent.type(
-      screen.getByRole('textbox', {
+      screen.getByRole('combobox', {
         name: /country/i,
       }),
       country
@@ -489,9 +491,11 @@ describe('<EmailsSection />', function () {
     // Enter the university manually
     await userEvent.type(universityInput, newUniversity)
 
-    const roleInput = screen.getByRole('textbox', { name: /role/i })
+    const roleInput = screen.getByRole('combobox', { name: /role/i })
     await userEvent.type(roleInput, userEmailData.affiliation.role!)
-    const departmentInput = screen.getByRole('textbox', { name: /department/i })
+    const departmentInput = screen.getByRole('combobox', {
+      name: /department/i,
+    })
     await userEvent.type(departmentInput, userEmailData.affiliation.department!)
 
     const userEmailDataCopy = {
@@ -577,37 +581,37 @@ describe('<EmailsSection />', function () {
     fetchMock.removeRoutes().clearHistory()
 
     expect(
-      screen.queryByRole('textbox', {
+      screen.queryByRole('combobox', {
         name: /country/i,
       })
     ).to.be.null
     expect(
-      screen.queryByRole('textbox', {
+      screen.queryByRole('combobox', {
         name: /university/i,
       })
     ).to.be.null
-    screen.getByRole('textbox', {
+    screen.getByRole('combobox', {
       name: /role/i,
     })
-    screen.getByRole('textbox', {
+    screen.getByRole('combobox', {
       name: /department/i,
     })
 
     await userEvent.click(screen.getByRole('button', { name: /change/i }))
 
-    screen.getByRole('textbox', {
+    screen.getByRole('combobox', {
       name: /country/i,
     })
-    screen.getByRole('textbox', {
+    screen.getByRole('combobox', {
       name: /university/i,
     })
     expect(
-      screen.queryByRole('textbox', {
+      screen.queryByRole('combobox', {
         name: /role/i,
       })
     ).to.be.null
     expect(
-      screen.queryByRole('textbox', {
+      screen.queryByRole('combobox', {
         name: /department/i,
       })
     ).to.be.null
@@ -668,11 +672,11 @@ describe('<EmailsSection />', function () {
       .post('/user/emails/confirm-secondary', 200)
 
     await userEvent.type(
-      screen.getByRole('textbox', { name: /role/i }),
+      screen.getByRole('combobox', { name: /role/i }),
       userEmailData.affiliation.role!
     )
     await userEvent.type(
-      screen.getByRole('textbox', { name: /department/i }),
+      screen.getByRole('combobox', { name: /department/i }),
       userEmailData.affiliation.department!
     )
     await userEvent.click(

+ 1 - 1
services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx

@@ -146,7 +146,7 @@ describe('user role and institution', function () {
     await fetchMock.callHistory.flush(true)
     fetchMock.removeRoutes().clearHistory()
 
-    fireEvent.click(screen.getByRole('textbox', { name: /department/i }))
+    fireEvent.click(screen.getByRole('combobox', { name: /department/i }))
 
     screen.getByText(fakeDepartment)
   })

+ 22 - 9
services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx

@@ -126,7 +126,7 @@ describe('<FigureModal />', function () {
     })
 
     it('Lists files from project', function () {
-      cy.findByRole('textbox', { name: 'Image file' }).click()
+      cy.findByRole('combobox', { name: 'Image file' }).click()
       cy.findByRole('listbox')
         .children()
         .should('have.length', 2)
@@ -137,7 +137,7 @@ describe('<FigureModal />', function () {
 
     it('Enables insert button when choosing file', function () {
       cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
-      cy.findByRole('textbox', { name: 'Image file' }).click()
+      cy.findByRole('combobox', { name: 'Image file' }).click()
       cy.findByRole('listbox').within(() => {
         cy.findByText('frog.jpg').click()
       })
@@ -145,7 +145,7 @@ describe('<FigureModal />', function () {
     })
 
     it('Inserts file when pressing insert button', function () {
-      cy.findByRole('textbox', { name: 'Image file' }).click()
+      cy.findByRole('combobox', { name: 'Image file' }).click()
       cy.findByRole('listbox').within(() => {
         cy.findByText('frog.jpg').click()
       })
@@ -166,8 +166,8 @@ describe('<FigureModal />', function () {
       cy.findByRole('menu').within(() => {
         cy.findByRole('button', { name: 'From another project' }).click()
       })
-      cy.findByRole('textbox', { name: 'Project' }).as('project-dropdown')
-      cy.findByRole('textbox', { name: 'Image file' }).as('file-dropdown')
+      cy.findByRole('combobox', { name: 'Project' }).as('project-dropdown')
+      cy.findByRole('combobox', { name: 'Image file' }).as('file-dropdown')
     })
 
     it('List projects and files in projects', function () {
@@ -203,6 +203,19 @@ describe('<FigureModal />', function () {
       cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
     })
 
+    it('Closes project dropdown on pressing Esc key but leaves modal open', function () {
+      cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
+      cy.get('@project-dropdown').click()
+      cy.findByRole('listbox').should('exist')
+      cy.get('@project-dropdown').type('{esc}', { force: true })
+      cy.findByRole('listbox').should('not.exist')
+      cy.findByRole('dialog').should('exist')
+
+      // Check that a subsequent press of the Esc key closes the modal
+      cy.get('@project-dropdown').type('{esc}', { force: true })
+      cy.findByRole('dialog').should('not.exist')
+    })
+
     it('Creates linked file when pressing insert', function () {
       cy.get('@project-dropdown').click()
       cy.findByRole('listbox').within(() => {
@@ -235,7 +248,7 @@ describe('<FigureModal />', function () {
         cy.findByRole('option', { name: 'My first project' }).click()
       })
       cy.findByRole('button', { name: 'select from output files' }).click()
-      cy.findByRole('textbox', { name: 'Output file' }).click()
+      cy.findByRole('combobox', { name: 'Output file' }).click()
       cy.findByRole('listbox').within(() => {
         cy.findByRole('option', { name: 'output.pdf' }).click()
       })
@@ -298,7 +311,7 @@ describe('<FigureModal />', function () {
       cy.findByRole('menu').within(() => {
         cy.findByText('From another project').click()
       })
-      cy.findByRole('textbox', { name: 'Project' }).click()
+      cy.findByRole('combobox', { name: 'Project' }).click()
       cy.findByRole('listbox').within(() => {
         cy.findByRole('option', { name: 'My first project' }).click()
       })
@@ -322,7 +335,7 @@ describe('<FigureModal />', function () {
       })
       expectNoOutputSwitch()
       it('should show output file selector', function () {
-        cy.findByRole('textbox', { name: 'Output file' }).click()
+        cy.findByRole('combobox', { name: 'Output file' }).click()
         cy.findByRole('listbox').within(() => {
           cy.findByRole('option', { name: 'output.pdf' }).click()
         })
@@ -341,7 +354,7 @@ describe('<FigureModal />', function () {
       expectNoOutputSwitch()
 
       it('should show source file selector', function () {
-        cy.findByRole('textbox', { name: 'Image file' }).click()
+        cy.findByRole('combobox', { name: 'Image file' }).click()
         cy.findByRole('listbox').within(() => {
           cy.findByRole('option', { name: 'frog.jpg' }).click()
         })