Skip to content

Commit

Permalink
Merge pull request #2810 from exadel-inc/fix/focus-flow-v2
Browse files Browse the repository at this point in the history
feat(esl-toggleable): revision v2 for ESLToggleable a11y manger and outside actions handling
  • Loading branch information
ala-n authored Dec 10, 2024
2 parents 2bfa98b + 6b0d6f5 commit c500d62
Show file tree
Hide file tree
Showing 11 changed files with 242 additions and 166 deletions.
7 changes: 3 additions & 4 deletions site/src/navigation/sidebar/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,8 @@ export class ESLDemoSidebar extends ESLToggleable {
this.toggle(isDesktop && isStoredOpen, {force: true, initiator: 'bpchange', immediate: !isDesktop});
}

@listen({inherit: true})
protected override _onOutsideAction(e: Event): void {
if (ESLMediaQuery.for('@+MD').matches) return;
super._onOutsideAction(e);
public override isOutsideAction(e: Event): boolean {
if (ESLMediaQuery.for('@+MD').matches) return false;
return super.isOutsideAction(e);
}
}
44 changes: 40 additions & 4 deletions site/views/draft/focus-management.njk
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,22 @@ aside:
<section>
<h2>Nested Focusable Elements</h2>

<esl-trigger class="btn btn-sec-blue mb-2" target="::next">Open</esl-trigger>
<esl-trigger class="btn btn-sec-blue mb-2" target="#zone1">Open</esl-trigger>
<esl-trigger class="btn btn-sec-orange mb-2" target="#zone2">Zone 2</esl-trigger>
<esl-trigger class="btn btn-sec-orange mb-2" target="#popupForZone2">Popup for Zone 2</esl-trigger>

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

<esl-popup id="popupForZone2" position="bottom" position-origin="outer">
<div class="popup-content p-4">
<esl-trigger class="btn btn-sec-orange mb-2" target="#zone2">Open zone</esl-trigger>
</div>
</esl-popup>

<esl-toggleable id="zone1"
class="alert alert-info p-3"
a11y="modal"
close-on-esc
close-on-outside-action>
Focus Trapped Zone

<esl-trigger class="btn btn-link" target="#popup">Open Popup</esl-trigger>
Expand All @@ -27,7 +40,7 @@ aside:
<p>{{ lorem.paragraph(1) }}</p>
<a href="#">Test</a>

<esl-trigger class="btn btn-link" target="#popup4" track-hover>Nested Popup</esl-trigger>
<esl-trigger class="btn btn-link" target="#popup4" track-hover hover-hide-delay="250">Nested Popup</esl-trigger>
<esl-popup id="popup4" position="top" position-origin="outer">
<div class="popup-content p-2">
<button class="btn btn-sec-blue">Button 1</button>
Expand All @@ -38,7 +51,7 @@ aside:
</esl-popup>

And some other popup content
<esl-trigger class="btn btn-link" target="#popup2" track-hover>Hover Popup</esl-trigger>
<esl-trigger class="btn btn-link" target="#popup2" track-hover hover-hide-delay="250">Hover Popup</esl-trigger>
<esl-popup id="popup2" position="top" position-origin="outer">
<div class="popup-content p-2" style="max-width: 400px">
<p>{{ lorem.paragraph(1) }}</p>
Expand All @@ -58,5 +71,28 @@ aside:
</div>
</esl-popup>

<esl-trigger class="btn btn-sec-orange" target="#zone2">Zone 2</esl-trigger>
</esl-toggleable>

<esl-toggleable id="zone2"
class="alert alert-alert p-3"
a11y="modal"
close-on=".close-button"
close-on-esc
close-on-outside-action>
<button type="button" class="close close-button" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>

<h2>Focus Trapped Zone 2</h2>
<p>{{ lorem.paragraph(1) }}</p>
<button class="btn btn-link">Focusable Button</button>

<esl-trigger class="btn btn-sec-blue" target="#popup5">Simple Popup</esl-trigger>
<esl-popup id="popup5" position="top" position-origin="outer">
<div class="popup-content p-2" style="max-width: 400px">
<p>{{ lorem.paragraph(1) }}</p>
</div>
</esl-popup>
</esl-toggleable>
</section>
13 changes: 3 additions & 10 deletions src/modules/esl-popup/core/esl-popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {ESLIntersectionTarget, ESLIntersectionEvent} from '../../esl-event-liste
import {calcPopupPosition, isOnHorizontalAxis} from './esl-popup-position';
import {ESLPopupPlaceholder} from './esl-popup-placeholder';

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

const INTERSECTION_LIMIT_FOR_ADJACENT_AXIS = 0.7;
Expand Down Expand Up @@ -111,15 +111,8 @@ export class ESLPopup extends ESLToggleable {
@attr({parser: parseBoolean, serializer: toBooleanAttribute, defaultValue: true})
public override closeOnOutsideAction: boolean;

/**
* Focus behavior. Available values:
* - 'none' - no focus management
* - 'grab' - focus on the first focusable element, does not affect focus flow or behavior after the last focusable element
* - 'chain' (default) - 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: 'chain'})
public override focusBehavior: ESLFocusFlowType;
@attr({defaultValue: 'popup'})
public override a11y: ESLA11yType;

public $placeholder: ESLPopupPlaceholder | null;

Expand Down
11 changes: 6 additions & 5 deletions src/modules/esl-toggleable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,13 @@ Use `ESLToggleableDispatcher.init()` to initialize (and bind) `ESLToggleableDisp
- `close-on-esc` - Close the Toggleable on ESC keyboard event
- `close-on-outside-action` - Close the Toggleable on a click/tap outside

- `focus-behavior` - Focus flow behavior. <i class="badge badge-sup badge-success">new</i>
- `a11y` - Accessibility behavior pattern. <i class="badge badge-sup badge-success">new</i>
Available values:
- `none` (default) - does not affect focus management
- `grab` - focus on the first focusable element, does not affect focus flow or behavior after the last focusable element
- `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
- `none` (default) - does not affect focus management or behavior
- `autofocus` - focus on the first focusable element on show
- `popup` - focus on the first focusable element and return focus to the activator after the last focusable element. Closees on focus lost when `close-on-outside-action` is set.
- `modal` - focus on the first focusable element and trap focus inside the Toggleable. Does not allow focus lost outside. Closes active Toggleables in 'ppopup' mode.
- `dialog` - focus on the first focusable element and trap focus inside the Toggleable. Does not allow focus lost outside. Does not close active Toggleables in 'ppopup' mode. <i class="badge badge-sup badge-warning">beta</i>

- `initial-params` - Initial params to pass to show/hide action on start
- `default-params` - Default params to merge into passed action params
Expand Down
2 changes: 1 addition & 1 deletion src/modules/esl-toggleable/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +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-manager';
export * from './core/esl-toggleable-dispatcher';
export * from './core/esl-toggleable-placeholder';
89 changes: 0 additions & 89 deletions src/modules/esl-toggleable/core/esl-toggleable-focus.ts

This file was deleted.

155 changes: 155 additions & 0 deletions src/modules/esl-toggleable/core/esl-toggleable-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import {listen} from '../../esl-utils/decorators/listen';
import {ESLEventUtils} from '../../esl-event-listener/core/api';
import {DelayedTask} from '../../esl-utils/async/delayed-task';

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 ESLA11yType = 'none' | 'autofocus' | 'popup' | 'dialog' | 'modal';

let instance: ESLToggleableManager;
/** Focus manager for toggleable instances. Singleton. */
export class ESLToggleableManager {
/** Active toggleable */
protected active = new Set<ESLToggleable>();
/** Focus scopes stack. Manager observes only top level scope. */
protected stack: ESLToggleable[] = [];

/** A delayed task for the focus management */
private _focusTaskMng = new DelayedTask();

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];
}

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

/** Finds the related toggleable element for the specified element */
public findRelated(element: HTMLElement | null | undefined): ESLToggleable | undefined {
if (!element) return undefined;
return this.stack.find((el) => el.contains(element));
}

/** Returns the stack of the toggleable elements for the specified element */
public getChainFor(element: ESLToggleable | undefined): ESLToggleable[] {
const stack = [];
while (element) {
stack.push(element);
element = this.findRelated(element.activator);
}
return stack;
}

/** Checks if the element is related to the specified toggleable open chain */
public isRelates(element: HTMLElement, related: ESLToggleable): boolean {
const scope = this.findRelated(element);
return this.getChainFor(scope).includes(related);
}

/** Changes focus scope to the specified element. Previous scope saved in the stack. */
public attach(element: ESLToggleable): void {
this.active.add(element);
if (element.a11y === 'none' && element !== this.current) return;
// Make sure popup at least can be focused itself
if (!element.hasAttribute('tabindex')) element.setAttribute('tabindex', '-1');
// Drop all popups on modal focus
if (element.a11y === 'modal') {
this.stack
.filter((el) => el.a11y === 'popup')
.forEach((el) => el.hide({initiator: 'focus'}));
}
// 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
this.queue(() => (element.$focusables[0] || element).focus({preventScroll: true}));
}

/** Removes the specified element from the known focus scopes. */
public detach(element: ESLToggleable, fallback?: HTMLElement | null): void {
this.active.delete(element);
if (fallback && (element === this.current || element.contains(document.activeElement))) {
// Return focus to the fallback element
this.queue(() => fallback.focus({preventScroll: true}));
}
if (!this.has(element)) return;
this.stack = this.stack.filter((el) => el !== element);
}

/** 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;

if (this.current.a11y === 'none' || this.current.a11y === 'autofocus') return;

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

if (this.current.a11y === 'popup') {
if ($last && e.target !== (e.shiftKey ? $first : $last)) return;
$fallback.focus();
e.preventDefault();
}
if (this.current.a11y === 'modal' || this.current.a11y === 'dialog') {
handleFocusChain(e, $first, $last);
}
}

/** Focus event handler for the focus management */
@listen({event: 'focusin', target: document})
protected _onFocusOut(e: FocusEvent): void {
const {current} = this;
if (!current || current.a11y === 'autofocus') return;
// Check if the focus is still inside the element
if (current.contains(document.activeElement)) return;

// Hide popup on focusout
if (current.a11y === 'popup') this.onOutsideInteraction(e, current);
// Trap focus inside the element
if (current.a11y === 'modal') {
this._focusTaskMng.cancel();
const $focusable = current.$focusables[0] || current;
$focusable.focus({preventScroll: true});
}
}

/** Catch all user interactions to initiate outside interaction handling */
@listen({event: 'mouseup touchend keydown', target: document, capture: true})
protected _onOutsideInteraction(e: Event): void {
for (const el of this.active) {
this.onOutsideInteraction(e, el);
}
}

/**
* Hides a toggleable element on outside interaction in case
* it is an outside interaction and it is allowed
*/
protected onOutsideInteraction(e: Event, el: ESLToggleable): void {
if (!el.closeOnOutsideAction || !el.isOutsideAction(e)) return;
// Used 10ms delay to decrease priority of the request but positive due to iOS issue
el.hide({initiator: 'outsideaction', hideDelay: 10, event: e});
}

/** Queues delayed task of the focus management */
private queue(cb: () => void): void {
// 34ms = macrotask + at least 1 frame
this._focusTaskMng.put(cb, 34);
}
}
Loading

0 comments on commit c500d62

Please sign in to comment.