diff --git a/apps/core/tests/test_feedback.py b/apps/core/tests/test_feedback.py
index bf8dbe74..9288cf20 100644
--- a/apps/core/tests/test_feedback.py
+++ b/apps/core/tests/test_feedback.py
@@ -88,6 +88,16 @@ def test_update_feedback_happy(self):
self.assertEqual(feedback_obj.feedback, "happy")
self.assertEqual(feedback_obj.page.specific, self.target_page)
+ def test_status_region_rendered_before_feedback(self):
+ # The confirmation message is announced through this live region, so it
+ # needs to be on the page before the feedback buttons are used.
+ res = self.client.get(self.target_page.url)
+ self.assertContains(
+ res,
+ '
',
+ html=True,
+ )
+
def test_csrf_token_exists(self):
cookie = self.client.cookies.get(settings.CSRF_COOKIE_NAME)
self.assertIsNone(cookie)
diff --git a/apps/frontend/static_src/js/active-nav-item/index.js b/apps/frontend/static_src/js/active-nav-item/index.js
index 677379fb..c88867df 100644
--- a/apps/frontend/static_src/js/active-nav-item/index.js
+++ b/apps/frontend/static_src/js/active-nav-item/index.js
@@ -3,6 +3,7 @@ export const initActiveNavItem = () => {
links.forEach((link) => {
if (new URL(link.href).pathname === window.location.pathname) {
link.classList.add('active');
+ link.setAttribute('aria-current', 'page');
}
});
};
diff --git a/apps/frontend/static_src/js/active-nav-item/index.test.js b/apps/frontend/static_src/js/active-nav-item/index.test.js
index 560bed19..c62070d4 100644
--- a/apps/frontend/static_src/js/active-nav-item/index.test.js
+++ b/apps/frontend/static_src/js/active-nav-item/index.test.js
@@ -17,4 +17,12 @@ describe('initActiveNavItem', () => {
expect(concepts.classList.contains('active')).toBe(false);
expect(guides.classList.contains('active')).toBe(true);
});
+
+ it('marks only the link matching the current path as the current page', () => {
+ initActiveNavItem();
+
+ const [concepts, guides] = document.querySelectorAll('.navigation__link');
+ expect(concepts.hasAttribute('aria-current')).toBe(false);
+ expect(guides.getAttribute('aria-current')).toBe('page');
+ });
});
diff --git a/apps/frontend/static_src/js/feedback/index.js b/apps/frontend/static_src/js/feedback/index.js
index 7a2a8fbd..a5aa9858 100644
--- a/apps/frontend/static_src/js/feedback/index.js
+++ b/apps/frontend/static_src/js/feedback/index.js
@@ -17,6 +17,7 @@ export const handleFeedback = () => {
const happyButton = document.querySelector('[data-happy-button]');
const unhappyButton = document.querySelector('[data-unhappy-button]');
const feedbackContainer = document.querySelector('[data-feedback-container]');
+ const feedbackStatus = document.querySelector('[data-feedback-status]');
const additionalFeedbackContainer = document.querySelector(
'[data-additional-feedback-container]',
);
@@ -41,9 +42,11 @@ export const handleFeedback = () => {
tooltipList.forEach((tooltip) => {
tooltip.dispose();
});
- feedbackContainer.innerHTML = `
${gettext(
- 'Thanks for your feedback!',
- )}`;
+ // Announce the confirmation in the live region, and move focus to it
+ // before removing the buttons so focus isn't lost to the page body.
+ feedbackStatus.textContent = gettext('Thanks for your feedback!');
+ feedbackStatus.focus();
+ feedbackContainer.remove();
feedbackPk = data.pk;
additionalFeedbackContainer.classList.add('active');
} catch (err) {
@@ -64,6 +67,7 @@ export const handleFeedback = () => {
'Content-type': 'application/json; charset=UTF-8',
},
});
+ feedbackStatus.focus();
additionalFeedbackContainer.innerHTML = '';
} catch (err) {
console.log(err);
diff --git a/apps/frontend/static_src/js/feedback/index.test.js b/apps/frontend/static_src/js/feedback/index.test.js
new file mode 100644
index 00000000..0f6b272d
--- /dev/null
+++ b/apps/frontend/static_src/js/feedback/index.test.js
@@ -0,0 +1,86 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vite-plus/test';
+import { handleFeedback } from '.';
+
+describe('handleFeedback', () => {
+ let fetchMock;
+
+ beforeEach(() => {
+ vi.stubGlobal('gettext', (text) => text);
+ fetchMock = vi.fn(async () => ({ json: async () => ({ pk: 1 }) }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ document.body.innerHTML = `
+
+
+
+
Was this page helpful?
+
+
+
+
+
+
+
+
+
+
+ `;
+ handleFeedback();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.unstubAllGlobals();
+ });
+
+ it.each(['happy', 'unhappy'])(
+ 'announces %s feedback in the existing status region and moves focus to it',
+ async (feedback) => {
+ const status = document.querySelector('[role="status"]');
+ const button = document.querySelector(`[data-${feedback}-button]`);
+ button.focus();
+ button.click();
+
+ await vi.waitFor(() => {
+ expect(status.textContent).toBe('Thanks for your feedback!');
+ });
+ // The same element that was on the page before the update, so screen
+ // readers already track it as a live region.
+ expect(status.isConnected).toBe(true);
+ expect(document.activeElement).toBe(status);
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
+ feedback,
+ });
+ },
+ );
+
+ it('keeps focus on the confirmation after submitting a comment', async () => {
+ const status = document.querySelector('[role="status"]');
+ document.querySelector('[data-unhappy-button]').click();
+ await vi.waitFor(() => {
+ expect(document.activeElement).toBe(status);
+ });
+
+ document.querySelector('[data-feedback-text]').value = 'Add a screenshot';
+ const submit = document.querySelector('[data-submit-button]');
+ submit.focus();
+ submit.click();
+
+ await vi.waitFor(() => {
+ expect(submit.isConnected).toBe(false);
+ });
+ expect(document.activeElement).toBe(status);
+ expect(status.isConnected).toBe(true);
+ expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({
+ pk: 1,
+ feedback_text: 'Add a screenshot',
+ });
+ });
+});
diff --git a/apps/frontend/static_src/scss/components/feedback.scss b/apps/frontend/static_src/scss/components/feedback.scss
index 530e0131..5a84df38 100644
--- a/apps/frontend/static_src/scss/components/feedback.scss
+++ b/apps/frontend/static_src/scss/components/feedback.scss
@@ -33,6 +33,11 @@
width: 42px;
}
+ &__status {
+ text-align: center;
+ margin-bottom: $gutter;
+ }
+
&__form {
visibility: hidden;
opacity: 0;