diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot
index d03caf846..c99afb8eb 100644
--- a/collections/forms/i18n/en.pot
+++ b/collections/forms/i18n/en.pot
@@ -5,8 +5,8 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
-"POT-Creation-Date: 2024-10-16T02:11:46.033Z\n"
-"PO-Revision-Date: 2024-10-16T02:11:46.034Z\n"
+"POT-Creation-Date: 2024-10-22T03:25:41.899Z\n"
+"PO-Revision-Date: 2024-10-22T03:25:41.902Z\n"
msgid "Upload file"
msgstr "Upload file"
diff --git a/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js b/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
new file mode 100644
index 000000000..378e8aa09
--- /dev/null
+++ b/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
@@ -0,0 +1,230 @@
+describe('', () => {
+ it('should highlight the first option on the currently displayed page', () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // And the 74th option is being highlighted
+ cy.findByRole('combobox').focus().type(`Select option 73`)
+
+ // And the 70th option is the first option on the current page
+ let optionOffset
+ cy.findAllByRole('option').eq(70).then(option => {
+ const { offsetTop } = option.get(0)
+ optionOffset = offsetTop
+ })
+
+ cy.findByRole('option', { selected: true })
+ .invoke('parent') // listbox
+ .invoke('parent') // scrollable div
+ .then((listBoxParent) => {
+ console.log('> listBoxParent', listBoxParent.get(0))
+ console.log('> optionOffset', optionOffset)
+ listBoxParent.get(0).scrollTop = optionOffset
+ })
+
+ cy.findAllByRole('option').eq(70).should('be.visible')
+
+ // When the PageUp key is pressed
+ cy.findByRole('combobox').trigger('keydown', { key: 'PageUp', force: true })
+
+ // Then the first option on the currently displayed page is highlighted
+ cy.findByRole('option', { selected: true })
+ .invoke('attr', 'aria-label')
+ .should('equal', 'Select option 70')
+ })
+
+ it('should highlight the first option on the previous page', () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // And the 70th option is being highlighted
+ // Will automatically scroll there and make it the first option on the page
+ cy.findByRole('combobox').focus().type(`Select option 70`)
+
+ // When the PageUp key is pressed
+ cy.findByRole('combobox').trigger('keydown', { key: 'PageUp', force: true })
+
+ // Then the first option on the previous page is highlighted
+ cy.findByRole('option', { selected: true })
+ .invoke('attr', 'aria-label')
+ .should('equal', 'Select option 61')
+
+ // And the previously highlighted option is not visible
+ cy.findAllByRole('option').eq(70).should('not.be.visible')
+ })
+
+ it('should highlight the first option', () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // And the 2nd option is being highlighted
+ cy.findByRole('combobox').focus().type(`Select option 1`)
+
+ // And the 2nd option is the first option on the current page
+ let optionOffset
+ cy.findAllByRole('option').eq(1).then(option => {
+ const { offsetTop } = option.get(0)
+ optionOffset = offsetTop
+ })
+
+ cy.findByRole('option', { selected: true })
+ .invoke('parent') // listbox
+ .invoke('parent') // scrollable div
+ .then((listBoxParent) => {
+ console.log('> listBoxParent', listBoxParent.get(0))
+ console.log('> optionOffset', optionOffset)
+ listBoxParent.get(0).scrollTop = optionOffset
+ })
+
+ // When the PageUp key is pressed
+ cy.findByRole('combobox').trigger('keydown', { key: 'PageUp', force: true })
+
+ // Then the first option is being highlighted
+ cy.all(
+ () => cy.findAllByRole('option').first().invoke('get', 0),
+ () => cy.findByRole('option', { selected: true }).invoke('get', 0)
+ ).then(([firstOption, highlightedOption]) => {
+ expect(highlightedOption).to.equal(firstOption)
+ })
+ })
+
+ it('should highlight the last option on the currently displayed page', () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ // first option will be highlighted automatically
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // When the PageDown key is pressed
+ cy.findByRole('combobox').trigger('keydown', { key: 'PageDown', force: true })
+
+ // Then the last option on the currently displayed page is highlighted
+ cy.all(
+ () => cy.get('[role="option"]:visible').last().invoke('get', 0),
+ () => cy.findByRole('option', { selected: true }).invoke('get', 0)
+ ).then(([lastVisibleOption, highlightedOption]) => {
+ expect(highlightedOption).to.equal(lastVisibleOption)
+ })
+ })
+
+ it(
+ 'should highlight the last option on the next page',
+ // We don't want the options to scroll when we check whether they're
+ // visible or not (as that'd make them visible)
+ { scrollBehavior: false },
+ () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // And the option last visible option is being highlighted
+ cy.get('[role="option"]:visible').then(($visibleOptions) => {
+ const visibleOptionsAmount = $visibleOptions.length
+
+ for (let i = 0; i < visibleOptionsAmount - 1; ++i) {
+ cy.findByRole('combobox').trigger('keydown', { key: 'ArrowDown', force: true })
+
+ if (i === visibleOptionsAmount - 2) {
+ cy.wrap(i).as('lastVisibleOptionIndex')
+ }
+ }
+ })
+
+ cy.get('@lastVisibleOptionIndex').then((lastVisibleOptionIndex) => {
+ cy
+ .get('[role="option"]')
+ .eq(lastVisibleOptionIndex + 1) // 1-based
+ .invoke('attr', 'aria-selected')
+ .should('equal', 'true')
+ })
+
+ // When the PageDown key is pressed
+ cy.findByRole('combobox').trigger('keydown', { key: 'PageDown', force: true })
+
+ // Then the next page is shown
+ cy.get('@lastVisibleOptionIndex').then((lastVisibleOptionIndex) => {
+ cy.get('[role="option"]').eq(lastVisibleOptionIndex + 1).should(
+ 'not.be.visible'
+ )
+ cy.get('[role="option"]').eq(lastVisibleOptionIndex + 2).should(
+ 'be.visible'
+ )
+ })
+
+ // And the last option on the next page is highlighted
+ cy
+ .get('[role="option"]:visible')
+ .last()
+ .invoke('attr', 'aria-selected')
+ .should('equal', 'true')
+
+ // And the previously highlighted option is not visible
+ cy.get('@lastVisibleOptionIndex').then((lastVisibleOptionIndex) => {
+ cy
+ .get('[role="option"]')
+ .eq(lastVisibleOptionIndex + 1)
+ .invoke('attr', 'aria-selected')
+ .should('equal', 'false')
+ })
+ }
+ )
+
+ it(
+ 'should highlight the last option',
+ // We don't want the options to scroll when we check whether they're
+ // visible or not (as that'd make them visible)
+ { scrollBehavior: false },
+ () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // And the 2nd-last option is being highlighted and visible
+ for (
+ let i = 0;
+ i < 11; // This will bring us to the second last option exactly
+ ++i
+ ) {
+ cy.findByRole('combobox').trigger('keydown', { key: 'PageDown', force: true })
+ }
+ cy.findAllByRole('option').eq(98).should('be.visible')
+
+ // And the last option is not visible
+ cy.findAllByRole('option').last().should('not.be.visible')
+
+ // When the PageDown key is pressed
+ cy.findByRole('combobox').trigger('keydown', { key: 'PageDown', force: true })
+
+ // Then the last option is highlighted
+ cy.all(
+ () => cy.findAllByRole('option').last().invoke('get', 0),
+ () => cy.findByRole('option', { selected: true }).invoke('get', 0)
+ ).then(([lastOption, highlightedOption]) => {
+ expect(highlightedOption).to.equal(lastOption)
+ })
+
+ // And the last option is visible
+ cy.findAllByRole('option').last().should('be.visible')
+ }
+ )
+})
diff --git a/components/select/src/single-select-a11y/menu/option.js b/components/select/src/single-select-a11y/menu/option.js
index 4ad2a5ef8..bcc3837d6 100644
--- a/components/select/src/single-select-a11y/menu/option.js
+++ b/components/select/src/single-select-a11y/menu/option.js
@@ -74,7 +74,7 @@ export function Option({
data-test={dataTest}
disabled={disabled}
role="option"
- aria-selected={highlighted || ''}
+ aria-selected={highlighted || 'false'}
aria-disabled={disabled}
aria-label={label}
onClick={() => {
diff --git a/components/select/src/single-select-a11y/single-select-a11y.e2e.stories.js b/components/select/src/single-select-a11y/single-select-a11y.e2e.stories.js
index df78e235f..2785ae080 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.e2e.stories.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.e2e.stories.js
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, { useState } from 'react'
import { SingleSelectA11y } from './single-select-a11y.js'
export default {
@@ -102,7 +102,7 @@ const fiveOptions = options.slice(0, 5)
export const DefaultPosition = () => (
null}
options={fiveOptions}
/>
@@ -112,7 +112,7 @@ export const FlippedPosition = () => (
<>
null}
options={options}
/>
@@ -136,7 +136,7 @@ export const ShiftedIntoView = () => (
<>
null}
options={options}
/>
@@ -155,3 +155,21 @@ export const ShiftedIntoView = () => (
`}
>
)
+
+const hundretOptions = Array.apply(null, Array(100)).map((x, i) => ({
+ value: `${i}`,
+ label: `Select option ${i}`,
+}))
+
+export const HundretOptions = () => {
+ const [value, setValue] = useState('0')
+
+ return (
+
+ )
+}
diff --git a/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js b/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js
index 53fe672cd..9f87eaeb2 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js
@@ -427,35 +427,20 @@ export const WithOptionsAndLoadingText = () => {
}
export const WithManyOptions = () => {
- // const [value, setValue] = useState('art_entry_point:_no_pmtct')
- const [value, setValue] = useState('10')
- const selectOptions = Array.apply(null, Array(100)).map((x, i) => i)
+ const [value, setValue] = useState('art_entry_point:_no_pmtct')
return (
- <>
- option.value === value).label
- // : ''
- // }
- onChange={(nextValue) => setValue(nextValue)}
- options={selectOptions.map((i) => ({
- value: i.toString(),
- label: `Select option ${i + 1}`,
- }))}
- />
-
-
- >
+ option.value === value).label
+ : ''
+ }
+ onChange={(nextValue) => setValue(nextValue)}
+ options={options}
+ />
)
}
diff --git a/components/select/src/single-select-a11y/single-select-a11y.test.js b/components/select/src/single-select-a11y/single-select-a11y.test.js
index 2bb282378..99a8bafa3 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.test.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.test.js
@@ -425,14 +425,13 @@ describe('', () => {
expect(combobox).toContainElement(withTextBar)
})
- /**************************
+ /***************************
* *
* ===================== *
* Keyboard interactions *
* ===================== *
* *
**************************/
-
describe.each([
{ key: ' ' },
{ key: 'Enter' },
@@ -579,8 +578,168 @@ describe('', () => {
expect(onChange).toHaveBeenCalledWith('foo')
})
+ it('should highlight the next option', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the first option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', { selected: true })
+ expect(highlightedOptionBefore.attributes.getNamedItem('aria-label').value).toBe('None')
+
+ // The second option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'ArrowDown' })
+ const highlightedOptionAfter = screen.getByRole('option', { selected: true })
+ expect(highlightedOptionAfter.attributes.getNamedItem('aria-label').value).toBe('Foo')
+ })
+
+ it('should highlight the previous option', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the last option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', { selected: true })
+ expect(highlightedOptionBefore.attributes.getNamedItem('aria-label').value).toBe('Bar')
+
+ // The second option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'ArrowUp' })
+ const highlightedOptionAfter = screen.getByRole('option', { selected: true })
+ expect(highlightedOptionAfter.attributes.getNamedItem('aria-label').value).toBe('Foo')
+ })
+
+ it('should highlight the first option', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the last option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', { selected: true })
+ expect(highlightedOptionBefore.attributes.getNamedItem('aria-label').value).toBe('Bar')
+
+ // The first option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'Home' })
+ const highlightedOptionAfter = screen.getByRole('option', { selected: true })
+ expect(highlightedOptionAfter.attributes.getNamedItem('aria-label').value).toBe('None')
+ })
+
+ it('should highlight the last option', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the first option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', { selected: true })
+ expect(highlightedOptionBefore.attributes.getNamedItem('aria-label').value).toBe('None')
+
+ // The last option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'End' })
+ const highlightedOptionAfter = screen.getByRole('option', { selected: true })
+ expect(highlightedOptionAfter.attributes.getNamedItem('aria-label').value).toBe('Bar')
+ })
+
+ it.only('should highlight the first option on the page', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the 70th option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', { selected: true })
+ expect(highlightedOptionBefore.attributes.getNamedItem('aria-label').value).toBe('Select option 70')
+
+ // Highlight the 4th option on the page by going down three times
+ fireEvent.keyDown(comboBox, { key: 'ArrowDown' })
+ fireEvent.keyDown(comboBox, { key: 'ArrowDown' })
+ fireEvent.keyDown(comboBox, { key: 'ArrowDown' })
+
+ // The last option should be highlighted
+ // fireEvent.keyDown(comboBox, { key: 'End' })
+ // const highlightedOptionAfter = screen.getByRole('option', { selected: true })
+ // expect(highlightedOptionAfter.attributes.getNamedItem('aria-label').value).toBe('Bar')
+ })
+
// @TODO
- it.skip('should move up an entire page', () => {})
it.skip('should move up half a page to the first option', () => {})
it.skip('should move down an entire page', () => {})
it.skip('should move down half a page to the last option', () => {})
diff --git a/cypress.config.js b/cypress.config.js
index 17f05f725..52bb6bf9f 100644
--- a/cypress.config.js
+++ b/cypress.config.js
@@ -15,7 +15,7 @@ module.exports = defineConfig({
e2e: {
setupNodeEvents,
baseUrl: 'http://localhost:5000',
- specPattern: '**/src/**/*.feature',
+ specPattern: '**/src/**/*{.feature,.test.e2e.js}',
experimentalRunAllSpecs: true,
},
})