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, }, })