`,
+ definition = {},
+ ) => {
+ document.body.innerHTML = `${html}`;
- 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', () => {
- expect(document.activeElement).toBe(document.body);
- expect(mainElement.getAttribute('tabindex')).toBe(null);
+ await Promise.resolve();
+ };
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ application.stop();
});
- it('should skip to main when skip link is clicked', () => {
- document.getElementById('skip').click();
- expect(mainElement.getAttribute('tabindex')).toEqual('-1');
- expect(document.activeElement).toBe(mainElement);
- expect(mainElement.getAttribute('blur')).toBe(null);
- expect(mainElement.getAttribute('focusout')).toBe(null);
+ describe('skip to the main content on clicking the skip link', () => {
+ beforeEach(async () => {
+ await setup();
+ });
+
+ 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', () => {
- 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);
+ describe('using to skip to a specific element', () => {
+ beforeEach(async () => {
+ await setup(
+ `
+
+
+
Section title
+
...lots of content...
+
+
+ `,
+ { 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(
+ `
+
+
+
Section title
+
...lots of content...
+
+
+ `,
+ { 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(
+ `
+
+
+
+
...lots of content...
+
+ `,
+ { 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);
+ });
});
});
diff --git a/client/src/controllers/FocusController.ts b/client/src/controllers/FocusController.ts
index c76d69e70b..b0ed73b193 100644
--- a/client/src/controllers/FocusController.ts
+++ b/client/src/controllers/FocusController.ts
@@ -1,33 +1,56 @@
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
* 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
+ *
+ * @example As an accessible skip link
+ * ```html
+ * Skip to main content
+ * ```
+ *
+ * @example As a button to skip to the top of a section
+ * ```html
+ *
+ *