From 2b8a0c7affa2b5af5b6baa8efd64932e735ca5d9 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Thu, 14 Nov 2024 02:39:36 +0100 Subject: [PATCH 1/4] feat(esl-toggleable): update focusBehaviour option to smoothly support boundary focus actions across different options --- .../esl-toggleable/core/esl-toggleable.ts | 75 ++++++++++++++++--- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/src/modules/esl-toggleable/core/esl-toggleable.ts b/src/modules/esl-toggleable/core/esl-toggleable.ts index ab890c0c9..261457e48 100644 --- a/src/modules/esl-toggleable/core/esl-toggleable.ts +++ b/src/modules/esl-toggleable/core/esl-toggleable.ts @@ -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'; @@ -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 */ @@ -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({defaultValue: {force: true, initiator: 'init'}}) public initialParams: ESLToggleableActionParams; @@ -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; @@ -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 { this.open = true; CSSClassUtils.add(this, this.activeClass); CSSClassUtils.add(document.body, this.bodyClass, this); @@ -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}))); + } } /** @@ -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 { this.open = false; CSSClassUtils.remove(this, this.activeClass); CSSClassUtils.remove(document.body, this.bodyClass, this); @@ -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 */ @@ -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[]; @@ -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 = { From 11b10eaf8514b5c27f257d470464ce720e2c0765 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Thu, 14 Nov 2024 02:41:26 +0100 Subject: [PATCH 2/4] feat(esl-popup): get rid from all focus management code BREAKING-CHANGE: 'autofocus' no longer available for popup, use 'focus-behaviour' instead --- src/modules/esl-popup/core/esl-popup.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/modules/esl-popup/core/esl-popup.ts b/src/modules/esl-popup/core/esl-popup.ts index 32576f0e1..cfafc072a 100644 --- a/src/modules/esl-popup/core/esl-popup.ts +++ b/src/modules/esl-popup/core/esl-popup.ts @@ -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'; @@ -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; @@ -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; @@ -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})); } /** @@ -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}); } /** From 04d6a63819049242230841365d21a1f2eb4510e7 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Thu, 14 Nov 2024 02:42:51 +0100 Subject: [PATCH 3/4] fix(esl-share): simplify code and remove overrides (according to esl-popup base state) --- src/modules/esl-share/core/esl-share-popup.ts | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/src/modules/esl-share/core/esl-share-popup.ts b/src/modules/esl-share/core/esl-share-popup.ts index 7e4587200..9fddfe0d5 100644 --- a/src/modules/esl-share/core/esl-share-popup.ts +++ b/src/modules/esl-share/core/esl-share-popup.ts @@ -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'; @@ -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 }; @@ -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; From 699ac7ff8ad5ff7d32cdbdd5fdec29632da1cf40 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Thu, 14 Nov 2024 02:43:10 +0100 Subject: [PATCH 4/4] fix(esl-tooltip): simplify code and remove overrides (according to esl-popup base state) --- src/modules/esl-tooltip/core/esl-tooltip.ts | 25 ++++----------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/src/modules/esl-tooltip/core/esl-tooltip.ts b/src/modules/esl-tooltip/core/esl-tooltip.ts index 8013f8234..67b63152b 100644 --- a/src/modules/esl-tooltip/core/esl-tooltip.ts +++ b/src/modules/esl-tooltip/core/esl-tooltip.ts @@ -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 */ @@ -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; @@ -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);