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(esl-toggleale/esl-popup): focus management cleanup (v2) #2766

Merged
merged 4 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 10 additions & 7 deletions src/modules/esl-popup/core/esl-popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {bind, memoize, ready, attr, boolAttr, jsonAttr, listen, decorate} from '
import {ESLTraversingQuery} from '../../esl-traversing-query/core';
import {afterNextRender, rafDecorator} from '../../esl-utils/async/raf';
import {ESLToggleable} from '../../esl-toggleable/core';
import {isElement, isRelativeNode, isRTL, Rect, getListScrollParents, getViewportRect} from '../../esl-utils/dom';
import {isElement, isRelativeNode, isRTL, Rect, getListScrollParents, getViewportRect, type FocusFlowType} from '../../esl-utils/dom';
import {parseBoolean, parseNumber, toBooleanAttribute} from '../../esl-utils/misc/format';
import {copyDefinedKeys} from '../../esl-utils/misc/object';
import {ESLIntersectionTarget, ESLIntersectionEvent} from '../../esl-event-listener/core/targets/intersection.target';
Expand Down Expand Up @@ -44,8 +44,6 @@ export interface ESLPopupActionParams extends ESLToggleableActionParams {
container?: string;
/** Container element that defines bounds of popups visibility (is not taken into account if the container attr is set on popup) */
containerEl?: HTMLElement;
/** Autofocus on popup/activator */
autofocus?: boolean;

/** Extra class to add to popup on activation */
extraClass?: string;
Expand Down Expand Up @@ -111,6 +109,15 @@ export class ESLPopup extends ESLToggleable {
@attr({parser: parseBoolean, serializer: toBooleanAttribute, defaultValue: true})
public override closeOnOutsideAction: 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: 'none'})
public override focusBehaviour: FocusFlowType;

public $placeholder: ESLPopupPlaceholder | null;

protected _extraClass?: string;
Expand Down Expand Up @@ -227,9 +234,6 @@ export class ESLPopup extends ESLToggleable {
// running as a separate task solves the problem with incorrect positioning on the first showing
if (wasOpened) this.afterOnShow(params);
else afterNextRender(() => this.afterOnShow(params));

// Autofocus logic
afterNextRender(() => params.autofocus && this.focus({preventScroll: true}));
}

/**
Expand All @@ -241,7 +245,6 @@ export class ESLPopup extends ESLToggleable {
this.beforeOnHide(params);
super.onHide(params);
this.afterOnHide(params);
params.autofocus && this.activator?.focus({preventScroll: true});
}

/**
Expand Down
22 changes: 1 addition & 21 deletions src/modules/esl-share/core/esl-share-popup.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import {ExportNs} from '../../esl-utils/environment/export-ns';
import {ESLPopup} from '../../esl-popup/core/esl-popup';
import {attr, bind, boolAttr, listen, memoize} from '../../esl-utils/decorators';
import {bind, boolAttr, listen, memoize} from '../../esl-utils/decorators';
import {ESLShareButton} from './esl-share-button';
import {ESLShareConfig} from './esl-share-config';

import type {ESLPopupActionParams} from '../../esl-popup/core/esl-popup';
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 Down Expand Up @@ -36,7 +34,6 @@ export class ESLSharePopup extends ESLPopup {
/** Default params to pass into the share popup */
static override DEFAULT_PARAMS: ESLSharePopupActionParams = {
...ESLPopup.DEFAULT_PARAMS,
autofocus: true,
position: 'top',
hideDelay: 300
};
Expand All @@ -56,23 +53,6 @@ export class ESLSharePopup extends ESLPopup {
return ESLSharePopup.create();
}

/**
* 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;

Expand Down
75 changes: 65 additions & 10 deletions src/modules/esl-toggleable/core/esl-toggleable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {parseBoolean, toBooleanAttribute} from '../../esl-utils/misc/format';
import {sequentialUID} from '../../esl-utils/misc/uid';
import {hasHover} from '../../esl-utils/environment/device-detector';
import {DelayedTask} from '../../esl-utils/async/delayed-task';
import {afterNextRender} from '../../esl-utils/async/raf';
import {ESLBaseElement} from '../../esl-base-element/core';
import {findParent, isMatches} from '../../esl-utils/dom/traversing';
import {getKeyboardFocusableElements, handleFocusFlow} from '../../esl-utils/dom/focus';
Expand Down Expand Up @@ -103,14 +104,6 @@ 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 All @@ -123,6 +116,14 @@ export class ESLToggleable extends ESLBaseElement {
/** Close the Toggleable on a click/tap outside */
@attr({parser: parseBoolean, serializer: toBooleanAttribute}) public closeOnOutsideAction: boolean;

/**
* 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;

/** Initial params to pass to show/hide action on the start */
@jsonAttr<ESLToggleableActionParams>({defaultValue: {force: true, initiator: 'init'}})
public initialParams: ESLToggleableActionParams;
Expand Down Expand Up @@ -199,6 +200,32 @@ export class ESLToggleable extends ESLBaseElement {
track ? this.$$on(this._onMouseLeave) : this.$$off(this._onMouseLeave);
}

/** Focus the first focusable element or the element itself if it's focusable */
public override focus(options?: FocusOptions): void {
if (this.hasAttribute('tabindex')) {
super.focus(options);
} else {
const focusable = this.$focusables[0];
focusable && focusable.focus(options);
}
}

/**
* Delegate focus to the last activator (or move it out if there is no activator)
* if the focused element is inside the Toggleable.
* @param deep - if true, the inner focused element will be handled as well
*/
public override blur(deep = false): void {
if (!this.hasFocus) return;
if (this.activator) {
this.activator.focus();
} else if (deep) {
(document.activeElement! as HTMLElement).blur();
} else {
super.blur();
}
}

/** Function to merge the result action params */
protected mergeDefaultParams(params?: ESLToggleableActionParams): ESLToggleableActionParams {
const type = this.constructor as typeof ESLToggleable;
Expand Down Expand Up @@ -259,7 +286,7 @@ export class ESLToggleable extends ESLBaseElement {
* Inner state and 'open' attribute are not affected and updated before `onShow` execution.
* Adds CSS classes, update a11y and fire {@link ESLToggleable.REFRESH_EVENT} event by default.
*/
protected onShow(params: ESLToggleableActionParams): void {
protected onShow(params: ESLToggleableActionParams): void | Promise<void> {
this.open = true;
CSSClassUtils.add(this, this.activeClass);
CSSClassUtils.add(document.body, this.bodyClass, this);
Expand All @@ -270,6 +297,11 @@ export class ESLToggleable extends ESLBaseElement {

this.updateA11y();
this.$$fire(this.REFRESH_EVENT); // To notify other components about content change

// Focus on the first focusable element
if (this.focusBehaviour !== 'none') {
queueMicrotask(() => afterNextRender(() => this.focus({preventScroll: true})));
}
}

/**
Expand All @@ -285,7 +317,7 @@ export class ESLToggleable extends ESLBaseElement {
* Inner state and 'open' attribute are not affected and updated before `onShow` execution.
* Removes CSS classes and update a11y by default.
*/
protected onHide(params: ESLToggleableActionParams): void {
protected onHide(params: ESLToggleableActionParams): void | Promise<void> {
this.open = false;
CSSClassUtils.remove(this, this.activeClass);
CSSClassUtils.remove(document.body, this.bodyClass, this);
Expand All @@ -294,6 +326,9 @@ export class ESLToggleable extends ESLBaseElement {
$container && CSSClassUtils.remove($container, this.containerActiveClass, this);
}
this.updateA11y();

// Blur if the toggleable has focus
queueMicrotask(() => afterNextRender(() => this.blur(true)));
}

/** Active state marker */
Expand All @@ -312,6 +347,11 @@ export class ESLToggleable extends ESLBaseElement {
el ? activators.set(this, el) : activators.delete(this);
}

/** If the togleable or its content has focus */
public get hasFocus(): boolean {
return this === document.activeElement || this.contains(document.activeElement);
}

/** List of all focusable elements inside instance */
public get $focusables(): HTMLElement[] {
return getKeyboardFocusableElements(this) as HTMLElement[];
Expand Down Expand Up @@ -367,12 +407,27 @@ export class ESLToggleable extends ESLBaseElement {
protected _onKeyboardEvent(e: KeyboardEvent): void {
if (this.closeOnEsc && e.key === ESC) {
this.hide({initiator: 'keyboard', event: e});
e.stopPropagation();
}
if (this.focusBehaviour !== 'none' && e.key === TAB) {
handleFocusFlow(e, this.$focusables, this.activator || this, this.focusBehaviour);
}
}

@listen('focusout')
protected _onFocusOut(e: FocusEvent): void {
if (!this.open) return;
if (this.focusBehaviour === 'chain') {
afterNextRender(() => {
if (this.hasFocus) return;
this.hide({initiator: 'focusout', event: e});
});
}
if (this.focusBehaviour === 'loop') {
this.focus({preventScroll: true});
}
}

@listen({auto: false, event: 'mouseenter'})
protected _onMouseEnter(e: MouseEvent): void {
const baseParams: ESLToggleableActionParams = {
Expand Down
25 changes: 4 additions & 21 deletions src/modules/esl-tooltip/core/esl-tooltip.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import {ExportNs} from '../../esl-utils/environment/export-ns';
import {ESLPopup} from '../../esl-popup/core';
import {memoize, attr, boolAttr} from '../../esl-utils/decorators';
import {memoize, 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 All @@ -29,26 +27,10 @@ export class ESLTooltip extends ESLPopup {
/** Default params to pass into the tooltip on show/hide actions */
public static override DEFAULT_PARAMS: ESLTooltipActionParams = {
...ESLPopup.DEFAULT_PARAMS,
autofocus: true
position: 'top',
hideDelay: 300
};

/**
* 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.
* Currently supported: 'top', 'bottom', 'left', 'right' position types ('top' by default)
*/
@attr({defaultValue: 'top'}) public override position: PositionType;

/** Tooltip 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;

Expand Down Expand Up @@ -94,6 +76,7 @@ export class ESLTooltip extends ESLPopup {
if (params.html) {
this.innerHTML = params.html;
}

this.dir = params.dir || '';
this.lang = params.lang || '';
this.parentNode !== document.body && document.body.appendChild(this);
Expand Down