Skip to content

Commit

Permalink
feat: return focus to top page element, incl a11y warnings
Browse files Browse the repository at this point in the history
  • Loading branch information
Sarah Grefalda authored and littleninja committed Mar 10, 2021
1 parent 399a24d commit 690ccaa
Show file tree
Hide file tree
Showing 3 changed files with 141 additions and 22 deletions.
23 changes: 14 additions & 9 deletions demo/demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ By default, the `auro-back-to-top` element is fixed to the bottom-right corner o

```html
<body>
<article>
<article id="top" tabindex="-1">
<auro-back-to-top></auro-back-to-top>
<h3>Beowulf</h3>
<p> ... </p>
Expand All @@ -20,7 +20,7 @@ By default, the `auro-back-to-top` element is fixed to the bottom-right corner o
.
.
.
<auro-back-to-top></auro-back-to-top>
<auro-back-to-top focus="top"></auro-back-to-top>
</body>
```

Expand Down Expand Up @@ -63,7 +63,7 @@ To render the trigger always-visible and inline, use the `inline` property.
<span slot="trigger">See code</span>

```html
<article>
<article id="top" tabindex="-1">
<h3>The Canterbury Tales</h3>
<section>
<h4>The Knight's Tale</h4>
Expand All @@ -75,7 +75,7 @@ To render the trigger always-visible and inline, use the `inline` property.
<h4>The Miller's Tale</h4>
<p> ... </p>
<p> ... </p>
<auro-back-to-top inline></auro-back-to-top>
<auro-back-to-top focus="top" inline></auro-back-to-top>
</section>
</article>
```
Expand Down Expand Up @@ -116,7 +116,7 @@ To render the trigger always-visible and inline, use the `inline` property.
<p>He nevere yet no vileynye ne sayde</p>
<p>In al his lyf unto no maner wight.</p>
<p>He was a verray, parfit gentil knyght."</p>
<auro-back-to-top inline></auro-back-to-top>
<auro-back-to-top focus="top" inline></auro-back-to-top>
</section>
</article>

Expand All @@ -128,11 +128,11 @@ The trigger content--the arrow-up icon and text--can be customized to anything y
<span slot="trigger">See code</span>

```html
<article>
<article id="top" tabindex="-1">
<h3>I'm a Little Teapot</h3>
<p> ... </p>
<p> ... </p>
<auro-back-to-top inline>hop to top! 🫖</auro-back-to-top>
<auro-back-to-top focus="top" inline>hop to top! 🫖</auro-back-to-top>
</article>
```

Expand All @@ -147,9 +147,14 @@ The trigger content--the arrow-up icon and text--can be customized to anything y
<p>When I get all steamed up,</p>
<p>Hear me shout,</p>
<p>Tip me over and pour me out!</p>
<auro-back-to-top inline>hop to top! 🫖</auro-back-to-top>
<auro-back-to-top focus="top" inline>hop to top! 🫖</auro-back-to-top>
</article>

<script>
document.body.append(document.createElement('auro-back-to-top'));
(function () {
document.body.append(document.createElement('auro-back-to-top'));
const mainEl = document.querySelector('main');
mainEl.setAttribute('id', 'top');
mainEl.setAttribute('tabindex', '-1');
})();
</script>
57 changes: 51 additions & 6 deletions src/auro-back-to-top.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,40 +19,52 @@ const
/**
* auro-back-to-top provides helps users quickly return to page top.
*
* @attr {String} focus - Id attribute of the element to receive focus when trigger is clicked
* @attr {Boolean} inline - Render the trigger inline, will always be visible
* @attr {String} offset - Adjust how far the user scrolls before the fixed button appears, expressed in CSS measurement units (`vh` recommended)
* @attr {Boolean} visible - Indicates trigger visibility
*
* @slot - Customize trigger content
* @slot - Customize trigger message
*/
class AuroBackToTop extends LitElement {

static get properties() {
return {
focus: {
type: String,
},
inline: {
type: Boolean,
},
offset: {
type: String,
},
visible: {
attribute: false,
type: Boolean,
}
},
};
}

constructor() {
super();

this.inline = false;
this.offset = '100vh';

/**
* @private
* Whether the trigger button is visible, does not apply to inline trigger.
*/
this.visible = false;

const dom = new DOMParser().parseFromString(arrowUp.svg, 'text/html'),
svg = dom.body.firstChild;

svg.classList.add('icon');

/**
* @private
* Reference to arrow-up svg icon
*/
this.svg = svg;
}

Expand All @@ -62,8 +74,41 @@ class AuroBackToTop extends LitElement {
`;
}

scrollTop() {
/**
* @private
* Set focus by element id for accessibility of keyboard users
* @returns {void}
*/
setFocus() {
if (!this.focus) {
console.warn('Required `focus` attribute missing, this will harm accessibility.'); // eslint-disable-line no-console

return;
}

const focusEl = document.getElementById(this.focus);

if (!focusEl) {
console.warn(`No element found with id ${this.focus}, check that the element exists and has the expected id attribute.`); // eslint-disable-line no-console

return;
}

focusEl.focus({ preventScroll: true });

if (document.activeElement !== focusEl) {
console.warn(`Element with id ${this.focus}, check this is a focusable element or assign tabindex value of -1 so it can programmatically receive focus.`); // eslint-disable-line no-console
}
}

/**
* @private
* Handle trigger click by scrolling to window top and setting focus
* @returns {void}
*/
onTriggerClick() {
window.scrollTo(window.scrollX, WINDOW_SCROLL_TOP);
this.setFocus();
}

firstUpdated() {
Expand Down Expand Up @@ -103,7 +148,7 @@ class AuroBackToTop extends LitElement {
? html``
: html`<div class="reference" style=${styleMap(referenceStyles)}></div>`
}
<button @click=${this.scrollTop} class=${classMap(buttonClasses)}>
<button @click=${this.onTriggerClick} class=${classMap(buttonClasses)}>
<div class="message"><slot>${DEFAULT_MESSAGE}</slot></div>
${this.svg}
</button>
Expand Down
83 changes: 76 additions & 7 deletions test/auro-back-to-top.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import '../src/auro-back-to-top.js';

describe('auro-back-to-top', () => {
const sandbox = sinon.createSandbox();
let intersectionStub, observeStub; // eslint-disable-line init-declarations
let consoleWarnStub, intersectionStub, observeStub; // eslint-disable-line init-declarations

beforeEach(() => {
consoleWarnStub = sandbox.stub(console, 'warn');
observeStub = sandbox.stub();
intersectionStub = sandbox.stub(window, 'IntersectionObserver').callsFake(() => ({
observe: observeStub,
Expand Down Expand Up @@ -47,22 +48,18 @@ describe('auro-back-to-top', () => {

it('defines fixed trigger and is always visible when Intersection Observers are not supported', async () => {
Reflect.deleteProperty(window, 'IntersectionObserver');

// eslint-disable-next-line one-var
const el = await fixture(html`
<auro-back-to-top></auro-back-to-top>
`),
root = el.shadowRoot,
iconEl = root.querySelector('.icon'), // eslint-disable-line sort-vars
referenceEl = root.querySelector('.reference'), // eslint-disable-line sort-vars
triggerEl = root.querySelector('.trigger');

await elementUpdated(el);

await expect(iconEl, 'Expect icon element to exist').to.exist;
await expect(referenceEl, 'Expect reference element to exist (even though it will not be used').to.exist;
await expect(triggerEl, 'Expect trigger element to exist').to.exist;
await expect(triggerEl.classList.contains('trigger--inline')).to.be.false;
await expect(triggerEl.classList.contains('trigger--visible')).to.be.true;
await expect(triggerEl.textContent).to.match(/back to top/iu);
});

it('sets up an Intersection Observer', async () => {
Expand Down Expand Up @@ -141,6 +138,78 @@ describe('auro-back-to-top', () => {
}));
expect(root.querySelector('.reference').style.height).to.equal('42vh');
});
it('assigns focus to element with id matching `focus` property', async() => {
const parentNode = document.createElement('main');

parentNode.setAttribute('id', 'top');
parentNode.setAttribute('tabindex', '-1');
sandbox.spy(parentNode, 'focus');

// eslint-disable-next-line one-var
const el = await fixture(html`
<auro-back-to-top inline focus="top"></auro-back-to-top>
`, { parentNode });

await elementUpdated(el);
await el.shadowRoot.querySelector('.trigger').click();

expect(document.activeElement).to.equal(parentNode);
expect(parentNode.focus).to.be.calledWith({ preventScroll: true });
expect(consoleWarnStub).not.to.have.been.called;
});
it('warns when `focus` property not set', async() => {
const parentNode = document.createElement('main');

parentNode.setAttribute('id', 'top');
parentNode.setAttribute('tabindex', '-1');

// eslint-disable-next-line one-var
const el = await fixture(html`
<auro-back-to-top inline></auro-back-to-top>
`, { parentNode });

await elementUpdated(el);
await el.shadowRoot.querySelector('.trigger').click();

expect(document.activeElement).not.to.equal(parentNode);
expect(consoleWarnStub).to.have.been.calledOnce;
expect(consoleWarnStub).to.have.been.calledWith(sinon.match(/required `focus` attribute missing/iu));
});
it('warn when element with id matching `focus` property cannot be found', async() => {
const parentNode = document.createElement('main');

parentNode.setAttribute('id', 'top');
parentNode.setAttribute('tabindex', '-1');

// eslint-disable-next-line one-var
const el = await fixture(html`
<auro-back-to-top inline focus="broken"></auro-back-to-top>
`, { parentNode });

await elementUpdated(el);
await el.shadowRoot.querySelector('.trigger').click();

expect(document.activeElement).not.to.equal(parentNode);
expect(consoleWarnStub).to.have.been.calledOnce;
expect(consoleWarnStub).to.have.been.calledWith(sinon.match(/check that the element exists/iu));
});
it('warns when element with id matching `focus` property cannot receive focus', async() => {
const parentNode = document.createElement('main');

parentNode.setAttribute('id', 'top');

// eslint-disable-next-line one-var
const el = await fixture(html`
<auro-back-to-top inline focus="top"></auro-back-to-top>
`, { parentNode });

await elementUpdated(el);
await el.shadowRoot.querySelector('.trigger').click();

expect(document.activeElement).not.to.equal(parentNode);
expect(consoleWarnStub).to.have.been.calledOnce;
expect(consoleWarnStub).to.have.been.calledWith(sinon.match(/check this is a focusable element/iu));
});
});
});

Expand Down

0 comments on commit 690ccaa

Please sign in to comment.