From e8aea3c55801ab9b3dd9df5229b489efc8cef96c Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Tue, 9 May 2023 09:46:05 -0400 Subject: [PATCH] A11Y: Improve select-kit accessibility (#21400) This improves keyboard navigation in and out of select-kit components. The improvements include: - `Tab` will now dismiss the dropdown once the active element is outside the select-kit element - pressing `Escape` will not bubble, this is most noticeable in the composer, pressing `Esc` there now when a dropdown is expanded will not dismiss the composer - `Shift+Tab` will also dismiss the dropdown once focus is outside it --- .../tests/acceptance/category-edit-test.js | 2 + .../select-kit-accessibility-test.js | 67 +++++++++++++++++++ .../select-kit/single-select-test.js | 50 +++++++++++++- .../components/select-kit/select-kit-body.js | 61 +++++++++-------- .../select-kit/select-kit-filter.js | 2 + .../components/select-kit/select-kit-row.js | 20 +----- 6 files changed, 153 insertions(+), 49 deletions(-) create mode 100644 app/assets/javascripts/discourse/tests/acceptance/select-kit-accessibility-test.js diff --git a/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js b/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js index a315b60e72d..aaf2dda4dd6 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js @@ -114,6 +114,7 @@ acceptance("Category Edit", function (needs) { await allowedTagChooser.expand(); await allowedTagChooser.selectRowByValue("monkey"); + await allowedTagChooser.collapse(); const allowedTagGroupChooser = selectKit("#category-allowed-tag-groups"); await allowedTagGroupChooser.expand(); await allowedTagGroupChooser.selectRowByValue("TagGroup1"); @@ -127,6 +128,7 @@ acceptance("Category Edit", function (needs) { assert.deepEqual(payload.allowed_tags, ["monkey"]); assert.deepEqual(payload.allowed_tag_groups, ["TagGroup1"]); + await allowedTagGroupChooser.collapse(); await allowedTagChooser.expand(); await allowedTagChooser.deselectItemByValue("monkey"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/select-kit-accessibility-test.js b/app/assets/javascripts/discourse/tests/acceptance/select-kit-accessibility-test.js new file mode 100644 index 00000000000..d4940f45231 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/select-kit-accessibility-test.js @@ -0,0 +1,67 @@ +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; +import { click, tab, triggerKeyEvent, visit } from "@ember/test-helpers"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import { test } from "qunit"; + +acceptance("Select-kit - Composer - Accessibility", function (needs) { + needs.user(); + needs.site({ can_tag_topics: true }); + needs.settings({ allow_uncategorized_topics: true }); + + test("tabbing works", async function (assert) { + await visit("/"); + await click("#create-topic"); + + const tagChooser = selectKit(".mini-tag-chooser"); + await tagChooser.expand(); + + assert + .dom(".mini-tag-chooser .filter-input") + .isFocused("it should focus the filter by default"); + + await tab(); + + assert + .dom(".mini-tag-chooser .select-kit-row:first-child") + .isFocused("it should focus the first row next"); + + await tab({ backwards: true }); + + assert + .dom(".mini-tag-chooser .filter-input") + .isFocused("it should focus the filter again when tabbing backwards"); + + await tab({ backwards: true }); + + assert + .dom(".mini-tag-chooser .select-kit-header") + .isFocused("it should focus the tag chooser header next"); + + await tab({ backwards: true }); + + assert + .dom(".category-chooser .select-kit-header") + .isFocused("it should focus the category chooser header next"); + + await tab(); + + assert + .dom(".mini-tag-chooser .select-kit-header") + .isFocused("it should focus the tag chooser again"); + + await tagChooser.expand(); + + await triggerKeyEvent( + ".mini-tag-chooser .select-kit-row:first-child", + "keydown", + "Escape" + ); + + assert.notOk( + exists(".mini-tag-chooser .select-kit-body .select-kit-row"), + "Hitting Escape dismisses the tag chooser" + ); + + assert.ok(exists(".composer-fields"), "Escape does not dismiss composer"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js index c06eeadf84f..299e1038ad7 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js @@ -1,6 +1,6 @@ import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { render } from "@ember/test-helpers"; +import { render, tab } from "@ember/test-helpers"; import I18n from "I18n"; import { hbs } from "ember-cli-htmlbars"; import selectKit from "discourse/tests/helpers/select-kit-helper"; @@ -63,6 +63,54 @@ module("Integration | Component | select-kit/single-select", function (hooks) { ); }); + test("accessibility", async function (assert) { + setDefaultState(this); + + await render(hbs``); + + await this.subject.expand(); + + const content = this.subject.displayedContent(); + assert.strictEqual(content.length, 3, "it shows rows"); + + assert + .dom(".select-kit-header") + .isFocused("it should focus the header first"); + + await tab(); + + assert + .dom(".select-kit-row:first-child") + .isFocused("it should focus the first row next"); + + await tab(); + + assert + .dom(".select-kit-row:nth-child(2)") + .isFocused("tab moves focus to 2nd row"); + + await tab(); + + assert + .dom(".select-kit-row:nth-child(3)") + .isFocused("tab moves focus to 3rd row"); + + await tab(); + + assert.notOk( + this.subject.isExpanded(), + "when there are no more rows, Tab collapses the dropdown" + ); + + await this.subject.expand(); + + assert.ok(this.subject.isExpanded(), "dropdown is expanded again"); + + await tab({ backwards: true }); + + assert.notOk(this.subject.isExpanded(), "Shift+Tab collapses the dropdown"); + }); + test("value", async function (assert) { setDefaultState(this); diff --git a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-body.js b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-body.js index a6a8437fe6b..8cdac072c85 100644 --- a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-body.js +++ b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-body.js @@ -1,5 +1,6 @@ import Component from "@ember/component"; -import { bind } from "@ember/runloop"; +import { bind } from "discourse-common/utils/decorators"; +import { later } from "@ember/runloop"; import { computed } from "@ember/object"; export default Component.extend({ @@ -10,55 +11,53 @@ export default Component.extend({ return false; }), - rootEventType: "click", - - init() { - this._super(...arguments); - - this.handleRootMouseDownHandler = bind(this, this.handleRootMouseDown); - }, - didInsertElement() { this._super(...arguments); this.element.style.position = "relative"; - - document.addEventListener( - this.rootEventType, - this.handleRootMouseDownHandler, - true - ); + document.addEventListener("click", this.handleClick, true); + this.selectKit + .mainElement() + .addEventListener("keydown", this._handleKeydown, true); }, willDestroyElement() { this._super(...arguments); - - document.removeEventListener( - this.rootEventType, - this.handleRootMouseDownHandler, - true - ); + document.removeEventListener("click", this.handleClick, true); + this.selectKit + .mainElement() + .removeEventListener("keydown", this._handleKeydown, true); }, - handleRootMouseDown(event) { - if (!this.selectKit.isExpanded) { + @bind + handleClick(event) { + if (!this.selectKit.isExpanded || !this.selectKit.mainElement()) { return; } - const headerElement = document.querySelector( - `#${this.selectKit.uniqueID}-header` - ); - - if (headerElement && headerElement.contains(event.target)) { + if (this.selectKit.mainElement().contains(event.target)) { return; } - if (this.element.contains(event.target)) { + this.selectKit.close(event); + }, + + @bind + _handleKeydown(event) { + if (!this.selectKit.isExpanded || event.key !== "Tab") { return; } - if (this.selectKit.mainElement()) { + later(() => { + if ( + this.isDestroying || + this.isDestroyed || + this.selectKit.mainElement().contains(document.activeElement) + ) { + return; + } + this.selectKit.close(event); - } + }, 50); }, }); diff --git a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-filter.js b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-filter.js index 56f1fceeac5..47c10fdec3b 100644 --- a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-filter.js +++ b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-filter.js @@ -94,6 +94,8 @@ export default Component.extend(UtilsMixin, { if (event.key === "Escape") { this.selectKit.close(event); this.selectKit.headerElement().focus(); + event.preventDefault(); + event.stopPropagation(); return false; } diff --git a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-row.js b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-row.js index 9f0df11e103..d8a33cfd99d 100644 --- a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-row.js +++ b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-row.js @@ -41,7 +41,6 @@ export default Component.extend(UtilsMixin, { if (!this.site.mobileView) { this.element.addEventListener("mouseenter", this.handleMouseEnter); this.element.addEventListener("focus", this.handleMouseEnter); - this.element.addEventListener("blur", this.handleBlur); } }, @@ -49,9 +48,8 @@ export default Component.extend(UtilsMixin, { this._super(...arguments); if (!this.site.mobileView) { - this.element.removeEventListener("mouseenter", this.handleBlur); + this.element.removeEventListener("mouseenter", this.handleMouseEnter); this.element.removeEventListener("focus", this.handleMouseEnter); - this.element.removeEventListener("blur", this.handleMouseEnter); } }, @@ -134,20 +132,6 @@ export default Component.extend(UtilsMixin, { return false; }, - @action - handleBlur(event) { - if ( - (!this.isDestroying || !this.isDestroyed) && - event.target && - this.selectKit.mainElement() - ) { - if (!this.selectKit.mainElement().contains(event.target)) { - this.selectKit.close(event); - } - } - return false; - }, - click(event) { event.preventDefault(); event.stopPropagation(); @@ -193,6 +177,8 @@ export default Component.extend(UtilsMixin, { } else if (event.key === "Escape") { this.selectKit.close(event); this.selectKit.headerElement().focus(); + event.preventDefault(); + event.stopPropagation(); } else { if (this.isValidInput(event.key)) { this.selectKit.set("filter", event.key);