Skip to content

Commit

Permalink
fix(esl-toggleable): focus management reworked to use scopes. Introdu…
Browse files Browse the repository at this point in the history
…ced `ESLToggleableFocusManager`
  • Loading branch information
ala-n committed Nov 18, 2024
1 parent f0825ff commit fbac20e
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 57 deletions.
2 changes: 1 addition & 1 deletion site/views/draft/focus-management.njk
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ aside:

<esl-trigger class="btn btn-sec-blue mb-2" target="::next">Open</esl-trigger>

<esl-toggleable class="alert alert-info p-3" focus-behavior="loop">
<esl-toggleable class="alert alert-info p-3" focus-behavior="loop" close-on-esc>
Focus Trapped Zone

<esl-trigger class="btn btn-link" target="#popup">Open Popup</esl-trigger>
Expand Down
5 changes: 2 additions & 3 deletions src/modules/esl-popup/core/esl-popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import {ESLIntersectionTarget, ESLIntersectionEvent} from '../../esl-event-liste
import {calcPopupPosition, isOnHorizontalAxis} from './esl-popup-position';
import {ESLPopupPlaceholder} from './esl-popup-placeholder';

import type {FocusFlowType} from '../../esl-utils/dom';
import type {ESLToggleableActionParams} from '../../esl-toggleable/core';
import type {ESLToggleableActionParams, ESLFocusFlowType} from '../../esl-toggleable/core';
import type {PositionType, PositionOriginType, IntersectionRatioRect} from './esl-popup-position';

const INTERSECTION_LIMIT_FOR_ADJACENT_AXIS = 0.7;
Expand Down Expand Up @@ -118,7 +117,7 @@ export class ESLPopup extends ESLToggleable {
* - 'loop' - focus on the first focusable element and loop through the focusable elements
*/
@attr({defaultValue: 'chain'})
public override focusBehavior: FocusFlowType;
public override focusBehavior: ESLFocusFlowType;

public $placeholder: ESLPopupPlaceholder | null;

Expand Down
1 change: 1 addition & 0 deletions src/modules/esl-toggleable/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export type {ESLToggleableTagShape} from './core/esl-toggleable.shape';
export type {ESLToggleableDispatcherTagShape} from './core/esl-toggleable-dispatcher.shape';

export * from './core/esl-toggleable';
export * from './core/esl-toggleable-focus';
export * from './core/esl-toggleable-dispatcher';
export * from './core/esl-toggleable-placeholder';
91 changes: 91 additions & 0 deletions src/modules/esl-toggleable/core/esl-toggleable-focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {listen} from '../../esl-utils/decorators/listen';
import {afterNextRender} from '../../esl-utils/async/raf';
import {ESLEventUtils} from '../../esl-event-listener/core/api';

import {TAB} from '../../esl-utils/dom/keys';
import {handleFocusChain} from '../../esl-utils/dom/focus';

import type {ESLToggleable} from './esl-toggleable';

/** Focus flow behaviors */
export type ESLFocusFlowType = 'none' | 'grab' | 'loop' | 'chain';

let instance: ESLToggleableFocusManager;
/** Focus manager for toggleable instances. Singleton. */
export class ESLToggleableFocusManager {
/** Focus scopes stack. Manger observes only top level scope. */
protected stack: ESLToggleable[] = [];

public constructor() {
if (instance) return instance;
ESLEventUtils.subscribe(this);
// eslint-disable-next-line @typescript-eslint/no-this-alias
instance = this;
}

/** Current focus scope */
public get current(): ESLToggleable {
return this.stack[this.stack.length - 1];
}

/** Check if the element is in the known focus scopes */
public has(element: ESLToggleable): boolean {
return this.stack.includes(element);
}

/** Change focus scope to the specified element. Previous scope saved in the stack. */
public attach(element: ESLToggleable): void {
if (element.focusBehavior === 'none' && element !== this.current) return;
// Remove the element from the stack and add it on top
this.stack = this.stack.filter((el) => el !== element).concat(element);
// Focus on the first focusable element
queueMicrotask(() => afterNextRender(() => element.focus({preventScroll: true})));
}

/** Remove the specified element from the known focus scopes. */
public detach(element: ESLToggleable): void {
if (!this.has(element)) return;
const {current} = this;
this.stack = this.stack.filter((el) => el !== element);
if (current === element) {
// Blur if the toggleable has focus
queueMicrotask(() => afterNextRender(() => element.blur(true)));
}
}

/** Keyboard event handler for the focus management */
@listen({event: 'keydown', target: document})
protected _onKeyDown(e: KeyboardEvent): void | boolean {
if (!this.current || e.key !== TAB) return;

const {focusBehavior, $focusables} = this.current;

const $first = $focusables[0];
const $last = $focusables[$focusables.length - 1];
const $fallback = this.current.activator || this.current;

if (focusBehavior === 'loop') return handleFocusChain(e, $first, $last);
if (focusBehavior === 'chain') {
if ($last && e.target !== (e.shiftKey ? $first : $last)) return;
$fallback.focus();
e.preventDefault();
}
}

/** Focusout event handler */
@listen({event: 'focusout', target: document})
protected _onFocusOut(e: FocusEvent): void {
const {current} = this;
if (!current || !current.contains(e.target as HTMLElement)) return;
afterNextRender(() => {
// Check if the focus is still inside the element
if (current === document.activeElement || current.contains(document.activeElement)) return;
if (current.focusBehavior === 'chain') {
current.hide({initiator: 'focusout', event: e});
}
if (current.focusBehavior === 'loop') {
current.focus({preventScroll: true});
}
});
}
}
43 changes: 13 additions & 30 deletions src/modules/esl-toggleable/core/esl-toggleable.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import {ExportNs} from '../../esl-utils/environment/export-ns';
import {SYSTEM_KEYS, ESC, TAB} from '../../esl-utils/dom/keys';
import {SYSTEM_KEYS, ESC} from '../../esl-utils/dom/keys';
import {CSSClassUtils} from '../../esl-utils/dom/class';
import {prop, attr, jsonAttr, listen} from '../../esl-utils/decorators';
import {defined, copyDefinedKeys} from '../../esl-utils/misc/object';
import {parseBoolean, toBooleanAttribute} from '../../esl-utils/misc/format';
import {sequentialUID} from '../../esl-utils/misc/uid';
import {hasHover} from '../../esl-utils/environment/device-detector';
import {DelayedTask} from '../../esl-utils/async/delayed-task';
import {afterNextRender} from '../../esl-utils/async/raf';
import {ESLBaseElement} from '../../esl-base-element/core';
import {findParent, isMatches} from '../../esl-utils/dom/traversing';
import {getKeyboardFocusableElements, handleFocusFlow} from '../../esl-utils/dom/focus';
import {getKeyboardFocusableElements} from '../../esl-utils/dom/focus';
import {ESLToggleableFocusManager} from './esl-toggleable-focus';

import type {FocusFlowType} from '../../esl-utils/dom/focus';
import type {ESLFocusFlowType} from './esl-toggleable-focus';
import type {DelegatedEvent} from '../../esl-event-listener/core/types';

/** Default Toggleable action params type definition */
Expand Down Expand Up @@ -123,7 +123,7 @@ export class ESLToggleable extends ESLBaseElement {
* - 'chain' - focus on the first focusable element first and return focus to the activator after the last focusable element
* - 'loop' - focus on the first focusable element and loop through the focusable elements
*/
@attr({defaultValue: 'none'}) public focusBehavior: FocusFlowType;
@attr({defaultValue: 'none'}) public focusBehavior: ESLFocusFlowType;

/** Initial params to pass to show/hide action on the start */
@jsonAttr<ESLToggleableActionParams>({defaultValue: {force: true, initiator: 'init'}})
Expand Down Expand Up @@ -297,12 +297,9 @@ export class ESLToggleable extends ESLBaseElement {
}

this.updateA11y();
this.$$fire(this.REFRESH_EVENT); // To notify other components about content change
this.focusManager.attach(this);

// Focus on the first focusable element
if (this.focusBehavior !== 'none') {
queueMicrotask(() => afterNextRender(() => this.focus({preventScroll: true})));
}
this.$$fire(this.REFRESH_EVENT); // To notify other components about content change
}

/**
Expand All @@ -327,9 +324,7 @@ export class ESLToggleable extends ESLBaseElement {
$container && CSSClassUtils.remove($container, this.containerActiveClass, this);
}
this.updateA11y();

// Blur if the toggleable has focus
queueMicrotask(() => afterNextRender(() => this.blur(true)));
this.focusManager.detach(this);
}

/** Active state marker */
Expand All @@ -340,6 +335,11 @@ export class ESLToggleable extends ESLBaseElement {
this.toggleAttribute('open', this._open = value);
}

/** Focus manager instance */
public get focusManager(): ESLToggleableFocusManager {
return new ESLToggleableFocusManager();
}

/** Last component that has activated the element. Uses {@link ESLToggleableActionParams.activator}*/
public get activator(): HTMLElement | null | undefined {
return activators.get(this);
Expand Down Expand Up @@ -410,23 +410,6 @@ export class ESLToggleable extends ESLBaseElement {
this.hide({initiator: 'keyboard', event: e});
e.stopPropagation();
}
if (this.focusBehavior !== 'none' && e.key === TAB && this.open) {
handleFocusFlow(e, this.$focusables, this.activator || this, this.focusBehavior);
}
}

@listen('focusout')
protected _onFocusOut(e: FocusEvent): void {
if (!this.open) return;
afterNextRender(() => {
if (this.hasFocus) return;
if (this.focusBehavior === 'chain') {
this.hide({initiator: 'focusout', event: e});
}
if (this.focusBehavior === 'loop') {
this.focus({preventScroll: true});
}
});
}

@listen({auto: false, event: 'mouseenter'})
Expand Down
28 changes: 5 additions & 23 deletions src/modules/esl-utils/dom/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,11 @@ export const handleFocusChain = (e: KeyboardEvent, first: HTMLElement | undefine
}
};

export type FocusFlowType = 'none' | 'loop' | 'chain';

export const handleFocusFlow = (
e: KeyboardEvent,
$focusables: HTMLElement[],
$fallback: HTMLElement,
type: FocusFlowType = 'loop'
): boolean | undefined => {
if (!type || type === 'none') return;

const $first = $focusables[0];
const $last = $focusables[$focusables.length - 1];

if (type === 'loop') return handleFocusChain(e, $first, $last);

if (type === 'chain' && $fallback) {
if ($last && e.target !== (e.shiftKey ? $first : $last)) return;
$fallback.focus();
e.preventDefault();
}
};

const FOCUSABLE_SELECTOR = 'a[href], button, input, textarea, select, details, summary, output, [tabindex]:not([tabindex="-1"])';
const isFocusableAllowed = (el: HTMLElement): boolean => !el.hasAttribute('disabled') && !el.closest('[inert]');

/** @returns if the element is focusable */
export const isFocusable = (el: HTMLElement): boolean => el && el.matches(FOCUSABLE_SELECTOR) && isFocusableAllowed(el);

/**
* Gets keyboard-focusable elements within a specified root element
Expand All @@ -50,6 +32,6 @@ const FOCUSABLE_SELECTOR = 'a[href], button, input, textarea, select, details, s
*/
export const getKeyboardFocusableElements = (root: HTMLElement | Document = document, ignoreVisibility = false): Element[] => {
return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hasAttribute('disabled') && !el.closest('[inert]') && (ignoreVisibility || isVisible(el))
(el: HTMLElement) => isFocusableAllowed(el) && (ignoreVisibility || isVisible(el))
);
};

0 comments on commit fbac20e

Please sign in to comment.