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: ESLTogglable based focus management (UPDATED v2) #2753

Open
wants to merge 18 commits into
base: main-beta
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
376f388
feat(esl-utils): add extended `handleFocusFlow` keyboard based focus …
ala-n Nov 8, 2024
c954d72
feat(esl-toggleable): add out of the box `ESLToggleable` focus manager
ala-n Nov 8, 2024
6ef1f2e
feat(esl-tooltip): get rid from inner `hasFocusLoop` and custom focus…
ala-n Nov 8, 2024
b5260b9
feat(esl-share): separate `ESLSharePopup` implementation from `ESLToo…
ala-n Nov 8, 2024
ea8dd94
fix(esl-share): fix inner ESLToggleableActionParams instances type
ala-n Nov 8, 2024
28a5bc9
Merge branch 'main-beta' into feat/focus-management
ala-n Nov 11, 2024
2b8a0c7
feat(esl-toggleable): update focusBehaviour option to smoothly suppor…
ala-n Nov 14, 2024
11b10ea
feat(esl-popup): get rid from all focus management code
ala-n Nov 14, 2024
04d6a63
fix(esl-share): simplify code and remove overrides (according to esl-…
ala-n Nov 14, 2024
699ac7f
fix(esl-tooltip): simplify code and remove overrides (according to es…
ala-n Nov 14, 2024
b3c62f3
Merge pull request #2766 from exadel-inc/feat/focus-management-2
ala-n Nov 14, 2024
1e457ad
style(esl-popup): fix type import style for `FocusFlowType`
ala-n Nov 14, 2024
ea50c3f
Merge branch 'feat/focus-management-2' into feat/focus-management
ala-n Nov 14, 2024
f5e906b
docs: fix usage of british version of the word `behavior`
ala-n Nov 14, 2024
b729e08
docs(esl-toggleable): TS doc fixes
ala-n Nov 14, 2024
8c338c6
Merge remote-tracking branch 'origin/main-beta' into feat/focus-manag…
ala-n Nov 14, 2024
cf1ed1b
style(esl-toggleable): fix focus behavior for chain focus flow
ala-n Nov 14, 2024
60e951f
style(esl-toggleable): fix false observation for closed toggleable
ala-n Nov 15, 2024
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
4 changes: 2 additions & 2 deletions src/modules/esl-share/core/esl-share-popup.shape.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type {ESLTooltipTagShape} from '../../esl-tooltip/core/esl-tooltip.shape';
import type {ESLPopupTagShape} from '../../esl-popup/core/esl-popup.shape';
import type {ESLSharePopup} from './esl-share-popup';

/**
* Tag declaration interface of ESL Share Popup element
* Used for TSX declaration
*/
export interface ESLSharePopupTagShape extends ESLTooltipTagShape<ESLSharePopup> {
export interface ESLSharePopupTagShape extends ESLPopupTagShape<ESLSharePopup> {
/** Allowed children */
children?: any;
}
Expand Down
55 changes: 49 additions & 6 deletions src/modules/esl-share/core/esl-share-popup.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {ExportNs} from '../../esl-utils/environment/export-ns';
import {ESLTooltip} from '../../esl-tooltip/core/esl-tooltip';
import {bind, listen, memoize, prop} from '../../esl-utils/decorators';
import {ESLPopup} from '../../esl-popup/core/esl-popup';
import {attr, bind, boolAttr, listen, memoize} from '../../esl-utils/decorators';
import {ESLShareButton} from './esl-share-button';
import {ESLShareConfig} from './esl-share-config';

import type {ESLTooltipActionParams} from '../../esl-tooltip/core/esl-tooltip';
import type {ESLShareButtonConfig} from './esl-share-config';
import type {FocusFlowType} from '../../esl-utils/dom/focus';
import type {PositionType} from '../../esl-popup/core/esl-popup-position';

export type {ESLSharePopupTagShape} from './esl-share-popup.shape';

Expand All @@ -28,12 +30,13 @@ export interface ESLSharePopupActionParams extends ESLTooltipActionParams {
* - forwards the sharing attributes from the host share {@link ESLShare} component
*/
@ExportNs('SharePopup')
export class ESLSharePopup extends ESLTooltip {
export class ESLSharePopup extends ESLPopup {
static override is = 'esl-share-popup';

/** Default params to pass into the share popup */
static override DEFAULT_PARAMS: ESLSharePopupActionParams = {
...ESLTooltip.DEFAULT_PARAMS,
...ESLPopup.DEFAULT_PARAMS,
autofocus: true,
position: 'top',
hideDelay: 300
};
Expand All @@ -49,24 +52,64 @@ export class ESLSharePopup extends ESLTooltip {

/** Shared instance of ESLSharePopup */
@memoize()
public static override get sharedInstance(): ESLSharePopup {
public static get sharedInstance(): ESLSharePopup {
return ESLSharePopup.create();
}

@prop(true) public override hasFocusLoop: boolean;
/**
* Focus behaviour. Awailable values:
* - 'none' - no focus management
* - '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 focusBehaviour: FocusFlowType;

/**
* Popup position relative to the trigger.
* Currently supported: 'top', 'bottom', 'left', 'right' position types ('top' by default)
*/
@attr({defaultValue: 'top'}) public override position: PositionType;

/** Popup behavior if it does not fit in the window ('fit' by default) */
@attr({defaultValue: 'fit'}) public override behavior: string;

/** Disable arrow at Tooltip */
@boolAttr() public disableArrow: boolean;

/** Hashstring with a list of buttons already rendered in the popup */
protected _list: string = '';

public override connectedCallback(): void {
super.connectedCallback();
this.classList.add(ESLPopup.is);
this.classList.toggle('disable-arrow', this.disableArrow);
this.tabIndex = 0;
}

/** Sets initial state of the Tooltip */
protected override setInitialState(): void {}

public override onShow(params: ESLTooltipActionParams): void {
ala-n marked this conversation as resolved.
Show resolved Hide resolved
if (params.disableArrow) {
this.disableArrow = params.disableArrow;
}
if (params.list) {
const buttonsList = ESLShareConfig.instance.get(params.list);
this.appendButtonsFromList(buttonsList);
}
this.forwardAttributes();
this.dir = params.dir || '';
this.lang = params.lang || '';
this.parentNode !== document.body && document.body.appendChild(this);
super.onShow(params);
}

/** Actions to execute on Tooltip hiding. */
public override onHide(params: ESLTooltipActionParams): void {
super.onHide(params);
this.parentNode === document.body && document.body.removeChild(this);
}

/** Checks that the button list from the config was already rendered in the popup. */
protected isEqual(config: ESLShareButtonConfig[]): boolean {
return stringifyButtonsList(config) === this._list;
Expand Down
8 changes: 8 additions & 0 deletions src/modules/esl-toggleable/core/esl-toggleable.shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export interface ESLToggleableTagShape<T extends ESLToggleable = ESLToggleable>
/** Open toggleable marker. Can be used to define initial state */
'open'?: boolean;

/**
* Define focus behaviour
* - 'none' - no focus management
* - '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
*/
'focus-behaviour'?: 'none' | 'chain' | 'loop';
ala-n marked this conversation as resolved.
Show resolved Hide resolved

/** Define Toggleable group meta information to organize groups */
'group'?: string;

Expand Down
20 changes: 19 additions & 1 deletion src/modules/esl-toggleable/core/esl-toggleable.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {ExportNs} from '../../esl-utils/environment/export-ns';
import {ESC, SYSTEM_KEYS} from '../../esl-utils/dom/keys';
import {SYSTEM_KEYS, ESC, TAB} from '../../esl-utils/dom/keys';
import {CSSClassUtils} from '../../esl-utils/dom/class';
import {prop, attr, jsonAttr, listen} from '../../esl-utils/decorators';
import {defined, copyDefinedKeys} from '../../esl-utils/misc/object';
Expand All @@ -9,7 +9,9 @@ import {hasHover} from '../../esl-utils/environment/device-detector';
import {DelayedTask} from '../../esl-utils/async/delayed-task';
import {ESLBaseElement} from '../../esl-base-element/core';
import {findParent, isMatches} from '../../esl-utils/dom/traversing';
import {getKeyboardFocusableElements, handleFocusFlow} from '../../esl-utils/dom/focus';

import type {FocusFlowType} from '../../esl-utils/dom/focus';
import type {DelegatedEvent} from '../../esl-event-listener/core/types';

/** Default Toggleable action params type definition */
Expand Down Expand Up @@ -101,6 +103,14 @@ export class ESLToggleable extends ESLBaseElement {
*/
@attr({defaultValue: '*'}) public containerActiveClassTarget: string;

/**
* Focus behaviour. Awailable values:
* - 'none' - no focus management
* - '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
*/
@attr({defaultValue: 'none'}) public focusBehaviour: FocusFlowType;

/** Toggleable group meta information to organize groups */
@attr({name: 'group'}) public groupName: string;
/** Selector to mark inner close triggers */
Expand Down Expand Up @@ -302,6 +312,11 @@ export class ESLToggleable extends ESLBaseElement {
el ? activators.set(this, el) : activators.delete(this);
}

/** List of all focusable elements inside instance */
public get $focusables(): HTMLElement[] {
return getKeyboardFocusableElements(this) as HTMLElement[];
}

/** Returns the element to apply a11y attributes */
protected get $a11yTarget(): HTMLElement | null {
const target = this.getAttribute('a11y-target');
Expand Down Expand Up @@ -353,6 +368,9 @@ export class ESLToggleable extends ESLBaseElement {
if (this.closeOnEsc && e.key === ESC) {
this.hide({initiator: 'keyboard', event: e});
}
if (this.focusBehaviour !== 'none' && e.key === TAB) {
handleFocusFlow(e, this.$focusables, this.activator || this, this.focusBehaviour);
}
}

@listen({auto: false, event: 'mouseenter'})
Expand Down
43 changes: 9 additions & 34 deletions src/modules/esl-tooltip/core/esl-tooltip.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import {ExportNs} from '../../esl-utils/environment/export-ns';
import {ESLPopup} from '../../esl-popup/core';
import {memoize, attr, boolAttr, listen, prop} from '../../esl-utils/decorators';
import {TAB} from '../../esl-utils/dom/keys';
import {getKeyboardFocusableElements, handleFocusChain} from '../../esl-utils/dom/focus';
import {memoize, attr, boolAttr} from '../../esl-utils/decorators';

import type {ESLPopupActionParams} from '../../esl-popup/core';
import type {PositionType} from '../../esl-popup/core/esl-popup-position';
import type {FocusFlowType} from '../../esl-utils/dom/focus';

export interface ESLTooltipActionParams extends ESLPopupActionParams {
/** text to be shown */
Expand Down Expand Up @@ -33,7 +32,13 @@ export class ESLTooltip extends ESLPopup {
autofocus: true
};

@prop(false) public hasFocusLoop: boolean;
/**
* Focus behaviour. Awailable values:
* - 'none' - no focus management
* - '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 focusBehaviour: FocusFlowType;

/**
* Tooltip position relative to the trigger.
Expand All @@ -53,19 +58,6 @@ export class ESLTooltip extends ESLPopup {
return document.createElement('esl-tooltip');
}

/** List of all focusable elements inside instance */
public get $focusables(): HTMLElement[] {
return getKeyboardFocusableElements(this) as HTMLElement[];
}

/** First and last focusable elements inside instance */
public get $boundaryFocusable(): {$first: HTMLElement | undefined, $last: HTMLElement | undefined} {
const {$focusables} = this;
const $first = $focusables[0];
const $last = $focusables.pop();
return {$first, $last};
}

/** Active state marker */
public static get open(): boolean {
return this.sharedInstance.open;
Expand Down Expand Up @@ -113,23 +105,6 @@ export class ESLTooltip extends ESLPopup {
super.onHide(params);
this.parentNode === document.body && document.body.removeChild(this);
}

@listen({inherit: true})
protected override _onKeyboardEvent(e: KeyboardEvent): void {
super._onKeyboardEvent(e);
if (e.key === TAB) this._onTabKey(e);
}

/** Actions on TAB keypressed */
protected _onTabKey(e: KeyboardEvent): void {
if (!this.activator) return;
const {$first, $last} = this.$boundaryFocusable;
if (this.hasFocusLoop) return handleFocusChain(e, $first, $last) as void;
if (!$last || e.target === (e.shiftKey ? $first : $last)) {
this.activator.focus();
e.preventDefault();
}
}
}

declare global {
Expand Down
22 changes: 22 additions & 0 deletions src/modules/esl-utils/dom/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,28 @@ export const handleFocusChain = (e: KeyboardEvent, first: HTMLElement | undefine
}
};

export type FocusFlowType = 'none' | 'loop' | 'chain';

export const handleFocusFlow = (
e: KeyboardEvent,
$focusables: HTMLElement[],
$fallback: HTMLElement,
type: FocusFlowType = 'loop'
): boolean | undefined => {
if (!type || type === 'none') return;

const $first = $focusables[0];
const $last = $focusables[$focusables.length - 1];

if (type === 'loop') return handleFocusChain(e, $first, $last);

if (type === 'chain' && $last && $fallback) {
if (e.target !== (e.shiftKey ? $first : $last)) return;
$fallback.focus();
e.preventDefault();
}
};

/**
* TODO: add visibility check
* Gets keyboard-focusable elements within a specified root element
Expand Down