diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9952fc2..607aa1c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,8 +12,8 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 - name: Install dependencies - run: yarn + run: yarn install --frozen-lockfile - name: Install playwright browsers run: npx playwright install --with-deps - name: Run tests - run: npx playwright test \ No newline at end of file + run: ENV=CI npx playwright test \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 040179b..97b775e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.7.0 - 2025-11-07 + +- Remove non accessible keyboard shortcuts for Accordion component +- Add new boolean setting `closeOnBlur` for Dropdown component. If `true`, the expanded list will be closed on button blur. + ## 1.6.2 - 2024-12-17 - Add new event `onInit` option for Accordion component. diff --git a/examples/accessible-dropdown/README.md b/examples/accessible-dropdown/README.md index 8482196..1e4b6be 100644 --- a/examples/accessible-dropdown/README.md +++ b/examples/accessible-dropdown/README.md @@ -73,19 +73,20 @@ Dropdown.initFromPreset(); ### Options -| name | type | default | description | -|------------------------|---------------------------|--------------------|-------------------------------------------------| +| name | type | default | description | +|------------------------|---------------------------|--------------------|----------------------------------------------------------------------------------------------------------------| | `automaticSelection` | boolean \|\| string | `false` | if `true`, first item is automatically selected, if `string`, check if element exists and it will be selected. | -| `buttonSelector` | string | `button` | Button selector. | -| `labelSelector` | string | `.dropdown__label` | Label selector. | -| `listSelector` | string | `ul` | Listbox selector. | -| `mediaQuery` | null or matchMedia object | `null` | Set dropdown for a specific media query. | -| `nonSelectedItemLabel` | string | `No item selected` | Default button text if no items are selected. | -| `onChange` | null or function | `null` | Event on dropdown change. | -| `onClose` | null or function | `null` | Event on dropdown close. | -| `onListItemClick` | null or function | `null` | Event on dropdown list item click. | -| `onOpen` | null or function | `null` | Event on dropdown open. | -| `prefixId` | string | `dropdown` | Define the prefix id of the component. | +| `buttonSelector` | string | `button` | Button selector. | +| `closeOnBlur` | boolean | `false` | if `true`, the dropdown closes automatically when the button loses focus. | +| `labelSelector` | string | `.dropdown__label` | Label selector. | +| `listSelector` | string | `ul` | Listbox selector. | +| `mediaQuery` | null or matchMedia object | `null` | Set dropdown for a specific media query. | +| `nonSelectedItemLabel` | string | `No item selected` | Default button text if no items are selected. | +| `onChange` | null or function | `null` | Event on dropdown change. | +| `onClose` | null or function | `null` | Event on dropdown close. | +| `onListItemClick` | null or function | `null` | Event on dropdown list item click. | +| `onOpen` | null or function | `null` | Event on dropdown open. | +| `prefixId` | string | `dropdown` | Define the prefix id of the component. | ### Methods diff --git a/examples/accessible-dropdown/index.html b/examples/accessible-dropdown/index.html index 54aa8bc..d6b298f 100644 --- a/examples/accessible-dropdown/index.html +++ b/examples/accessible-dropdown/index.html @@ -10,15 +10,15 @@

Accessible Dropdown

- +

Back to components list

- +

Demo

Non automatic selection

- +

Action buttons

- + +

Action buttons

+ + + - +

Code

- +

See the Pen Accessible Collapsible Dropdown Listbox by Be API (@beapi) @@ -114,12 +128,12 @@

Code

- + - \ No newline at end of file + diff --git a/package.json b/package.json index 8bb5ed1..e8a85c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@beapi/be-a11y", - "version": "1.6.2", + "version": "1.7.0", "type": "module", "description": "Collection of usefull accessible components", "repository": { diff --git a/src/classes/Accordion.js b/src/classes/Accordion.js index 2ec8dcd..cbc3143 100644 --- a/src/classes/Accordion.js +++ b/src/classes/Accordion.js @@ -26,11 +26,6 @@ class Accordion extends AbstractDomElement { this._handleButtonBlur = handleButtonBlur.bind(this) this._handleButtonFocus = handleButtonFocus.bind(this) this._handleButtonClick = handleButtonClick.bind(this) - this._handleKeydown = handleKeydown.bind(this) - this._focusPreviousTab = focusPreviousTab.bind(this) - this._focusNextTab = focusNextTab.bind(this) - this._focusFirstTab = focusFirstTab.bind(this) - this._focusLastTab = focusLastTab.bind(this) new ThrottledEvent(window, 'resize').add('resize', this._onResizeHandler) this._onResizeHandler() @@ -119,8 +114,6 @@ class Accordion extends AbstractDomElement { trigger.addEventListener('focus', this._handleButtonFocus) trigger.addEventListener('blur', this._handleButtonBlur) }) - - document.addEventListener('keydown', this._handleKeydown) } /** @@ -161,8 +154,6 @@ class Accordion extends AbstractDomElement { }) super.destroy() - - document.addEventListener('keydown', this._handleKeydown) } /** @@ -181,21 +172,10 @@ class Accordion extends AbstractDomElement { this.activePanel = panel - const firstFocusableElement = panel.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - )[0] - if (this._settings.hasAnimation && window.getComputedStyle(panel).display === 'none') { - DOMAnimations.slideDown(panel, 500, () => { - if (firstFocusableElement) { - firstFocusableElement.focus() - } - }) + DOMAnimations.slideDown(panel, 500) } else { panel.style.display = 'block' - if (firstFocusableElement) { - firstFocusableElement.focus() - } } return @@ -303,106 +283,6 @@ function handleButtonClick(e) { } } -/** - * Handle keyboard keydown - * - * @param {KeyboardEvent} e Keyboard keydown event - * - * @returns {void} - * - * @author Milan Ricoul - */ -function handleKeydown(e) { - if (!this.focus) { - return - } - - switch (e.code) { - case 'ArrowUp': - e.preventDefault() - this._focusPreviousTab() - break - case 'ArrowDown': - e.preventDefault() - this._focusNextTab() - break - case 'Home': - e.preventDefault() - this._focusFirstTab() - break - case 'End': - e.preventDefault() - this._focusLastTab() - break - } -} - -/** - * Focus the previous trigger. If not previous trigger, focus the last trigger. - * - * @returns {void} - * - * @author Milan Ricoul - */ -function focusPreviousTab() { - const s = this._settings - const activeElement = document.activeElement - const triggers = this._element.querySelectorAll(s.triggerSelector) - const triggersCount = triggers.length - - if (activeElement.classList.contains(s.triggerSelector.substring(1))) { - const currentIndexOfActiveElement = Array.prototype.indexOf.call(triggers, activeElement) - - triggers[currentIndexOfActiveElement === 0 ? triggersCount - 1 : currentIndexOfActiveElement - 1].focus() - } -} - -/** - * Focus the next trigger. If not next trigger, focus the first trigger. - * - * @returns {void} - * - * @author Milan Ricoul - */ -function focusNextTab() { - const s = this._settings - const activeElement = document.activeElement - const triggers = this._element.querySelectorAll(s.triggerSelector) - const triggersCount = triggers.length - - if (activeElement.classList.contains(s.triggerSelector.substring(1))) { - const currentIndexOfActiveElement = Array.prototype.indexOf.call(triggers, activeElement) - - triggers[currentIndexOfActiveElement === triggersCount - 1 ? 0 : currentIndexOfActiveElement + 1].focus() - } -} - -/** - * Focus the first trigger. - * - * @returns {void} - * - * @author Milan Ricoul - */ -function focusFirstTab() { - this._element.querySelectorAll(this._settings.triggerSelector)[0].focus() -} - -/** - * Focus the last trigger. - * - * @returns {void} - * - * @author Milan Ricoul - */ -function focusLastTab() { - const s = this._settings - const triggers = this._element.querySelectorAll(s.triggerSelector) - const triggersCount = triggers.length - - triggers[triggersCount - 1].focus() -} - /** * Events * diff --git a/src/classes/Accordion.test.js b/src/classes/Accordion.test.js index 4b586a7..04138f1 100644 --- a/src/classes/Accordion.test.js +++ b/src/classes/Accordion.test.js @@ -5,10 +5,10 @@ test.describe('Accordion', () => { await page.goto('http://localhost:5173/examples/accessible-accordion/index.html') }) - test('Click the first tab accordion, expect the first focusable element in the panel is focused.', async ({page}) => { + test('Click the first accordion button, expect this accordion button is still focused.', async ({page}) => { const id = await page.locator('#accordion-demo-1').getAttribute('data-id') await page.click(`#accordion-${id}-1`) - await expect(page.locator('#cufc1-1')).toBeFocused() + await expect(page.locator(`#accordion-${id}-1`)).toBeFocused() }) test('Click the second tab accordion, expect the second panel is visible.', async ({page}) => { @@ -21,61 +21,4 @@ test.describe('Accordion', () => { expect(display).toBe('block') }) - - test('Press "ArrowDown" key when the first tab accordion is focused, expect the second tab accordion is focused.', async ({page}) => { - const id = await page.locator('#accordion-demo-1').getAttribute('data-id') - await page.focus(`#accordion-${id}-1`) - await page.keyboard.press('ArrowDown') - const secondAccordionTab = await page.$(`#accordion-${id}-2`) - - expect(await page.evaluate(elem => window.document.activeElement === elem, secondAccordionTab)).toEqual(true) - }) - - test('Press "ArrowUp" key when the first tab accordion is focused, expect the last tab accordion is focused.', async ({page}) => { - const id = await page.locator('#accordion-demo-1').getAttribute('data-id') - await page.focus(`#accordion-${id}-1`) - await page.keyboard.press('ArrowUp') - const lastAccordionTab = await page.$(`#accordion-${id}-3`) - - expect(await page.evaluate(elem => window.document.activeElement === elem, lastAccordionTab)).toEqual(true) - }) - - test('Press "ArrowUp" key when the last tab accordion is focused, expect the second tab accordion is focused.', async ({page}) => { - const id = await page.locator('#accordion-demo-1').getAttribute('data-id') - await page.focus(`#accordion-${id}-3`) - await page.keyboard.press('ArrowUp') - const secondAccordionTab = await page.$(`#accordion-${id}-2`) - - expect(await page.evaluate(elem => window.document.activeElement === elem, secondAccordionTab)).toEqual(true) - }) - - test('Press "ArrowDown" key when the last tab accordion is focused, expect the first tab accordion is focused.', async ({page}) => { - const id = await page.locator('#accordion-demo-1').getAttribute('data-id') - - await page.focus(`#accordion-${id}-3`) - await page.keyboard.press('ArrowDown') - const firstAccordionTab = await page.$(`#accordion-${id}-1`) - - expect(await page.evaluate(elem => window.document.activeElement === elem, firstAccordionTab)).toEqual(true) - }) - - test('Press "Home" key when the last tab accordion is focused, expect the first tab accordion is focused.', async ({page}) => { - const id = await page.locator('#accordion-demo-1').getAttribute('data-id') - - await page.focus(`#accordion-${id}-3`) - await page.keyboard.press('Home') - const firstAccordionTab = await page.$(`#accordion-${id}-1`) - - expect(await page.evaluate(elem => window.document.activeElement === elem, firstAccordionTab)).toEqual(true) - }) - - test('Press "End" key when the first tab accordion is focused, expect the last tab accordion is focused.', async ({page}) => { - const id = await page.locator('#accordion-demo-1').getAttribute('data-id') - - await page.focus(`#accordion-${id}-1`) - await page.keyboard.press('End') - const lastAccordionTab = await page.$(`#accordion-${id}-3`) - - expect(await page.evaluate(elem => window.document.activeElement === elem, lastAccordionTab)).toEqual(true) - }) }) diff --git a/src/classes/Dropdown.js b/src/classes/Dropdown.js index c37a67f..d7813f9 100644 --- a/src/classes/Dropdown.js +++ b/src/classes/Dropdown.js @@ -24,6 +24,7 @@ class Dropdown extends AbstractDomElement { this._onResize = onResize.bind(this) this._handleKeydown = handleKeydown.bind(this) this._handleButtonClick = handleButtonClick.bind(this) + this._handleButtonBlur = handleButtonBlur.bind(this) this._handleListItemClick = handleListItemClick.bind(this) this._handleOutsideElementClick = handleOutsideElementClick.bind(this) this._focusPreviousElement = focusPreviousElement.bind(this) @@ -52,7 +53,7 @@ class Dropdown extends AbstractDomElement { this.active = true const el = this._element - const { automaticSelection, buttonSelector, labelSelector, listClassName, listSelector } = this._settings + const { automaticSelection, buttonSelector, closeOnBlur, labelSelector, listClassName, listSelector } = this._settings const buttonId = `${this.id}-button` const labelId = `${this.id}-label` @@ -111,6 +112,10 @@ class Dropdown extends AbstractDomElement { } } + if (closeOnBlur) { + this.button.addEventListener('blur', this._handleButtonBlur) + } + this.button.addEventListener('click', this._handleButtonClick) document.addEventListener('click', this._handleOutsideElementClick) document.addEventListener('keydown', this._handleKeydown) @@ -322,6 +327,19 @@ function handleButtonClick() { this.opened ? this.close() : this.open() } +/** + * Handle button blur on dropdown button + * + * @author Milan Ricoul + */ +function handleButtonBlur() { + if (!this.opened) { + return + } + + this.close() +} + /** * Handle list items click * @@ -475,7 +493,7 @@ function focusLastElement() { * @returns {void} */ function handleOutsideElementClick(e) { - if (this.opened && !this._element.contains(e.target)) { + if (this.opened && !this._element.contains(e.target) && !this._settings.closeOnBlur) { this.close(this.id) } } @@ -492,6 +510,7 @@ function onResize() { Dropdown.defaults = { automaticSelection: false, buttonSelector: 'button', + closeOnBlur: false, labelSelector: '.dropdown__label', listClassName: 'dropdown__list', listSelector: 'ul', diff --git a/src/classes/Dropdown.test.js b/src/classes/Dropdown.test.js index 6830d08..e046727 100644 --- a/src/classes/Dropdown.test.js +++ b/src/classes/Dropdown.test.js @@ -65,17 +65,26 @@ test.describe('Dropdown', () => { expect(display).toBe('none') }) + test('Focus the dropdown button, click on the body, expect the listbox is not visible.', async ({ page }) => { + await page.focus('#dropdown-6 button') + await page.keyboard.down('Enter') + await page.click('body') + const display = await page.$eval('#dropdown-6 ul', (listbox) => window.getComputedStyle(listbox).display) + + expect(display).toBe('none') + }) + test('Click on "Add item" button, expect there is a Dummy list item.', async ({ page }) => { await page.click('#add') - const lastItemText = await page.locator('#dropdown-6 li').last().textContent() + const lastItemText = await page.locator('#dropdown-7 li').last().textContent() expect(lastItemText).toBe('Dummy') }) test('Click on "Remove first item" button, expect the new first item is "Movies".', async ({ page }) => { await page.click('#remove') - const firstItemText = await page.locator('#dropdown-6 li').first().textContent() + const firstItemText = await page.locator('#dropdown-7 li').first().textContent() expect(firstItemText).toBe('Movies') }) @@ -83,7 +92,7 @@ test.describe('Dropdown', () => { await page.click('#remove-all') const isListItemsEmpty = await page - .locator('#dropdown-6 ul') + .locator('#dropdown-7 ul') .evaluate((element) => element.textContent.trim() === '') expect(isListItemsEmpty).toBe(true) })