From 27a3df7d73ae6e99cd740125d42a27bdc8563360 Mon Sep 17 00:00:00 2001 From: Robin Gagnon Date: Tue, 10 Nov 2020 22:38:15 -0500 Subject: [PATCH] A11y: Add keyboard navigation to article feed (#10468) Co-authored-by: Andrew Bone Co-authored-by: Nick Taylor --- .../javascripts/utilities/buildArticleHTML.js | 27 +- .../stylesheets/components/stories.scss | 17 + app/javascript/Search/Search.jsx | 83 ++-- .../Search/__tests__/Search.test.jsx | 8 +- app/javascript/articles/Article.jsx | 7 + app/javascript/articles/Feed.jsx | 7 + .../__snapshots__/Article.test.jsx.snap | 14 + .../packs/homePageFeedShortcuts.jsx | 28 +- .../__tests__/useListNavigation.test.js | 364 ++++++++++++++++++ .../shared/components/useListNavigation.js | 225 +++++++++++ app/javascript/utilities/viewport.js | 24 ++ app/views/articles/_single_story.html.erb | 1 + 12 files changed, 738 insertions(+), 67 deletions(-) create mode 100644 app/javascript/shared/components/__tests__/useListNavigation.test.js create mode 100644 app/javascript/shared/components/useListNavigation.js create mode 100644 app/javascript/utilities/viewport.js diff --git a/app/assets/javascripts/utilities/buildArticleHTML.js b/app/assets/javascripts/utilities/buildArticleHTML.js index 97f345e4b..100381bb2 100644 --- a/app/assets/javascripts/utilities/buildArticleHTML.js +++ b/app/assets/javascripts/utilities/buildArticleHTML.js @@ -267,13 +267,27 @@ function buildArticleHTML(article) { ''; } - return ( - '
+ ${article.title} + + `; + + var articleElement = + '
\ -
\ + '">' + + navigationLink + + '
\ ' + videoHTML + '\ @@ -314,8 +328,9 @@ function buildArticleHTML(article) {
\
\ \ -
' - ); +
'; + + return articleElement; } return ''; diff --git a/app/assets/stylesheets/components/stories.scss b/app/assets/stylesheets/components/stories.scss index 2261d1b66..7ec857ffe 100644 --- a/app/assets/stylesheets/components/stories.scss +++ b/app/assets/stylesheets/components/stories.scss @@ -13,6 +13,13 @@ background: var(--card-bg); font-size: var(--fs-base); // Todo: remove when ready. box-shadow: 0 0 0 1px var(--card-border); + position: relative; + + &:focus-within { + outline: none; + --card-border: var(--accent-brand); + box-shadow: 0 0 0 2px var(--card-border); + } @media (min-width: $breakpoint-m) { border-radius: var(--radius); @@ -33,6 +40,16 @@ } } + &__hidden-navigation-link { + pointer-events: none; + opacity: 0; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + // Defining additional colors. &__secondary { color: var(--card-color-secondary); diff --git a/app/javascript/Search/Search.jsx b/app/javascript/Search/Search.jsx index 64a82d5fe..eea0e21af 100644 --- a/app/javascript/Search/Search.jsx +++ b/app/javascript/Search/Search.jsx @@ -1,5 +1,5 @@ import 'preact/devtools'; -import { Component, h } from 'preact'; +import { h, Component, Fragment } from 'preact'; import PropTypes from 'prop-types'; import { getInitialSearchTerm, @@ -7,10 +7,11 @@ import { preloadSearchResults, displaySearchResults, } from '../utilities/search'; +import { KeyboardShortcuts } from '../shared/components/useKeyboardShortcuts'; import { SearchForm } from './SearchForm'; -const GLOBAL_MINIMIZE_KEY = '0'; -const GLOBAL_SEARCH_KEY = '/'; +const GLOBAL_MINIMIZE_KEY = 'Digit0'; +const GLOBAL_SEARCH_KEY = 'Slash'; const ENTER_KEY = 'Enter'; export class Search extends Component { @@ -72,7 +73,6 @@ export class Search extends Component { } componentDidMount() { - this.registerGlobalKeysListener(); InstantClick.on('change', this.enableSearchPageListener); window.addEventListener('popstate', this.syncSearchUrlWithInput); @@ -112,54 +112,43 @@ export class Search extends Component { InstantClick.off('change', this.enableSearchPageListener); } - registerGlobalKeysListener() { + minimizeHeader = (event) => { + event.preventDefault(); + document.body.classList.toggle('zen-mode'); + }; + + focusOnSearchBox = (event) => { + event.preventDefault(); + document.body.classList.remove('zen-mode'); + const { searchBoxId } = this.props; const searchBox = document.getElementById(searchBoxId); - - this.globalKeysListener = (event) => { - const { tagName, classList } = document.activeElement; - - if ( - (event.key !== GLOBAL_SEARCH_KEY && - event.key !== GLOBAL_MINIMIZE_KEY) || - tagName === 'INPUT' || - tagName === 'TEXTAREA' || - classList.contains('input') - ) { - return; - } - - if (event.key === GLOBAL_SEARCH_KEY) { - event.preventDefault(); - document.body.classList.remove('zen-mode'); - searchBox.focus(); - searchBox.select(); - } else if ( - event.key === GLOBAL_MINIMIZE_KEY && - !this.hasKeyModifiers(event) - ) { - event.preventDefault(); - document.body.classList.toggle('zen-mode'); - } - }; - - document.addEventListener('keydown', this.globalKeysListener); - } + searchBox.focus(); + searchBox.select(); + }; render({ searchBoxId }, { searchTerm = '' }) { return ( - { - const { - key, - target: { value }, - } = event; - this.search(key, value); - }} - onSubmitSearch={this.submit} - searchBoxId={searchBoxId} - /> + + + { + const { + key, + target: { value }, + } = event; + this.search(key, value); + }} + onSubmitSearch={this.submit} + searchBoxId={searchBoxId} + /> + ); } } diff --git a/app/javascript/Search/__tests__/Search.test.jsx b/app/javascript/Search/__tests__/Search.test.jsx index 09518194d..4568dd2cb 100644 --- a/app/javascript/Search/__tests__/Search.test.jsx +++ b/app/javascript/Search/__tests__/Search.test.jsx @@ -87,8 +87,8 @@ describe('', () => { render(); - expect(window.addEventListener).toHaveBeenCalledTimes(1); - expect(window.addEventListener).toHaveBeenCalledWith( + expect(window.addEventListener).toHaveBeenNthCalledWith( + 1, 'popstate', expect.any(Function), ); @@ -103,8 +103,8 @@ describe('', () => { unmount(); - expect(window.removeEventListener).toHaveBeenCalledTimes(1); - expect(window.removeEventListener).toHaveBeenCalledWith( + expect(window.removeEventListener).toHaveBeenNthCalledWith( + 1, 'popstate', expect.any(Function), ); diff --git a/app/javascript/articles/Article.jsx b/app/javascript/articles/Article.jsx index 871ade56c..6261b45c5 100644 --- a/app/javascript/articles/Article.jsx +++ b/app/javascript/articles/Article.jsx @@ -51,6 +51,13 @@ export const Article = ({ data-content-user-id={article.user_id} data-testid={isFeatured ? 'featured-article' : `article-${article.id}`} > + + {article.title} +
{ diff --git a/app/javascript/articles/Feed.jsx b/app/javascript/articles/Feed.jsx index 6f421dd71..ab72a5123 100644 --- a/app/javascript/articles/Feed.jsx +++ b/app/javascript/articles/Feed.jsx @@ -1,6 +1,7 @@ import { h } from 'preact'; import { useEffect, useState } from 'preact/hooks'; import PropTypes from 'prop-types'; +import { useListNavigation } from '../shared/components/useListNavigation'; import { useKeyboardShortcuts } from '../shared/components/useKeyboardShortcuts'; /* global userData sendHapticMessage showModal buttonFormData renderNewSidebarCount */ @@ -131,6 +132,12 @@ export const Feed = ({ timeFrame, renderFeed }) => { } } + useListNavigation( + 'article.crayons-story', + 'a.crayons-story__hidden-navigation-link', + 'div.paged-stories', + ); + useKeyboardShortcuts({ b: (event) => { const article = event.target?.closest('article.crayons-story'); diff --git a/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap b/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap index 335cd22da..6155fa4a4 100644 --- a/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap +++ b/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap @@ -11,6 +11,13 @@ Object { data-testid="featured-article" id="featured-story-marker" > + + Unbranded Home Loan Account +
@@ -217,6 +224,13 @@ Object { data-testid="featured-article" id="featured-story-marker" > + + Unbranded Home Loan Account +
diff --git a/app/javascript/packs/homePageFeedShortcuts.jsx b/app/javascript/packs/homePageFeedShortcuts.jsx index 684f4755f..013d08b7e 100644 --- a/app/javascript/packs/homePageFeedShortcuts.jsx +++ b/app/javascript/packs/homePageFeedShortcuts.jsx @@ -1,21 +1,29 @@ -import { h, render } from 'preact'; +import { h, render, Fragment } from 'preact'; +import { ListNavigation } from '../shared/components/useListNavigation'; import { KeyboardShortcuts } from '../shared/components/useKeyboardShortcuts'; document.addEventListener('DOMContentLoaded', () => { const root = document.querySelector('#articles-list'); render( - { - const article = event.target?.closest('.crayons-story'); + + { + const article = event.target?.closest('.crayons-story'); - if (!article) return; + if (!article) return; - article.querySelector('button[id^=article-save-button-]')?.click(); - }, - }} - />, + article.querySelector('button[id^=article-save-button-]')?.click(); + }, + }} + /> + + , root, ); }); diff --git a/app/javascript/shared/components/__tests__/useListNavigation.test.js b/app/javascript/shared/components/__tests__/useListNavigation.test.js new file mode 100644 index 000000000..d1a89bd5a --- /dev/null +++ b/app/javascript/shared/components/__tests__/useListNavigation.test.js @@ -0,0 +1,364 @@ +import '@testing-library/jest-dom'; +import { fireEvent } from '@testing-library/preact'; +import { renderHook } from '@testing-library/preact-hooks'; +import { useListNavigation } from '../useListNavigation'; + +const NAVIGATION_UP_KEY = 'KeyK'; +const NAVIGATION_DOWN_KEY = 'KeyJ'; + +describe('List navigation hook', () => { + beforeAll(() => { + window.scrollTo = function () {}; + }); + + it('should focus on first element when nothing is focused', () => { + document.body.innerHTML = ` +
+ + +
+ `; + + renderHook(() => useListNavigation('article.container', 'a.focusable')); + + const firstFocusable = document.querySelector('#focusable-1'); + + fireEvent.keyDown(document, { code: NAVIGATION_DOWN_KEY }); + + expect(firstFocusable).toHaveFocus(); + }); + + it('should focus on immediate previous focusable on up key', () => { + document.body.innerHTML = ` +
+ + +
+ `; + + renderHook(() => useListNavigation('article.container', 'a.focusable')); + + const firstFocusable = document.querySelector('#focusable-1'); + const secondFocusable = document.querySelector('#focusable-2'); + + secondFocusable.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_UP_KEY }); + + expect(firstFocusable).toHaveFocus(); + }); + + it('should focus on immediate next focusable on down key', () => { + document.body.innerHTML = ` +
+ + +
+ `; + + renderHook(() => useListNavigation('article.container', 'a.focusable')); + + const firstFocusable = document.querySelector('#focusable-1'); + const secondFocusable = document.querySelector('#focusable-2'); + + firstFocusable.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_DOWN_KEY }); + + expect(secondFocusable).toHaveFocus(); + }); + + it('should keep focus on up key when the start of the list is reached', () => { + document.body.innerHTML = ` +
+ + +
+ `; + + renderHook(() => useListNavigation('article.container', 'a.focusable')); + + const firstFocusable = document.querySelector('#focusable-1'); + const secondFocusable = document.querySelector('#focusable-2'); + + secondFocusable.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_UP_KEY }); + expect(firstFocusable).toHaveFocus(); + + // focus is already at the start of the list + fireEvent.keyDown(document, { code: NAVIGATION_UP_KEY }); + expect(firstFocusable).toHaveFocus(); + }); + + it('should keep focus on down key when the bottom of the list is reached', () => { + document.body.innerHTML = ` +
+ + +
+ `; + + renderHook(() => useListNavigation('article.container', 'a.focusable')); + + const firstFocusable = document.querySelector('#focusable-1'); + const secondFocusable = document.querySelector('#focusable-2'); + + firstFocusable.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_DOWN_KEY }); + expect(secondFocusable).toHaveFocus(); + + // focus is already at the bottom of the list + fireEvent.keyDown(document, { code: NAVIGATION_DOWN_KEY }); + expect(secondFocusable).toHaveFocus(); + }); + + it('should focus on previous element before waterfall container on up key', () => { + document.body.innerHTML = ` +
+ +
+ +
+
+ `; + + renderHook(() => + useListNavigation('article.container', 'a.focusable', 'div.waterfall'), + ); + + const firstFocusable = document.querySelector('#focusable-1'); + const secondFocusable = document.querySelector('#focusable-2'); + + secondFocusable.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_UP_KEY }); + + expect(firstFocusable).toHaveFocus(); + }); + + it('should focus on next element inside waterfall container on down key', () => { + document.body.innerHTML = ` +
+ +
+ +
+
+ `; + + renderHook(() => + useListNavigation('article.container', 'a.focusable', 'div.waterfall'), + ); + + const firstFocusable = document.querySelector('#focusable-1'); + const secondFocusable = document.querySelector('#focusable-2'); + + firstFocusable.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_DOWN_KEY }); + + expect(secondFocusable).toHaveFocus(); + }); + + it('should focus on previous element on up key when an unrelated element is between 2 relevant elements', async () => { + document.body.innerHTML = ` +
+ + + +
+ `; + + renderHook(() => useListNavigation('article.container', 'a.focusable')); + + const firstFocusable = document.querySelector('#focusable-1'); + const secondFocusable = document.querySelector('#focusable-2'); + + secondFocusable.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_UP_KEY }); + + expect(firstFocusable).toHaveFocus(); + }); + + it('should focus on next element on down key when an unrelated element is between 2 relevant elements', () => { + document.body.innerHTML = ` +
+ + + +
+ `; + + renderHook(() => useListNavigation('article.container', 'a.focusable')); + + const firstFocusable = document.querySelector('#focusable-1'); + const secondFocusable = document.querySelector('#focusable-2'); + + firstFocusable.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_DOWN_KEY }); + + expect(secondFocusable).toHaveFocus(); + }); + + it('should focus on previous element on up key when inner secondary element is focused', () => { + document.body.innerHTML = ` +
+ + +
+ `; + + renderHook(() => useListNavigation('article.container', 'a.focusable')); + + const firstFocusable = document.querySelector('#focusable-1'); + const secondInnerSecondaryFocusable = document.querySelector( + '#inner-secondary-2', + ); + + secondInnerSecondaryFocusable.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_UP_KEY }); + + expect(firstFocusable).toHaveFocus(); + }); + + it('should focus on next element on down key when inner secondary element is focused', () => { + document.body.innerHTML = ` +
+ + +
+ `; + + renderHook(() => useListNavigation('article.container', 'a.focusable')); + + const firstInnerSecondaryFocusable = document.querySelector( + '#inner-secondary-1', + ); + const secondFocusable = document.querySelector('#focusable-2'); + + firstInnerSecondaryFocusable.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_DOWN_KEY }); + + expect(secondFocusable).toHaveFocus(); + }); + + it('should skip previous element on up key when it is unrelated and handle waterfall', () => { + document.body.innerHTML = ` +
+ + +
+ +
+
+ `; + + renderHook(() => + useListNavigation('article.container', 'a.focusable', 'div.waterfall'), + ); + + const firstElement = document.querySelector('#focusable-1'); + const secondElement = document.querySelector('#focusable-2'); + + secondElement.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_UP_KEY }); + + expect(firstElement).toHaveFocus(); + }); + + it('should skip next element on down key when it is unrelated and handle waterfall', () => { + document.body.innerHTML = ` +
+ + +
+ +
+
+ `; + + renderHook(() => + useListNavigation('article.container', 'a.focusable', 'div.waterfall'), + ); + + const firstElement = document.querySelector('#focusable-1'); + const secondElement = document.querySelector('#focusable-2'); + + firstElement.focus(); + + fireEvent.keyDown(document, { code: NAVIGATION_DOWN_KEY }); + + expect(secondElement).toHaveFocus(); + }); +}); diff --git a/app/javascript/shared/components/useListNavigation.js b/app/javascript/shared/components/useListNavigation.js new file mode 100644 index 000000000..ebc280c73 --- /dev/null +++ b/app/javascript/shared/components/useListNavigation.js @@ -0,0 +1,225 @@ +import PropTypes from 'prop-types'; +import { isInViewport } from '../../utilities/viewport'; +import { useKeyboardShortcuts } from './useKeyboardShortcuts'; + +const NAVIGATION_UP_KEY = 'KeyK'; +const NAVIGATION_DOWN_KEY = 'KeyJ'; + +const DIRECTIONS = { + UP: 'up', + DOWN: 'down', +}; + +/** + * Hook that registers a global key shortcut for 'j' and 'k' to navigate up and down in a list of items + * + * @example + * useListNavigation( + * ".crayons-story", + * "a[id^=article-link-]", + * "div.paged-stories,div.substories", + * ) + * + * Note: + * To avoid conflicts, only one of these should be called per page. + * + * Note on waterfalls: + * In the next example, the waterfall container would be 'div.paged-stories': + *
+ *
+ *
+ * + *
+ *
+ *
+ * + *
+ *
+ *
+ *
+ * + * @param {string} itemSelector - The selector for the highest level container of an item + * @param {string} focusableSelector - The selector for the element that should be focused on + * @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point + */ +export function useListNavigation( + itemSelector, + focusableSelector, + waterfallItemContainerSelector, +) { + function navigateInDirection(direction) { + navigate( + itemSelector, + focusableSelector, + waterfallItemContainerSelector, + direction, + ); + } + + useKeyboardShortcuts( + { + [NAVIGATION_UP_KEY]: () => navigateInDirection(DIRECTIONS.UP), + [NAVIGATION_DOWN_KEY]: () => navigateInDirection(DIRECTIONS.DOWN), + }, + window, + { timeout: 0 }, + ); +} + +/** + * Calls a hook that registers global key event listeners for 'j' and 'k' to navigate up and down in a list of items + * + * @example + * + * + * Note: + * To avoid conflicts, only one of these should be called per page. + * + * Note on waterfalls: + * In the next example, the waterfall container would be 'div.paged-stories': + *
+ *
+ *
+ * + *
+ *
+ *
+ * + *
+ *
+ *
+ *
+ * + * @param {string} itemSelector - The selector for the highest level container of an item + * @param {string} focusableSelector - The selector for the element that should be focused on + * @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point + */ +export function ListNavigation({ + itemSelector, + focusableSelector, + waterfallItemContainerSelector, +}) { + useListNavigation( + itemSelector, + focusableSelector, + waterfallItemContainerSelector, + ); + + return null; +} + +ListNavigation.propTypes = { + itemSelector: PropTypes.string.isRequired, + focusableSelector: PropTypes.string.isRequired, + waterfallItemContainerSelector: PropTypes.string, +}; + +/** + * Focuses on the next/previous element depending on the navigation direction + * + * @param {string} itemSelector - The selector for the highest level container of an item + * @param {string} focusableSelector - The selector for the element that should be focused on + * @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point + * @param {string} direction - The navigation direction (up or down) + */ +function navigate( + itemSelector, + focusableSelector, + waterfallItemContainerSelector, + direction, +) { + const closestContainer = document.activeElement?.closest(itemSelector); + + let nextContainer; + if (!closestContainer) { + nextContainer = getFirstVisibleElement(itemSelector); + } + if (!nextContainer) { + const getElementCallback = + direction === DIRECTIONS.UP ? getPreviousElement : getNextElement; + + nextContainer = getElementCallback( + closestContainer, + itemSelector, + waterfallItemContainerSelector, + ); + } + + const nextFocusable = nextContainer?.querySelector(focusableSelector); + if (nextFocusable) { + nextFocusable.focus(); + if (!isInViewport(nextFocusable, 64)) { + window.scrollTo({ top: nextContainer.offsetTop - 64 }); + } + } +} + +/** + * Gets the next element of a list that matches a selector + * + * @param {object} element - The current element + * @param {string} itemSelector - The selector for the highest level container of an item + * @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point + * + * @returns {object} The next element to focus on + */ +function getNextElement(element, itemSelector, waterfallItemContainerSelector) { + const sibling = element?.nextElementSibling; + if ( + sibling && + !sibling.matches(`${itemSelector},${waterfallItemContainerSelector}`) + ) { + return sibling.nextElementSibling; + } + return sibling; +} + +/** + * Gets the previous element of a list that matches a selector + * + * @param {object} element - The current element + * @param {string} itemSelector - The selector for the highest level container of an item + * @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point + * + * @returns {object} The previous element to focus on + */ +function getPreviousElement( + element, + itemSelector, + waterfallItemContainerSelector, +) { + if (!element) { + return null; + } + + let sibling = element.previousElementSibling; + if (!sibling && waterfallItemContainerSelector) { + // reached the top of a waterfall level + sibling = element.closest(waterfallItemContainerSelector) + ?.previousElementSibling; + } + + if (sibling && !sibling.matches(itemSelector)) { + return sibling.previousElementSibling; + } + + return sibling; +} + +/** + * Gets the first visible element that matches a selector + * + * @param {string} selector - The CSS selector + * + * @returns {object} The first visible element + */ +function getFirstVisibleElement(selector) { + const elements = document.querySelectorAll(selector); + return Array.prototype.find.call(elements, (element) => + isInViewport(element), + ); +} diff --git a/app/javascript/utilities/viewport.js b/app/javascript/utilities/viewport.js new file mode 100644 index 000000000..b9c2ec1d7 --- /dev/null +++ b/app/javascript/utilities/viewport.js @@ -0,0 +1,24 @@ +/** + * Checks if an element is visible in the viewport + * + * @example + * const element = document.querySelector('#element'); + * isInViewport(element); // true or false + * + * @param {object} element - The HTML element to check + * @param {number} [offsetTop=0] - Part of the screen to ignore counting from the top + * + * @returns {boolean} isInViewport - true if the element is visible in the viewport + */ +export function isInViewport(element, offsetTop = 0) { + const boundingRect = element.getBoundingClientRect(); + const clientHeight = + window.innerHeight || document.documentElement.clientHeight; + const clientWidth = window.innerWidth || document.documentElement.clientWidth; + return ( + boundingRect.top >= offsetTop && + boundingRect.left >= 0 && + boundingRect.bottom <= clientHeight && + boundingRect.right <= clientWidth + ); +} diff --git a/app/views/articles/_single_story.html.erb b/app/views/articles/_single_story.html.erb index 840832570..e5fd15332 100644 --- a/app/views/articles/_single_story.html.erb +++ b/app/views/articles/_single_story.html.erb @@ -1,4 +1,5 @@
+ <%= story.title %> <% if featured == true %>
<% end %>