UNPKG

@exadel/esl

Version:

Exadel Smart Library (ESL) is the lightweight custom elements library that provide a set of super-flexible components

157 lines (156 loc) 6.63 kB
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 ESLToggleableManager { 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); } /** 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(() => (element.$focusables[0] || element).focus({ preventScroll: true })); } /** 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(); const $focusable = current.$focusables[0] || current; $focusable.focus({ preventScroll: true }); } } /** 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; // 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 */ queue(cb) { // 34ms = macrotask + at least 1 frame this._focusTaskMng.put(cb, 34); } } __decorate([ listen({ event: 'keydown', target: document }) ], ESLToggleableManager.prototype, "_onKeyDown", null); __decorate([ listen({ event: 'focusin', target: document }) ], ESLToggleableManager.prototype, "_onFocusIn", null); __decorate([ listen({ event: 'mouseup touchend keydown', target: document, capture: true }) ], ESLToggleableManager.prototype, "_onOutsideInteraction", null);