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:
Robin Gagnon 2020-11-10 22:38:15 -05:00 committed by GitHub
parent af58e7c1a9
commit 27a3df7d73
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 738 additions and 67 deletions

View file

@ -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 '';

View file

@ -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);

View file

@ -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>
);
}
}

View file

@ -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),
);

View file

@ -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) => {

View file

@ -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');

View file

@ -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"
>

View file

@ -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,
);
});

View file

@ -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();
});
});

View 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),
);
}

View 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
);
}

View file

@ -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 %>