-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2810 from exadel-inc/fix/focus-flow-v2
feat(esl-toggleable): revision v2 for ESLToggleable a11y manger and outside actions handling
- Loading branch information
Showing
11 changed files
with
242 additions
and
166 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
155 changes: 155 additions & 0 deletions
155
src/modules/esl-toggleable/core/esl-toggleable-manager.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.