From 03e556c685f2cad8e6057ed6082f9bc601f6ea1b Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Tue, 22 Oct 2024 17:12:57 +0800 Subject: [PATCH] test(select a11y): add pageUp/pageDown tests (cypress) for --- collections/forms/i18n/en.pot | 4 +- .../keyboard-interactions.test.e2e.js | 256 ++++++++++++++++++ .../src/single-select-a11y/menu/option.js | 2 +- .../single-select-a11y.e2e.stories.js | 26 +- .../single-select-a11y.prod.stories.js | 41 +-- .../single-select-a11y.test.js | 167 +++++++++++- cypress.config.js | 2 +- 7 files changed, 456 insertions(+), 42 deletions(-) create mode 100644 components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot index d03caf8462..c99afb8eb0 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 0000000000..993dd3731f --- /dev/null +++ b/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js @@ -0,0 +1,256 @@ +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 4ad2a5ef82..bcc3837d6e 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 df78e235fb..2785ae0807 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 53fe672cdb..a1c11677f9 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 @@ -394,6 +394,7 @@ export const WithOptionsAndLoading = () => { } onChange={(nextValue) => setValue(nextValue)} options={[ + { label: 'None', value: '' }, { value: '1', label: 'Option 1' }, { value: '2', label: 'Option 2' }, { value: '3', label: 'Option 3' }, @@ -418,6 +419,7 @@ export const WithOptionsAndLoadingText = () => { } onChange={(nextValue) => setValue(nextValue)} options={[ + { label: 'None', value: '' }, { value: '1', label: 'Option 1' }, { value: '2', label: 'Option 2' }, { value: '3', label: 'Option 3' }, @@ -427,35 +429,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 2bb2823788..15b12209d8 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,9 +578,163 @@ describe('', () => { expect(onChange).toHaveBeenCalledWith('foo') }) - // @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', () => {}) + 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') + }) }) diff --git a/cypress.config.js b/cypress.config.js index 17f05f7253..52bb6bf9f2 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, }, })