A11y: Add keyboard navigation to article feed (#10468)
Co-authored-by: Andrew Bone <AndrewB05@gmail.com> Co-authored-by: Nick Taylor <nick@dev.to>
This commit is contained in:
parent
af58e7c1a9
commit
27a3df7d73
12 changed files with 738 additions and 67 deletions
|
|
@ -267,13 +267,27 @@ function buildArticleHTML(article) {
|
|||
'</div></a>';
|
||||
}
|
||||
|
||||
return (
|
||||
'<article class="crayons-story" data-article-path="' +
|
||||
var navigationLink = `
|
||||
<a
|
||||
href="${article.path}"
|
||||
aria-labelledby="article-link-${article.id}"
|
||||
class="crayons-story__hidden-navigation-link"
|
||||
>
|
||||
${article.title}
|
||||
</a>
|
||||
`;
|
||||
|
||||
var articleElement =
|
||||
'<article class="crayons-story" ' +
|
||||
'data-article-path="' +
|
||||
article.path +
|
||||
'id="article-' +
|
||||
article.id +
|
||||
'" data-content-user-id="' +
|
||||
article.user_id +
|
||||
'">\
|
||||
<div role="presentation">\
|
||||
'">' +
|
||||
navigationLink +
|
||||
'<div role="presentation">\
|
||||
' +
|
||||
videoHTML +
|
||||
'\
|
||||
|
|
@ -314,8 +328,9 @@ function buildArticleHTML(article) {
|
|||
</div>\
|
||||
</div>\
|
||||
</div>\
|
||||
</article>'
|
||||
);
|
||||
</article>';
|
||||
|
||||
return articleElement;
|
||||
}
|
||||
|
||||
return '';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<SearchForm
|
||||
searchTerm={searchTerm}
|
||||
onSearch={(event) => {
|
||||
const {
|
||||
key,
|
||||
target: { value },
|
||||
} = event;
|
||||
this.search(key, value);
|
||||
}}
|
||||
onSubmitSearch={this.submit}
|
||||
searchBoxId={searchBoxId}
|
||||
/>
|
||||
<Fragment>
|
||||
<KeyboardShortcuts
|
||||
shortcuts={{
|
||||
[GLOBAL_SEARCH_KEY]: this.focusOnSearchBox,
|
||||
[GLOBAL_MINIMIZE_KEY]: this.minimizeHeader,
|
||||
}}
|
||||
/>
|
||||
<SearchForm
|
||||
searchTerm={searchTerm}
|
||||
onSearch={(event) => {
|
||||
const {
|
||||
key,
|
||||
target: { value },
|
||||
} = event;
|
||||
this.search(key, value);
|
||||
}}
|
||||
onSubmitSearch={this.submit}
|
||||
searchBoxId={searchBoxId}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,8 +87,8 @@ describe('<Search />', () => {
|
|||
|
||||
render(<Search />);
|
||||
|
||||
expect(window.addEventListener).toHaveBeenCalledTimes(1);
|
||||
expect(window.addEventListener).toHaveBeenCalledWith(
|
||||
expect(window.addEventListener).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'popstate',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
|
@ -103,8 +103,8 @@ describe('<Search />', () => {
|
|||
|
||||
unmount();
|
||||
|
||||
expect(window.removeEventListener).toHaveBeenCalledTimes(1);
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith(
|
||||
expect(window.removeEventListener).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'popstate',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,13 @@ export const Article = ({
|
|||
data-content-user-id={article.user_id}
|
||||
data-testid={isFeatured ? 'featured-article' : `article-${article.id}`}
|
||||
>
|
||||
<a
|
||||
href={article.path}
|
||||
aria-labelledby={`article-link-${article.id}`}
|
||||
className="crayons-story__hidden-navigation-link"
|
||||
>
|
||||
{article.title}
|
||||
</a>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={(event) => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ Object {
|
|||
data-testid="featured-article"
|
||||
id="featured-story-marker"
|
||||
>
|
||||
<a
|
||||
aria-labelledby="article-link-62407"
|
||||
class="crayons-story__hidden-navigation-link"
|
||||
href="/some-post/path"
|
||||
>
|
||||
Unbranded Home Loan Account
|
||||
</a>
|
||||
<div
|
||||
role="presentation"
|
||||
>
|
||||
|
|
@ -217,6 +224,13 @@ Object {
|
|||
data-testid="featured-article"
|
||||
id="featured-story-marker"
|
||||
>
|
||||
<a
|
||||
aria-labelledby="article-link-62407"
|
||||
class="crayons-story__hidden-navigation-link"
|
||||
href="/some-post/path"
|
||||
>
|
||||
Unbranded Home Loan Account
|
||||
</a>
|
||||
<div
|
||||
role="presentation"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<KeyboardShortcuts
|
||||
shortcuts={{
|
||||
b: (event) => {
|
||||
const article = event.target?.closest('.crayons-story');
|
||||
<Fragment>
|
||||
<KeyboardShortcuts
|
||||
shortcuts={{
|
||||
b: (event) => {
|
||||
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();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ListNavigation
|
||||
itemSelector=".crayons-story"
|
||||
focusableSelector="a.crayons-story__hidden-navigation-link"
|
||||
waterfallItemContainerSelector="div.paged-stories,div.substories"
|
||||
/>
|
||||
</Fragment>,
|
||||
root,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
</article>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
</article>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
</article>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
</article>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
</article>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
</article>
|
||||
<div class="waterfall">
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
</article>
|
||||
<div class="waterfall">
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
</article>
|
||||
<article class="unrelated">
|
||||
<a href="/" class="unrelated-focusable" id="unrelated-focusable">link</a>
|
||||
</article>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
</article>
|
||||
<article class="unrelated">
|
||||
<a href="/" class="unrelated-focusable">link</a>
|
||||
</article>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
<a href="/" id="inner-secondary-1">link</a>
|
||||
</article>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
<a href="/" id="inner-secondary-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
<a href="/" id="inner-secondary-1">link</a>
|
||||
</article>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
<a href="/" id="inner-secondary-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
</article>
|
||||
<article class="unrelated">
|
||||
<a href="/" class="unrelated">link</a>
|
||||
</article>
|
||||
<div class="waterfall">
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-1">link</a>
|
||||
</article>
|
||||
<article class="unrelated">
|
||||
<a href="/" class="unrelated-focusable">link</a>
|
||||
</article>
|
||||
<div class="waterfall">
|
||||
<article class="container">
|
||||
<a href="/" class="focusable" id="focusable-2">link</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
225
app/javascript/shared/components/useListNavigation.js
Normal file
225
app/javascript/shared/components/useListNavigation.js
Normal file
|
|
@ -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':
|
||||
* <article />
|
||||
* <article />
|
||||
* <div class="paged-stories">
|
||||
* <!-- level 1 -->
|
||||
* <article />
|
||||
* <article />
|
||||
* <div class="paged-stories">
|
||||
* <!-- level 2 -->
|
||||
* <article />
|
||||
* <article />
|
||||
* </div>
|
||||
* </div>
|
||||
*
|
||||
* @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
|
||||
* <ListNavigation
|
||||
* itemSelector=".crayons-story"
|
||||
* focusableSelector="a[id^=article-link-]"
|
||||
* waterfallItemContainerSelector="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':
|
||||
* <article />
|
||||
* <article />
|
||||
* <div class="paged-stories">
|
||||
* <!-- level 1 -->
|
||||
* <article />
|
||||
* <article />
|
||||
* <div class="paged-stories">
|
||||
* <!-- level 2 -->
|
||||
* <article />
|
||||
* <article />
|
||||
* </div>
|
||||
* </div>
|
||||
*
|
||||
* @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),
|
||||
);
|
||||
}
|
||||
24
app/javascript/utilities/viewport.js
Normal file
24
app/javascript/utilities/viewport.js
Normal file
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<div class="crayons-story <% if featured == true %>crayons-story--featured<% end %>" data-content-user-id="<%= story.user_id %>">
|
||||
<a href="<%= story.path %>" aria-labelledby="article-link-<%= story.id %>" class="crayons-story__hidden-navigation-link"><%= story.title %></a>
|
||||
<% if featured == true %>
|
||||
<div id="featured-story-marker" data-featured-article="articles-<%= story.id %>"></div>
|
||||
<% end %>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue