Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(esl-toggleable): revision v2 for ESLToggleable a11y manger and outside actions handling #2810

Merged
merged 12 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from 9 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
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` - Accesibility behavior pattern. <i class="badge badge-sup badge-success">new</i>
ala-n marked this conversation as resolved.
Show resolved Hide resolved
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
Loading