-
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.
fix(esl-toggleable): focus management reworked to use scopes. Introdu…
…ced `ESLToggleableFocusManager`
- Loading branch information
Showing
6 changed files
with
113 additions
and
57 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import {listen} from '../../esl-utils/decorators/listen'; | ||
import {afterNextRender} from '../../esl-utils/async/raf'; | ||
import {ESLEventUtils} from '../../esl-event-listener/core/api'; | ||
|
||
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 ESLFocusFlowType = 'none' | 'grab' | 'loop' | 'chain'; | ||
|
||
let instance: ESLToggleableFocusManager; | ||
/** Focus manager for toggleable instances. Singleton. */ | ||
export class ESLToggleableFocusManager { | ||
/** Focus scopes stack. Manger observes only top level scope. */ | ||
protected stack: ESLToggleable[] = []; | ||
|
||
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]; | ||
} | ||
|
||
/** Check if the element is in the known focus scopes */ | ||
public has(element: ESLToggleable): boolean { | ||
return this.stack.includes(element); | ||
} | ||
|
||
/** Change focus scope to the specified element. Previous scope saved in the stack. */ | ||
public attach(element: ESLToggleable): void { | ||
if (element.focusBehavior === 'none' && element !== this.current) return; | ||
// 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 | ||
queueMicrotask(() => afterNextRender(() => element.focus({preventScroll: true}))); | ||
} | ||
|
||
/** Remove the specified element from the known focus scopes. */ | ||
public detach(element: ESLToggleable): void { | ||
if (!this.has(element)) return; | ||
const {current} = this; | ||
this.stack = this.stack.filter((el) => el !== element); | ||
if (current === element) { | ||
// Blur if the toggleable has focus | ||
queueMicrotask(() => afterNextRender(() => element.blur(true))); | ||
} | ||
} | ||
|
||
/** 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; | ||
|
||
const {focusBehavior, $focusables} = this.current; | ||
|
||
const $first = $focusables[0]; | ||
const $last = $focusables[$focusables.length - 1]; | ||
const $fallback = this.current.activator || this.current; | ||
|
||
if (focusBehavior === 'loop') return handleFocusChain(e, $first, $last); | ||
if (focusBehavior === 'chain') { | ||
if ($last && e.target !== (e.shiftKey ? $first : $last)) return; | ||
$fallback.focus(); | ||
e.preventDefault(); | ||
} | ||
} | ||
|
||
/** Focusout event handler */ | ||
@listen({event: 'focusout', target: document}) | ||
protected _onFocusOut(e: FocusEvent): void { | ||
const {current} = this; | ||
if (!current || !current.contains(e.target as HTMLElement)) return; | ||
afterNextRender(() => { | ||
// Check if the focus is still inside the element | ||
if (current === document.activeElement || current.contains(document.activeElement)) return; | ||
if (current.focusBehavior === 'chain') { | ||
current.hide({initiator: 'focusout', event: e}); | ||
} | ||
if (current.focusBehavior === 'loop') { | ||
current.focus({preventScroll: true}); | ||
} | ||
}); | ||
} | ||
} |
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