@exadel/esl
Version:
Exadel Smart Library (ESL) is the lightweight custom elements library that provide a set of super-flexible components
163 lines (162 loc) • 6.94 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
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';
let instance;
/** Focus manager for toggleable instances. Singleton. */
export class ESLToggleableManagerDefault {
constructor() {
/** Active toggleable */
this.active = new Set();
/** Focus scopes stack. Manager observes only top level scope. */
this.stack = [];
/** A delayed task for the focus management */
this._focusTaskMng = new DelayedTask();
if (instance)
return instance;
ESLEventUtils.subscribe(this);
// eslint-disable-next-line @typescript-eslint/no-this-alias
instance = this;
}
/** Current focus scope */
get current() {
return this.stack[this.stack.length - 1];
}
/** Checks if the element is in the known focus scopes */
has(element) {
return this.stack.includes(element);
}
/** Finds the related toggleable element for the specified element */
findRelated(element) {
if (!element)
return undefined;
return this.stack.find((el) => el.contains(element));
}
/** Returns the stack of the toggleable elements for the specified element */
getChainFor(element) {
const stack = [];
while (element) {
if (stack.includes(element))
break;
stack.push(element);
element = this.findRelated(element.activator);
}
return stack;
}
/** Checks if the element is related to the specified toggleable open chain */
isRelates(element, related) {
const scope = this.findRelated(element);
return this.getChainFor(scope).includes(related);
}
/** Focuses on the first focusable element of the toggleable, if possible */
grabFocus(element, options = { preventScroll: true }) {
if (!element || !element.open)
return;
const autoFocusable = element.querySelector('[autofocus], [data-autofocus]');
(autoFocusable || element.$focusables[0] || element).focus(options);
}
/** Changes focus scope to the specified element. Previous scope saved in the stack. */
attach(element) {
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(() => this.grabFocus(element));
}
/** Removes the specified element from the known focus scopes. */
detach(element, fallback) {
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 */
_onKeyDown(e) {
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 */
_onFocusIn(e) {
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();
this.grabFocus(current);
}
}
/** Catch all user interactions to initiate outside interaction handling */
_onOutsideInteraction(e) {
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
*/
onOutsideInteraction(e, el) {
if (!el.closeOnOutsideAction || !el.isOutsideAction(e))
return;
// Use a delay (10ms by default) to decrease the priority of the request (usually > 0 due to iOS specifics)
el.hide({ initiator: 'outsideaction', hideDelay: el.OUTSIDE_ACTION_DELAY, event: e });
}
/** Queues delayed task of the focus management */
queue(cb) {
// 34ms = macrotask + at least 1 frame
this._focusTaskMng.put(cb, 34);
}
}
__decorate([
listen({ event: 'keydown', target: document })
], ESLToggleableManagerDefault.prototype, "_onKeyDown", null);
__decorate([
listen({ event: 'focusin', target: document })
], ESLToggleableManagerDefault.prototype, "_onFocusIn", null);
__decorate([
listen({ event: 'mouseup touchend keydown', target: document, capture: true })
], ESLToggleableManagerDefault.prototype, "_onOutsideInteraction", null);