kopia lustrzana https://github.com/wagtail/wagtail
Enhance & rework FocusController
- Update the Controller import & identifier to be `w-focus` not `w-skip-link` - Rework methods to be easier to maintain, pull out the focus event attachment handler to a function - Add JSDoc examples & refine description - Only add then remove the tabindex attribute if needed - Rework unit tests to be the emerging common structure, add unit test coverage & test for the preservation of existing tabindex attributespull/12555/head
rodzic
472c1e95c2
commit
3c09da2fb8
|
@ -1,38 +1,204 @@
|
||||||
import { Application } from '@hotwired/stimulus';
|
import { Application } from '@hotwired/stimulus';
|
||||||
|
|
||||||
import { SkipLinkController } from './SkipLinkController';
|
import { FocusController } from './FocusController';
|
||||||
|
|
||||||
describe('skip to the main content on clicking the skiplink', () => {
|
jest.useFakeTimers();
|
||||||
document.body.innerHTML = `
|
|
||||||
<a id="skip" class="button" data-controller="w-skip-link" data-action="click->w-skip-link#skip">Skip to main content</a>
|
|
||||||
<main>Main content</main>
|
|
||||||
<button id="other-content">other</button>`;
|
|
||||||
|
|
||||||
const application = Application.start();
|
describe('FocusController', () => {
|
||||||
|
let application;
|
||||||
|
|
||||||
application.register('w-skip-link', SkipLinkController);
|
const setup = async (
|
||||||
|
html = `<div>
|
||||||
|
<a id="skip" class="button" data-controller="w-focus" data-action="click->w-focus#focus">Skip to main content</a>
|
||||||
|
<main>Main content</main>
|
||||||
|
<button id="other-content">other</button>
|
||||||
|
</div>`,
|
||||||
|
definition = {},
|
||||||
|
) => {
|
||||||
|
document.body.innerHTML = `<main>${html}</main>`;
|
||||||
|
|
||||||
const mainElement = document.querySelector('main');
|
application = Application.start();
|
||||||
|
application.load({
|
||||||
|
controllerConstructor: FocusController,
|
||||||
|
identifier: 'w-focus',
|
||||||
|
...definition,
|
||||||
|
});
|
||||||
|
|
||||||
it('should keep tabindex, blur and focusout attribute as null when not in focus', () => {
|
await Promise.resolve();
|
||||||
expect(document.activeElement).toBe(document.body);
|
};
|
||||||
expect(mainElement.getAttribute('tabindex')).toBe(null);
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
application.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip to main when skip link is clicked', () => {
|
describe('skip to the main content on clicking the skip link', () => {
|
||||||
document.getElementById('skip').click();
|
beforeEach(async () => {
|
||||||
expect(mainElement.getAttribute('tabindex')).toEqual('-1');
|
await setup();
|
||||||
expect(document.activeElement).toBe(mainElement);
|
});
|
||||||
expect(mainElement.getAttribute('blur')).toBe(null);
|
|
||||||
expect(mainElement.getAttribute('focusout')).toBe(null);
|
it('should keep tabindex, blur and focusout attribute as null when not in focus', () => {
|
||||||
|
const mainElement = document.querySelector('main');
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
expect(mainElement.getAttribute('tabindex')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip to main when skip link is clicked', async () => {
|
||||||
|
const mainElement = document.querySelector('main');
|
||||||
|
|
||||||
|
document.getElementById('skip').click();
|
||||||
|
|
||||||
|
await jest.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(mainElement.getAttribute('tabindex')).toEqual('-1');
|
||||||
|
expect(document.activeElement).toBe(mainElement);
|
||||||
|
expect(mainElement.getAttribute('blur')).toBe(null);
|
||||||
|
expect(mainElement.getAttribute('focusout')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset tab index when focus is moved from skip link', () => {
|
||||||
|
const otherContent = document.getElementById('other-content');
|
||||||
|
otherContent.focus();
|
||||||
|
expect(document.activeElement).toBe(otherContent);
|
||||||
|
expect(otherContent.getAttribute('tabindex')).toBe(null);
|
||||||
|
expect(otherContent.getAttribute('blur')).toBe(null);
|
||||||
|
expect(otherContent.getAttribute('focusout')).toBe(null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reset tab index when focus is moved from skip link', () => {
|
describe('using to skip to a specific element', () => {
|
||||||
const otherContent = document.getElementById('other-content');
|
beforeEach(async () => {
|
||||||
otherContent.focus();
|
await setup(
|
||||||
expect(document.activeElement).toBe(otherContent);
|
`
|
||||||
expect(otherContent.getAttribute('tabindex')).toBe(null);
|
<main>
|
||||||
expect(otherContent.getAttribute('blur')).toBe(null);
|
<section>
|
||||||
expect(otherContent.getAttribute('focusout')).toBe(null);
|
<div class="section-top"><h3>Section title</h3></div>
|
||||||
|
<p>...lots of content...</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-controller="w-focus"
|
||||||
|
data-action="w-focus#focus"
|
||||||
|
data-w-focus-target-value=".section-top"
|
||||||
|
>
|
||||||
|
Skip to top
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</main>`,
|
||||||
|
{ identifier: 'w-focus' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not have modified anything on connect', () => {
|
||||||
|
const sectionTop = document.querySelector('.section-top');
|
||||||
|
expect(sectionTop.getAttribute('tabindex')).toBe(null);
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should focus on the section top when the button is clicked', async () => {
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
|
||||||
|
const sectionTop = document.querySelector('.section-top');
|
||||||
|
|
||||||
|
document.querySelector('button').click();
|
||||||
|
|
||||||
|
await jest.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(sectionTop.getAttribute('tabindex')).toEqual('-1');
|
||||||
|
expect(document.activeElement).toBe(sectionTop);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset the attributes when focus is moved from the section top', () => {
|
||||||
|
const sectionTop = document.querySelector('.section-top');
|
||||||
|
|
||||||
|
const button = document.querySelector('button');
|
||||||
|
button.focus();
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(button);
|
||||||
|
expect(sectionTop.getAttribute('tabindex')).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('avoid modifying tabindex if not required', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await setup(
|
||||||
|
`
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<div class="section-top" tabindex="-1"><h3>Section title</h3></div>
|
||||||
|
<p>...lots of content...</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-controller="w-focus"
|
||||||
|
data-action="w-focus#focus"
|
||||||
|
data-w-focus-target-value=".section-top"
|
||||||
|
>
|
||||||
|
Skip to top
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</main>`,
|
||||||
|
{ identifier: 'w-focus' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should focus on the section top when the button is clicked & when focus is removed keep the tabindex', async () => {
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
|
||||||
|
const sectionTop = document.querySelector('.section-top');
|
||||||
|
|
||||||
|
document.querySelector('button').click();
|
||||||
|
|
||||||
|
await jest.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(sectionTop.getAttribute('tabindex')).toEqual('-1');
|
||||||
|
expect(document.activeElement).toBe(sectionTop);
|
||||||
|
|
||||||
|
document.querySelector('button').focus(); // move focus away
|
||||||
|
sectionTop.dispatchEvent(new FocusEvent('focusout')); // simulate focusout event
|
||||||
|
expect(document.activeElement).toBe(document.querySelector('button'));
|
||||||
|
expect(sectionTop.getAttribute('tabindex')).toEqual('-1'); // tabindex should not be removed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('focusing on an element that is added dynamically', () => {
|
||||||
|
it('should support focusing on an element that is added dynamically', async () => {
|
||||||
|
await setup(
|
||||||
|
`
|
||||||
|
<main>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-controller="w-focus"
|
||||||
|
data-action="w-focus#focus"
|
||||||
|
data-w-focus-target-value=".error-message"
|
||||||
|
>
|
||||||
|
Skip first error message
|
||||||
|
</button>
|
||||||
|
<section>
|
||||||
|
<p>...lots of content...</p>
|
||||||
|
</section>
|
||||||
|
</main>`,
|
||||||
|
{ identifier: 'w-focus' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
|
||||||
|
const section = document.querySelector('section');
|
||||||
|
const button = document.querySelector('button');
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
await jest.runAllTimersAsync();
|
||||||
|
|
||||||
|
// No element found, defaults to not changing focus
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
|
||||||
|
const errorMessage = document.createElement('div');
|
||||||
|
errorMessage.classList.add('error-message');
|
||||||
|
section.appendChild(errorMessage);
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
await jest.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(errorMessage);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,33 +1,56 @@
|
||||||
import { Controller } from '@hotwired/stimulus';
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
import { forceFocus } from '../utils/forceFocus';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appears at the top left corner of the admin page with the tab button is clicked.
|
* Allows a target element (either via `href` or the targetValue as a selector) to be focused.
|
||||||
|
* If the element does not have a `tabindex` attribute, it will be added and removed when the element loses focus.
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Useful for the skip link functionality, which appears at the top left corner of the admin when the tab button is clicked.
|
||||||
* Used to provide an accessible skip button for keyboard control so that users can
|
* Used to provide an accessible skip button for keyboard control so that users can
|
||||||
* easily navigate to the main content without having to navigate a long list of navigation links.
|
* easily navigate to the main content without having to navigate a long list of navigation links.
|
||||||
*
|
|
||||||
* Inspired by https://github.com/selfthinker/dokuwiki_template_writr/blob/master/js/skip-link-focus-fix.js
|
* Inspired by https://github.com/selfthinker/dokuwiki_template_writr/blob/master/js/skip-link-focus-fix.js
|
||||||
|
*
|
||||||
|
* @example As an accessible skip link
|
||||||
|
* ```html
|
||||||
|
* <a href="#main" data-controller="w-focus" data-action="w-focus#focus:prevent">Skip to main content</a>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example As a button to skip to the top of a section
|
||||||
|
* ```html
|
||||||
|
* <section>
|
||||||
|
* <div class="section-top"><h3>Section title</h3></div>
|
||||||
|
* <p>...lots of content...</p>
|
||||||
|
* <button type="button" data-controller="w-focus" data-w-focus-target-value=".section-top" data-action="w-focus#focus">Skip to top</button>
|
||||||
|
* </section>
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export class SkipLinkController extends Controller<HTMLAnchorElement> {
|
export class FocusController extends Controller<
|
||||||
skipToTarget?: HTMLElement | null;
|
HTMLAnchorElement | HTMLButtonElement
|
||||||
|
> {
|
||||||
|
static values = {
|
||||||
|
target: String,
|
||||||
|
};
|
||||||
|
|
||||||
connect() {
|
declare targetValue: string;
|
||||||
this.skipToTarget = document.querySelector(
|
|
||||||
this.element.getAttribute('href') || 'main',
|
get target() {
|
||||||
|
const selector =
|
||||||
|
this.targetValue || this.element.getAttribute('href') || 'main';
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.element.closest<HTMLElement>(selector) ||
|
||||||
|
document.querySelector<HTMLElement>(selector)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBlur() {
|
focus() {
|
||||||
if (!this.skipToTarget) return;
|
const target = this.target;
|
||||||
this.skipToTarget.removeAttribute('tabindex');
|
|
||||||
this.skipToTarget.removeEventListener('blur', this.handleBlur);
|
|
||||||
this.skipToTarget.removeEventListener('focusout', this.handleBlur);
|
|
||||||
}
|
|
||||||
|
|
||||||
skip() {
|
if (target) {
|
||||||
if (!this.skipToTarget) return;
|
forceFocus(target);
|
||||||
this.skipToTarget.setAttribute('tabindex', '-1');
|
this.dispatch('focused', { bubbles: true, cancelable: false, target });
|
||||||
this.skipToTarget.addEventListener('blur', this.handleBlur);
|
}
|
||||||
this.skipToTarget.addEventListener('focusout', this.handleBlur);
|
|
||||||
this.skipToTarget.focus();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { DialogController } from './DialogController';
|
||||||
import { DismissibleController } from './DismissibleController';
|
import { DismissibleController } from './DismissibleController';
|
||||||
import { DrilldownController } from './DrilldownController';
|
import { DrilldownController } from './DrilldownController';
|
||||||
import { DropdownController } from './DropdownController';
|
import { DropdownController } from './DropdownController';
|
||||||
|
import { FocusController } from './FocusController';
|
||||||
import { FormsetController } from './FormsetController';
|
import { FormsetController } from './FormsetController';
|
||||||
import { InitController } from './InitController';
|
import { InitController } from './InitController';
|
||||||
import { KeyboardController } from './KeyboardController';
|
import { KeyboardController } from './KeyboardController';
|
||||||
|
@ -22,7 +23,6 @@ import { ProgressController } from './ProgressController';
|
||||||
import { RevealController } from './RevealController';
|
import { RevealController } from './RevealController';
|
||||||
import { RulesController } from './RulesController';
|
import { RulesController } from './RulesController';
|
||||||
import { SessionController } from './SessionController';
|
import { SessionController } from './SessionController';
|
||||||
import { SkipLinkController } from './SkipLinkController';
|
|
||||||
import { SlugController } from './SlugController';
|
import { SlugController } from './SlugController';
|
||||||
import { SubmitController } from './SubmitController';
|
import { SubmitController } from './SubmitController';
|
||||||
import { SwapController } from './SwapController';
|
import { SwapController } from './SwapController';
|
||||||
|
@ -51,6 +51,7 @@ export const coreControllerDefinitions: Definition[] = [
|
||||||
{ controllerConstructor: DismissibleController, identifier: 'w-dismissible' },
|
{ controllerConstructor: DismissibleController, identifier: 'w-dismissible' },
|
||||||
{ controllerConstructor: DrilldownController, identifier: 'w-drilldown' },
|
{ controllerConstructor: DrilldownController, identifier: 'w-drilldown' },
|
||||||
{ controllerConstructor: DropdownController, identifier: 'w-dropdown' },
|
{ controllerConstructor: DropdownController, identifier: 'w-dropdown' },
|
||||||
|
{ controllerConstructor: FocusController, identifier: 'w-focus' },
|
||||||
{ controllerConstructor: FormsetController, identifier: 'w-formset' },
|
{ controllerConstructor: FormsetController, identifier: 'w-formset' },
|
||||||
{ controllerConstructor: InitController, identifier: 'w-init' },
|
{ controllerConstructor: InitController, identifier: 'w-init' },
|
||||||
{ controllerConstructor: KeyboardController, identifier: 'w-kbd' },
|
{ controllerConstructor: KeyboardController, identifier: 'w-kbd' },
|
||||||
|
@ -62,7 +63,6 @@ export const coreControllerDefinitions: Definition[] = [
|
||||||
{ controllerConstructor: RevealController, identifier: 'w-reveal' },
|
{ controllerConstructor: RevealController, identifier: 'w-reveal' },
|
||||||
{ controllerConstructor: RulesController, identifier: 'w-rules' },
|
{ controllerConstructor: RulesController, identifier: 'w-rules' },
|
||||||
{ controllerConstructor: SessionController, identifier: 'w-session' },
|
{ controllerConstructor: SessionController, identifier: 'w-session' },
|
||||||
{ controllerConstructor: SkipLinkController, identifier: 'w-skip-link' },
|
|
||||||
{ controllerConstructor: SlugController, identifier: 'w-slug' },
|
{ controllerConstructor: SlugController, identifier: 'w-slug' },
|
||||||
{ controllerConstructor: SubmitController, identifier: 'w-submit' },
|
{ controllerConstructor: SubmitController, identifier: 'w-submit' },
|
||||||
{ controllerConstructor: SwapController, identifier: 'w-swap' },
|
{ controllerConstructor: SwapController, identifier: 'w-swap' },
|
||||||
|
|
Ładowanie…
Reference in New Issue