Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions core/src/utils/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { focusVisibleElement } from '@utils/helpers';
import { focusRedirectedElement } from '@utils/helpers';

/**
* This query string selects elements that
Expand Down Expand Up @@ -94,7 +94,7 @@ const focusElementInContext = <T extends HTMLElement>(
if (radioGroup) {
radioGroup.setFocus();
} else {
focusVisibleElement(elementToFocus);
focusRedirectedElement(elementToFocus);
}
} else {
// Focus fallback element instead of letting focus escape
Expand Down
58 changes: 56 additions & 2 deletions core/src/utils/focus-visible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const FOCUS_KEYS = [
export interface FocusVisibleUtility {
destroy: () => void;
setFocus: (elements: Element[]) => void;
isKeyboardMode: () => boolean;
}

let focusVisibleUtility: FocusVisibleUtility | null = null;
Expand Down Expand Up @@ -46,10 +47,39 @@ export const focusElements = (elements: Element[]) => {
focusVisible.setFocus(elements);
};

/**
* Reports whether the most recent interaction on the page was keyboard-driven.
*
* Check this before drawing the keyboard focus indicator programmatically.
*
* @returns `true` while the user is navigating with a keyboard, and before the
* first interaction on the page.
*/
export const isKeyboardMode = () => getOrInitFocusVisibleUtility().isKeyboardMode();

/**
* Watches how the user is interacting with the page and marks the focused
* element with `ion-focused`, so the keyboard focus indicator is only drawn
* while the user navigates with a keyboard.
*
* @param rootEl Scopes the utility to this element's shadow root, so it only
* reacts to interactions inside it. Omit it to listen on the document.
* @returns `setFocus` to mark elements focused programmatically, and `destroy`
* to detach the listeners.
*/
export const startFocusVisible = (rootEl?: HTMLElement): FocusVisibleUtility => {
let currentFocus: Element[] = [];

/*
* Starts as `true` so an element focused before the user has interacted,
* such as one focused on page load, still draws an indicator.
*/
let keyboardMode = true;

/*
* `ref` is where the listeners go and `root` is the element focus falls back
* to once it leaves everything inside `ref`.
*/
const ref = rootEl ? rootEl.shadowRoot! : document;
const root = rootEl ? rootEl : document.body;

Expand All @@ -63,12 +93,25 @@ export const startFocusVisible = (rootEl?: HTMLElement): FocusVisibleUtility =>
setFocus([]);
};

/*
* Only the keys that move focus keep the indicator on. Any other key means
* the user is typing into the focused element rather than navigating, so the
* indicator is dropped.
*/
const onKeydown = (ev: Event) => {
keyboardMode = FOCUS_KEYS.includes((ev as KeyboardEvent).key);
if (!keyboardMode) {
setFocus([]);
}
};

/*
* The indicator does not always belong to the element that took focus. The
* composed path is walked so every `ion-focusable` ancestor is marked too,
* which is how an `ion-item` draws the indicator for a checkbox slotted into
* it, since a checkbox in an item drops the class itself. The composed path
* is used because it reaches hosts across shadow boundaries.
*/
const onFocusin = (ev: Event) => {
if (keyboardMode && ev.composedPath !== undefined) {
const toFocus = ev.composedPath().filter((el: any) => {
Expand All @@ -81,20 +124,30 @@ export const startFocusVisible = (rootEl?: HTMLElement): FocusVisibleUtility =>
setFocus(toFocus);
}
};

/*
* Focus landing back on `root` means it left every focusable element, so
* nothing should stay marked. Focus moving between elements is left alone
* because `onFocusin` marks the new one.
*/
const onFocusout = () => {
if (ref.activeElement === root) {
setFocus([]);
}
};

ref.addEventListener('keydown', onKeydown);
/*
* Capture phase, so the mode is current for the overlay focus trap, which
* intercepts Tab in its own capture listener.
*/
ref.addEventListener('keydown', onKeydown, true);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without it, Tab reads the mode from the previous click instead of the Tab itself, so the indicator never shows.

ref.addEventListener('focusin', onFocusin);
ref.addEventListener('focusout', onFocusout);
ref.addEventListener('touchstart', pointerDown, { passive: true });
ref.addEventListener('mousedown', pointerDown);

const destroy = () => {
ref.removeEventListener('keydown', onKeydown);
ref.removeEventListener('keydown', onKeydown, true);
ref.removeEventListener('focusin', onFocusin);
ref.removeEventListener('focusout', onFocusout);
ref.removeEventListener('touchstart', pointerDown);
Expand All @@ -104,5 +157,6 @@ export const startFocusVisible = (rootEl?: HTMLElement): FocusVisibleUtility =>
return {
destroy,
setFocus,
isKeyboardMode: () => keyboardMode,
};
};
16 changes: 15 additions & 1 deletion core/src/utils/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { EventEmitter } from '@stencil/core';
import { focusElements } from '@utils/focus-visible';
import { focusElements, isKeyboardMode } from '@utils/focus-visible';
import { printIonError } from '@utils/logging';
import { isRTL } from '@utils/rtl';

Expand Down Expand Up @@ -304,6 +304,20 @@ export const focusVisibleElement = (el: HTMLElement) => {
}
};

/**
* Focuses an element a focus trap is redirecting focus to. Only draws the
* keyboard focus indicator when the user is navigating with a keyboard, so a
* redirect caused by a tap or click does not leave the element looking as
* though it was tabbed to.
*/
export const focusRedirectedElement = (el: HTMLElement) => {
if (isKeyboardMode()) {
focusVisibleElement(el);
} else {
el.focus();
}
};

/**
* Clears the keyboard focus ring (`ion-focused`) that the focus-visible
* utility may have applied to elements during a programmatic focus.
Expand Down
3 changes: 2 additions & 1 deletion core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { OVERLAY_BACK_BUTTON_PRIORITY } from './hardware-back-button';
import {
addEventListener,
componentOnReady,
focusRedirectedElement,
focusVisibleElement,
getElementRoot,
removeEventListener,
Expand Down Expand Up @@ -296,7 +297,7 @@ const focusElementInOverlay = (hostToFocus: HTMLElement | null | undefined, over
}

if (elementToFocus) {
focusVisibleElement(elementToFocus);
focusRedirectedElement(elementToFocus);
} else {
// Focus overlay instead of letting focus escape
overlay.focus();
Expand Down
58 changes: 58 additions & 0 deletions core/src/utils/test/overlays/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
<ion-button id="create-and-present-toast" onclick="createAndPresentToast()"
>Create and Present Toast</ion-button
>
<ion-button id="create-and-present-focus-trap" onclick="createAndPresentFocusTrapModal()"
>Create and Present Focus Trap Modal</ion-button
>
</ion-content>
</div>
</ion-app>
Expand Down Expand Up @@ -128,6 +131,61 @@
await toast.present();
};

/*
Presents a modal for checking the focus trap's redirect by hand. Both
routes move focus to the input behind the modal, which the trap
redirects back to the checkbox. Only the keyboard route should leave an
indicator there.
*/
const createAndPresentFocusTrapModal = async () => {
const div = document.createElement('div');
div.innerHTML = `
<ion-content class="ion-padding">
<ion-checkbox>Dark Mode</ion-checkbox>

<ion-button id="modal-move-focus">Move focus outside</ion-button>

<p>Pointer: click the button above. Expect false below.</p>

<p>Keyboard: reopen the modal, press Tab twice to reach the button, then Enter. Expect true below.</p>

<p id="focus-trap-status">indicator on the checkbox: not checked yet</p>

<p>
The indicator is a faint wash, so the reading above is the reliable
check. Reopen the modal between the two routes, since a click leaves
the focus utility out of keyboard mode.
</p>
</ion-content>
`;

const report = () => {
const checkbox = div.querySelector('ion-checkbox');
const status = div.querySelector('#focus-trap-status');
status.textContent = `indicator on the checkbox: ${checkbox.classList.contains('ion-focused')}`;
};

/*
The reading is deferred a frame because `setFocus` is async, so the
trap has not redirected focus back yet when it returns.
*/
const moveFocusOutside = () => {
document.querySelector('#root-input').setFocus();
requestAnimationFrame(report);
};

const moveFocusButton = div.querySelector('ion-button#modal-move-focus');
moveFocusButton.onclick = moveFocusOutside;

const modal = await modalController.create({
component: div,
});

await modal.present();

return modal;
};

const createNestedOverlayModal = async () => {
const div = document.createElement('div');
div.innerHTML = `
Expand Down
66 changes: 66 additions & 0 deletions core/src/utils/test/overlays/overlays.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,5 +529,71 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await expect(wrapper).toHaveAttribute('role', 'dialog');
await expect(wrapper).toBeFocused();
});

/*
* The focus trap redirects focus back into the overlay when focus lands
* outside of it. The indicator should only follow that redirect during
* keyboard navigation.
*
* The button is the modal's first focusable, so it is where the trap
* redirects focus to. It has to be something that can actually take focus,
* or the redirect is a no-op and the tests prove nothing. `ion-app` is
* required to apply the focused styles.
*/
const redirectContent = `
Comment thread
brandyscarney marked this conversation as resolved.
<ion-app>
<ion-button id="open-modal">Show Modal</ion-button>
<div tabindex="0">Outside Element</div>
<ion-modal trigger="open-modal">
<ion-content>
<ion-button id="inside-modal">Inside Modal</ion-button>
</ion-content>
</ion-modal>
</ion-app>
`;

test('should not show a focus indicator when focus is redirected after a pointer interaction', async ({ page }) => {
await page.setContent(redirectContent, config);

const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const insideButton = page.locator('ion-modal ion-button#inside-modal');

// Opening with a click leaves the focus utility in pointer mode.
await page.locator('ion-button#open-modal').click();
await ionModalDidPresent.next();

await page.locator('ion-app > div[tabindex="0"]').evaluate((el: HTMLElement) => el.focus());

/*
* The trap focuses the element before applying the indicator, and applies
* it through an async method. Waiting for focus to land and flushing a
* frame makes a missing indicator a real absence rather than an assertion
* that ran too early.
*/
await expect(insideButton).toBeFocused();
await page.evaluate(() => new Promise(requestAnimationFrame));

await expect(insideButton).not.toHaveClass(/ion-focused/);
});

test('should show a focus indicator when focus is redirected during keyboard navigation', async ({
page,
pageUtils,
}) => {
await page.setContent(redirectContent, config);

const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const insideButton = page.locator('ion-modal ion-button#inside-modal');

await page.locator('ion-button#open-modal').click();
await ionModalDidPresent.next();

// Shift turns keyboard mode back on without moving focus.
await pageUtils.pressKeys('Shift');
await page.locator('ion-app > div[tabindex="0"]').evaluate((el: HTMLElement) => el.focus());

await expect(insideButton).toBeFocused();
await expect(insideButton).toHaveClass(/ion-focused/);
});
});
});
Loading