From bc54b0055cba2514b2552d11e9181e0f9684b5a9 Mon Sep 17 00:00:00 2001 From: OsamaSayegh Date: Tue, 22 Mar 2022 11:08:31 +0300 Subject: [PATCH] A11Y: Improve topic entrance modal Clicking the Replies cell of a topic in a topics list shows a little modal with 2 buttons that take you to the first and last posts of the topic. This modal is currently completely inaccessible to keyboard/screen reader users because it can't be reached using the keyboard. This commit improves the modal so that it traps focus when it's shown and makes it possible to close the modal using the esc key. --- .../app/components/topic-entrance.js | 62 ++++++++++++++++++- .../templates/components/topic-entrance.hbs | 4 +- .../tests/acceptance/topic-entrance-test.js | 26 ++++++++ config/locales/client.en.yml | 3 + 4 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 app/assets/javascripts/discourse/tests/acceptance/topic-entrance-test.js diff --git a/app/assets/javascripts/discourse/app/components/topic-entrance.js b/app/assets/javascripts/discourse/app/components/topic-entrance.js index 2b73f4de6e9..6cfe3d4fb6a 100644 --- a/app/assets/javascripts/discourse/app/components/topic-entrance.js +++ b/app/assets/javascripts/discourse/app/components/topic-entrance.js @@ -2,7 +2,7 @@ import CleansUp from "discourse/mixins/cleans-up"; import Component from "@ember/component"; import DiscourseURL from "discourse/lib/url"; import I18n from "I18n"; -import discourseComputed from "discourse-common/utils/decorators"; +import discourseComputed, { bind } from "discourse-common/utils/decorators"; import { scheduleOnce } from "@ember/runloop"; function entranceDate(dt, showTime) { @@ -31,9 +31,11 @@ function entranceDate(dt, showTime) { export default Component.extend(CleansUp, { elementId: "topic-entrance", classNameBindings: ["visible::hidden"], - _position: null, topic: null, visible: null, + _position: null, + _originalActiveElement: null, + _activeButton: null, @discourseComputed("topic.created_at") createdDate: (createdAt) => new Date(createdAt), @@ -74,12 +76,64 @@ export default Component.extend(CleansUp, { $self.css(pos); }, + @bind + _escListener(e) { + if (e.key === "Escape") { + this.cleanUp(); + } else if (e.key === "Tab") { + if (this._activeButton === "top") { + this._jumpBottomButton().focus(); + this._activeButton = "bottom"; + e.preventDefault(); + } else if (this._activeButton === "bottom") { + this._jumpTopButton().focus(); + this._activeButton = "top"; + e.preventDefault(); + } + } + }, + + _jumpTopButton() { + return this.element.querySelector(".jump-top"); + }, + + _jumpBottomButton() { + return this.element.querySelector(".jump-bottom"); + }, + + _setupEscListener() { + document.body.addEventListener("keydown", this._escListener); + }, + + _removeEscListener() { + document.body.removeEventListener("keydown", this._escListener); + }, + + _trapFocus() { + this._originalActiveElement = document.activeElement; + this._jumpTopButton().focus(); + this._activeButton = "top"; + }, + + _releaseFocus() { + if (this._originalActiveElement) { + this._originalActiveElement.focus(); + this._originalActiveElement = null; + } + }, + + _applyDomChanges() { + this._setCSS(); + this._setupEscListener(); + this._trapFocus(); + }, + _show(data) { this._position = data.position; this.setProperties({ topic: data.topic, visible: true }); - scheduleOnce("afterRender", this, this._setCSS); + scheduleOnce("afterRender", this, this._applyDomChanges); $("html") .off("mousedown.topic-entrance") @@ -98,6 +152,8 @@ export default Component.extend(CleansUp, { cleanUp() { this.setProperties({ topic: null, visible: false }); $("html").off("mousedown.topic-entrance"); + this._removeEscListener(); + this._releaseFocus(); }, willDestroyElement() { diff --git a/app/assets/javascripts/discourse/app/templates/components/topic-entrance.hbs b/app/assets/javascripts/discourse/app/templates/components/topic-entrance.hbs index c6e9c5cae2a..b7947a4f145 100644 --- a/app/assets/javascripts/discourse/app/templates/components/topic-entrance.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/topic-entrance.hbs @@ -1,7 +1,7 @@ -{{#d-button action=(action "enterTop") class="btn-default full jump-top"}} +{{#d-button action=(action "enterTop") class="btn-default full jump-top" ariaLabel="topic_entrance.sr_jump_top_button"}} {{d-icon "step-backward"}} {{html-safe topDate}} {{/d-button}} -{{#d-button action=(action "enterBottom") class="btn-default full jump-bottom"}} +{{#d-button action=(action "enterBottom") class="btn-default full jump-bottom" ariaLabel="topic_entrance.sr_jump_bottom_button"}} {{html-safe bottomDate}} {{d-icon "step-forward"}} {{/d-button}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-entrance-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-entrance-test.js new file mode 100644 index 00000000000..2839284f4e7 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-entrance-test.js @@ -0,0 +1,26 @@ +import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; +import { click, triggerKeyEvent, visit } from "@ember/test-helpers"; +import { test } from "qunit"; + +const ESC_KEYCODE = 27; +acceptance("Topic Entrance Modal", function () { + test("can be closed with the esc key", async function (assert) { + await visit("/"); + await click(".topic-list-item button.posts-map"); + const topicEntrance = query("#topic-entrance"); + assert.ok( + !topicEntrance.classList.contains("hidden"), + "topic entrance modal appears" + ); + assert.equal( + document.activeElement, + topicEntrance.querySelector(".jump-top"), + "the jump top button has focus when the modal is shown" + ); + await triggerKeyEvent(topicEntrance, "keydown", ESC_KEYCODE); + assert.ok( + topicEntrance.classList.contains("hidden"), + "topic entrance modal disappears after pressing esc" + ); + }); +}); diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 193086e544d..0a85b4f345d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3987,6 +3987,9 @@ en: no_group_messages_title: "No group messages found" + topic_entrance: + sr_jump_top_button: "Jump to the first post" + sr_jump_bottom_button: "Jump to the last post" fullscreen_table: expand_btn: "Expand Table"