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"