diff --git a/src/modules/esl-share/core/esl-share.ts b/src/modules/esl-share/core/esl-share.ts index c23cf45e0..06ba23a7e 100644 --- a/src/modules/esl-share/core/esl-share.ts +++ b/src/modules/esl-share/core/esl-share.ts @@ -1,12 +1,12 @@ import {ExportNs} from '../../esl-utils/environment/export-ns'; import {attr, boolAttr, jsonAttr, prop, ready} from '../../esl-utils/decorators'; import {ESLTraversingQuery} from '../../esl-traversing-query/core'; -import {ESLTrigger} from '../../esl-trigger/core'; +import {ESLBaseTrigger} from '../../esl-trigger/core'; + import {ESLSharePopup} from './esl-share-popup'; import type {ESLToggleable} from '../../esl-toggleable/core/esl-toggleable'; import type {ESLSharePopupActionParams} from './esl-share-popup'; - export type {ESLShareTagShape} from './esl-share.shape'; /** @@ -16,9 +16,9 @@ export type {ESLShareTagShape} from './esl-share.shape'; * ESLShare is a component that allows triggering {@link ESLSharePopup} instance state changes. */ @ExportNs('Share') -export class ESLShare extends ESLTrigger { +export class ESLShare extends ESLBaseTrigger { public static override is = 'esl-share'; - public static override observedAttributes = ['list']; + public static observedAttributes = ['list']; /** Register {@link ESLShare} component and dependent {@link ESLSharePopup} */ public static override register(): void { @@ -51,7 +51,6 @@ export class ESLShare extends ESLTrigger { public override get $target(): ESLToggleable | null { return ESLSharePopup.sharedInstance; } - public override set $target(value: any) {} /** Checks that the target is in active state */ public override get isTargetActive(): boolean { @@ -78,7 +77,10 @@ export class ESLShare extends ESLTrigger { @ready protected override connectedCallback(): void { super.connectedCallback(); - this.onReady(); + if (!this.ready) { + this.$$attr('ready', true); + this.$$fire(this.SHARE_READY_EVENT, {bubbles: false}); + } } protected override attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void { @@ -92,9 +94,6 @@ export class ESLShare extends ESLTrigger { this.$target?.hide(); } - /** Updates `$target` Toggleable from `target` selector */ - public override updateTargetFromSelector(): void {} - /** Gets attribute value from the closest element with group behavior settings */ protected getClosestRelatedAttr(attrName: string): string | null { const relatedAttrName = `${this.baseTagName}-${attrName}`; @@ -104,22 +103,14 @@ export class ESLShare extends ESLTrigger { /** Merges params to pass to the toggleable */ protected override mergeToggleableParams(this: ESLShare, ...params: ESLSharePopupActionParams[]): ESLSharePopupActionParams { - return Object.assign({ + return super.mergeToggleableParams({ initiator: 'share', - activator: this, containerEl: this.$containerEl, list: this.list, dir: this.currentDir, lang: this.currentLang }, this.popupParams, ...params); } - - /** Actions on complete init and ready component */ - private onReady(): void { - if (this.ready) return; - this.$$attr('ready', true); - this.$$fire(this.SHARE_READY_EVENT, {bubbles: false}); - } } declare global { diff --git a/src/modules/esl-tab/core/esl-tab.ts b/src/modules/esl-tab/core/esl-tab.ts index 79f79b8d7..2a9cf62a8 100644 --- a/src/modules/esl-tab/core/esl-tab.ts +++ b/src/modules/esl-tab/core/esl-tab.ts @@ -14,7 +14,7 @@ import {ESLTrigger} from '../../esl-trigger/core'; export class ESLTab extends ESLTrigger { public static override is = 'esl-tab'; - @attr({defaultValue: 'show'}) public override mode: string; + @attr({defaultValue: 'show'}) public override mode: 'show' | 'toggle' | 'hide'; @attr({defaultValue: 'active'}) public override activeClass: string; public override initA11y(): void { diff --git a/src/modules/esl-trigger/core.ts b/src/modules/esl-trigger/core.ts index 22eecfc6c..dc563765d 100644 --- a/src/modules/esl-trigger/core.ts +++ b/src/modules/esl-trigger/core.ts @@ -1,3 +1,4 @@ export type {ESLTriggerTagShape} from './core/esl-trigger.shape'; +export * from './core/esl-base-trigger'; export * from './core/esl-trigger'; diff --git a/src/modules/esl-trigger/core/esl-base-trigger.ts b/src/modules/esl-trigger/core/esl-base-trigger.ts new file mode 100644 index 000000000..6f4b58a8c --- /dev/null +++ b/src/modules/esl-trigger/core/esl-base-trigger.ts @@ -0,0 +1,232 @@ +import {ESLBaseElement} from '../../esl-base-element/core'; +import {DeviceDetector} from '../../esl-utils/environment/device-detector'; +import {isElement} from '../../esl-utils/dom/api'; +import {setAttr} from '../../esl-utils/dom/attr'; +import {CSSClassUtils} from '../../esl-utils/dom/class'; +import {ENTER, SPACE, ESC} from '../../esl-utils/dom/keys'; +import {attr, boolAttr, prop, listen} from '../../esl-utils/decorators'; +import {parseBoolean, parseNumber, toBooleanAttribute} from '../../esl-utils/misc/format'; +import {ESLMediaQuery} from '../../esl-media-query/core'; +import {ESLTraversingQuery} from '../../esl-traversing-query/core'; + +import type {ESLToggleable, ESLToggleableActionParams} from '../../esl-toggleable/core/esl-toggleable'; + +/** Base class for elements that should trigger {@link ESLToggleable} instance */ +export abstract class ESLBaseTrigger extends ESLBaseElement { + /** Event that represents {@link ESLTrigger} state change */ + @prop('') public CHANGE_EVENT: string; + /** Events to observe target {@link ESLToggleable} instance state */ + @prop('esl:show esl:hide') public OBSERVED_EVENTS: string; + + /** @readonly Observed Toggleable active state marker */ + @boolAttr({readonly: true}) public active: boolean; + + /** CSS classes to set on active state */ + @attr({defaultValue: ''}) public activeClass: string; + /** Target element {@link ESLTraversingQuery} selector to set `activeClass` */ + @attr({defaultValue: ''}) public activeClassTarget: string; + + /** Click event tracking media query. Default: `all` */ + @attr({defaultValue: 'all'}) public trackClick: string; + /** Hover event tracking media query. Default: `none` */ + @attr({defaultValue: 'not all'}) public trackHover: string; + + /** Value of aria-label for active state */ + @attr({defaultValue: null}) public a11yLabelActive: string | null; + /** Value of aria-label for inactive state */ + @attr({defaultValue: null}) public a11yLabelInactive: string | null; + + /** Show delay value */ + @attr({defaultValue: 'none'}) public showDelay: string; + /** Hide delay value */ + @attr({defaultValue: 'none'}) public hideDelay: string; + + /** + * Alternative show delay value for hover action. + * Note: the value should be numeric in order to delay hover action. + */ + @attr({defaultValue: '0'}) public hoverShowDelay: string; + /** + * Alternative hide delay value for hover action. + * Note: the value should be numeric in order to delay hover action. + */ + @attr({defaultValue: '0'}) public hoverHideDelay: string; + + /** Prevent ESC keyboard event handling for target element hiding */ + @attr({parser: parseBoolean, serializer: toBooleanAttribute}) public ignoreEsc: boolean; + + /** Action to pass to the Toggleable. Supports `show`, `hide` and `toggle` values. `toggle` by default */ + @prop('toggle') public mode: 'toggle' | 'show' | 'hide'; + + /** Target observable Toggleable */ + public abstract get $target(): ESLToggleable | null; + + /** Element target to setup aria attributes */ + public get $a11yTarget(): HTMLElement | null { + return this; + } + + /** Value to setup aria-label */ + public get a11yLabel(): string | null { + if (!this.$target) return null; + return (this.isTargetActive ? this.a11yLabelActive : this.a11yLabelInactive) || null; + } + + /** Marker to allow track hover */ + public get allowHover(): boolean { + return DeviceDetector.hasHover && ESLMediaQuery.for(this.trackHover).matches; + } + /** Marker to allow track clicks */ + public get allowClick(): boolean { + return ESLMediaQuery.for(this.trackClick).matches; + } + + /** Checks that the target is in active state */ + public get isTargetActive(): boolean { + return !!this.$target?.open; + } + + protected override connectedCallback(): void { + super.connectedCallback(); + this.initA11y(); + } + + /** Check if the event target should be ignored */ + protected isTargetIgnored(target: EventTarget | null): boolean { + return !isElement(target); + } + + /** Merge params to pass to the toggleable */ + protected mergeToggleableParams(this: ESLBaseTrigger, ...params: ESLToggleableActionParams[]): ESLToggleableActionParams { + return Object.assign({ + initiator: 'trigger', + activator: this + }, ...params); + } + + /** Show target toggleable with passed params */ + public showTarget(params: ESLToggleableActionParams = {}): void { + const actionParams = this.mergeToggleableParams({ + delay: parseNumber(this.showDelay) + }, params); + if (this.$target && typeof this.$target.show === 'function') { + this.$target.show(actionParams); + } + } + /** Hide target toggleable with passed params */ + public hideTarget(params: ESLToggleableActionParams = {}): void { + const actionParams = this.mergeToggleableParams({ + delay: parseNumber(this.hideDelay) + }, params); + if (this.$target && typeof this.$target.hide === 'function') { + this.$target.hide(actionParams); + } + } + /** Toggles target toggleable with passed params */ + public toggleTarget(params: ESLToggleableActionParams = {}, state: boolean = !this.active): void { + state ? this.showTarget(params) : this.hideTarget(params); + } + + /** + * Updates trigger state according to toggleable state + * Does not produce `esl:change:active` event + */ + public updateState(): boolean { + const {active, isTargetActive} = this; + + this.toggleAttribute('active', isTargetActive); + const clsTarget = ESLTraversingQuery.first(this.activeClassTarget, this) as HTMLElement; + clsTarget && CSSClassUtils.toggle(clsTarget, this.activeClass, isTargetActive); + + this.updateA11y(); + + return isTargetActive !== active; + } + + + /** Handles target primary (observed) event */ + protected _onPrimaryEvent(event: Event): void { + switch (this.mode) { + case 'show': + return this.showTarget({event}); + case 'hide': + return this.hideTarget({event}); + default: + return this.toggleTarget({event}); + } + } + + /** Handles ESLToggleable state change */ + @listen({ + event: (that: ESLBaseTrigger) => that.OBSERVED_EVENTS, + target: (that: ESLBaseTrigger) => that.$target + }) + protected _onTargetStateChange(originalEvent?: Event): void { + if (!this.updateState()) return; + const detail = {active: this.active, originalEvent}; + this.$$fire(this.CHANGE_EVENT, {detail}); + } + + /** Handles `click` event */ + @listen('click') + protected _onClick(event: MouseEvent): void { + if (!this.allowClick || this.isTargetIgnored(event.target)) return; + event.preventDefault(); + this._onPrimaryEvent(event); + } + + /** Handles `keydown` event */ + @listen('keydown') + protected _onKeydown(event: KeyboardEvent): void { + if (![ENTER, SPACE, ESC].includes(event.key) || this.isTargetIgnored(event.target)) return; + event.preventDefault(); + if (event.key === ESC) { + if (this.ignoreEsc) return; + this.hideTarget({event}); + } else { + this._onPrimaryEvent(event); + } + } + + /** Handles hover `mouseenter` event */ + @listen('mouseenter') + protected _onMouseEnter(event: MouseEvent): void { + if (!this.allowHover) return; + const delay = parseNumber(this.hoverShowDelay); + this.toggleTarget({event, delay}, this.mode !== 'hide'); + event.preventDefault(); + } + + /** Handles hover `mouseleave` event */ + @listen('mouseleave') + protected _onMouseLeave(event: MouseEvent): void { + if (!this.allowHover) return; + if (this.mode === 'show' || this.mode === 'hide') return; + const delay = parseNumber(this.hoverHideDelay); + this.hideTarget({event, delay, trackHover: true}); + event.preventDefault(); + } + + /** Set initial a11y attributes. Do nothing if trigger contains actionable element */ + public initA11y(): void { + if (this.$a11yTarget !== this) return; + if (!this.hasAttribute('role')) this.setAttribute('role', 'button'); + if (this.getAttribute('role') === 'button' && !this.hasAttribute('tabindex')) { + this.setAttribute('tabindex', '0'); + } + } + + /** Update aria attributes */ + public updateA11y(): void { + const target = this.$a11yTarget; + if (!target) return; + + if (this.a11yLabelActive !== null || this.a11yLabelInactive !== null) { + setAttr(target, 'aria-label', this.a11yLabel); + } + setAttr(target, 'aria-expanded', String(this.active)); + if (this.$target && this.$target.id) { + setAttr(target, 'aria-controls', this.$target.id); + } + } +} diff --git a/src/modules/esl-trigger/core/esl-trigger.shape.ts b/src/modules/esl-trigger/core/esl-trigger.shape.ts index d2941ccda..86bafed5f 100644 --- a/src/modules/esl-trigger/core/esl-trigger.shape.ts +++ b/src/modules/esl-trigger/core/esl-trigger.shape.ts @@ -1,11 +1,12 @@ import type {ESLBaseElementShape} from '../../esl-base-element/core/esl-base-element.shape'; import type {ESLTrigger} from './esl-trigger'; +import type {ESLBaseTrigger} from './esl-base-trigger'; /** * Tag declaration interface of {@link ESLTrigger} element * Used for TSX declaration */ -export interface ESLTriggerTagShape extends ESLBaseElementShape { +export interface ESLTriggerTagShape extends ESLBaseElementShape { /** Define target Toggleable {@link ESLTraversingQuery} selector. `next` by default */ 'target'?: string; diff --git a/src/modules/esl-trigger/core/esl-trigger.ts b/src/modules/esl-trigger/core/esl-trigger.ts index d9cb4b96d..3055eefef 100644 --- a/src/modules/esl-trigger/core/esl-trigger.ts +++ b/src/modules/esl-trigger/core/esl-trigger.ts @@ -1,36 +1,21 @@ -import {ESLBaseElement} from '../../esl-base-element/core'; import {ExportNs} from '../../esl-utils/environment/export-ns'; -import {DeviceDetector} from '../../esl-utils/environment/device-detector'; import {isElement} from '../../esl-utils/dom/api'; -import {setAttr} from '../../esl-utils/dom/attr'; -import {CSSClassUtils} from '../../esl-utils/dom/class'; -import {ENTER, SPACE, ESC} from '../../esl-utils/dom/keys'; -import {attr, boolAttr, prop, listen, ready} from '../../esl-utils/decorators'; -import {parseBoolean, parseNumber, toBooleanAttribute} from '../../esl-utils/misc/format'; -import {ESLMediaQuery} from '../../esl-media-query/core'; +import {attr, prop, ready} from '../../esl-utils/decorators'; import {ESLTraversingQuery} from '../../esl-traversing-query/core'; - import {ESLToggleablePlaceholder} from '../../esl-toggleable/core'; -import type {ESLToggleable, ESLToggleableActionParams} from '../../esl-toggleable/core/esl-toggleable'; +import {ESLBaseTrigger} from './esl-base-trigger'; + +import type {ESLToggleable} from '../../esl-toggleable/core/esl-toggleable'; + @ExportNs('Trigger') -export class ESLTrigger extends ESLBaseElement { +export class ESLTrigger extends ESLBaseTrigger { public static override is = 'esl-trigger'; public static observedAttributes = ['target']; /** Event that represents {@link ESLTrigger} state change */ - @prop('esl:change:active') public CHANGE_EVENT: string; - /** Events to observe target {@link ESLToggleable} instance state */ - @prop('esl:show esl:hide') public OBSERVED_EVENTS: string; - - /** @readonly Observed Toggleable active state marker */ - @boolAttr({readonly: true}) public active: boolean; - - /** CSS classes to set on active state */ - @attr({defaultValue: ''}) public activeClass: string; - /** Target element {@link ESLTraversingQuery} selector to set `activeClass` */ - @attr({defaultValue: ''}) public activeClassTarget: string; + @prop('esl:change:active') public override CHANGE_EVENT: string; /** Selector for ignored inner elements */ @attr({defaultValue: 'a[href]'}) public ignore: string; @@ -38,47 +23,14 @@ export class ESLTrigger extends ESLBaseElement { /** Target Toggleable {@link ESLTraversingQuery} selector. `::next` by default */ @attr({defaultValue: '::next'}) public target: string; /** Action to pass to the Toggleable. Supports `show`, `hide` and `toggle` values. `toggle` by default */ - @attr({defaultValue: 'toggle'}) public mode: string; - - /** Click event tracking media query. Default: `all` */ - @attr({defaultValue: 'all'}) public trackClick: string; - /** Hover event tracking media query. Default: `none` */ - @attr({defaultValue: 'not all'}) public trackHover: string; + @attr({defaultValue: 'toggle'}) public override mode: 'toggle' | 'show' | 'hide'; /** Selector of inner target element to place aria attributes. Uses trigger itself if blank */ @attr({defaultValue: ''}) public a11yTarget: string; - /** Value of aria-label for active state */ - @attr({defaultValue: null}) public a11yLabelActive: string | null; - /** Value of aria-label for inactive state */ - @attr({defaultValue: null}) public a11yLabelInactive: string | null; - - /** Show delay value */ - @attr({defaultValue: 'none'}) public showDelay: string; - /** Hide delay value */ - @attr({defaultValue: 'none'}) public hideDelay: string; - - /** - * Show delay value override for hover. - * Note: the value should be numeric in order to delay hover action triggers for correct handling on mobile browsers. - */ - @attr({defaultValue: '0'}) public hoverShowDelay: string; - /** - * Hide delay value override for hover - * Note: the value should be numeric in order to delay hover action triggers for correct handling on mobile browsers. - */ - @attr({defaultValue: '0'}) public hoverHideDelay: string; - - /** Prevent ESC keyboard event handling for target element hiding */ - @attr({parser: parseBoolean, serializer: toBooleanAttribute}) public ignoreEsc: boolean; protected _$target: ESLToggleable | null; - protected override attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void { - if (!this.connected) return; - if (attrName === 'target') return this.updateTargetFromSelector(); - } - /** Target observable Toggleable */ public get $target(): ESLToggleable | null { return this._$target; @@ -91,30 +43,10 @@ export class ESLTrigger extends ESLBaseElement { } /** Element target to setup aria attributes */ - public get $a11yTarget(): HTMLElement | null { + public override get $a11yTarget(): HTMLElement | null { return this.a11yTarget ? this.querySelector(this.a11yTarget) : this; } - /** Value to setup aria-label */ - public get a11yLabel(): string | null { - if (!this.$target) return null; - return (this.isTargetActive ? this.a11yLabelActive : this.a11yLabelInactive) || null; - } - - /** Marker to allow track hover */ - public get allowHover(): boolean { - return DeviceDetector.hasHover && ESLMediaQuery.for(this.trackHover).matches; - } - /** Marker to allow track clicks */ - public get allowClick(): boolean { - return ESLMediaQuery.for(this.trackClick).matches; - } - - /** Checks that the target is in active state */ - public get isTargetActive(): boolean { - return !!this.$target?.open; - } - @ready protected override connectedCallback(): void { super.connectedCallback(); @@ -122,6 +54,11 @@ export class ESLTrigger extends ESLBaseElement { this.initA11y(); } + protected override attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void { + if (!this.connected) return; + if (attrName === 'target') return this.updateTargetFromSelector(); + } + /** Update `$target` Toggleable from `target` selector */ public updateTargetFromSelector(): void { if (!this.target) return; @@ -134,146 +71,12 @@ export class ESLTrigger extends ESLBaseElement { } /** Check if the event target should be ignored */ - protected isTargetIgnored(target: EventTarget | null): boolean { + protected override isTargetIgnored(target: EventTarget | null): boolean { if (!target || !isElement(target) || !this.ignore) return false; const $ignore = target.closest(this.ignore); // Ignore only inner elements (but do not ignore the trigger itself) return !!$ignore && $ignore !== this && this.contains($ignore); } - - /** Merge params to pass to the toggleable */ - protected mergeToggleableParams(this: ESLTrigger, ...params: ESLToggleableActionParams[]): ESLToggleableActionParams { - return Object.assign({ - initiator: 'trigger', - activator: this - }, ...params); - } - - /** Show target toggleable with passed params */ - public showTarget(params: ESLToggleableActionParams = {}): void { - const actionParams = this.mergeToggleableParams({ - delay: parseNumber(this.showDelay) - }, params); - if (this.$target && typeof this.$target.show === 'function') { - this.$target.show(actionParams); - } - } - /** Hide target toggleable with passed params */ - public hideTarget(params: ESLToggleableActionParams = {}): void { - const actionParams = this.mergeToggleableParams({ - delay: parseNumber(this.hideDelay) - }, params); - if (this.$target && typeof this.$target.hide === 'function') { - this.$target.hide(actionParams); - } - } - /** Toggles target toggleable with passed params */ - public toggleTarget(params: ESLToggleableActionParams = {}, state: boolean = !this.active): void { - state ? this.showTarget(params) : this.hideTarget(params); - } - - /** - * Updates trigger state according to toggleable state - * Does not produce `esl:change:active` event - */ - public updateState(): boolean { - const {isTargetActive} = this; - const wasActive = this.active; - - this.toggleAttribute('active', isTargetActive); - const clsTarget = ESLTraversingQuery.first(this.activeClassTarget, this) as HTMLElement; - clsTarget && CSSClassUtils.toggle(clsTarget, this.activeClass, isTargetActive); - - this.updateA11y(); - - return isTargetActive !== wasActive; - } - - /** Handles ESLToggleable state change */ - @listen({ - event: (that: ESLTrigger) => that.OBSERVED_EVENTS, - target: (that: ESLTrigger) => that.$target - }) - protected _onTargetStateChange(originalEvent?: Event): void { - if (!this.updateState()) return; - const detail = {active: this.active, originalEvent}; - this.$$fire(this.CHANGE_EVENT, {detail}); - } - - /** Handles `click` event */ - @listen('click') - protected _onClick(event: MouseEvent): void { - if (!this.allowClick || this.isTargetIgnored(event.target)) return; - event.preventDefault(); - this._onPrimaryEvent(event); - } - - /** Handles `keydown` event */ - @listen('keydown') - protected _onKeydown(event: KeyboardEvent): void { - if (![ENTER, SPACE, ESC].includes(event.key) || this.isTargetIgnored(event.target)) return; - event.preventDefault(); - if (event.key === ESC) { - if (this.ignoreEsc) return; - this.hideTarget({event}); - } else { - this._onPrimaryEvent(event); - } - } - - /** Handles target primary (observed) event */ - protected _onPrimaryEvent(event: Event): void { - switch (this.mode) { - case 'show': - return this.showTarget({event}); - case 'hide': - return this.hideTarget({event}); - default: - return this.toggleTarget({event}); - } - } - - /** Handles hover `mouseenter` event */ - @listen('mouseenter') - protected _onMouseEnter(event: MouseEvent): void { - if (!this.allowHover) return; - const delay = parseNumber(this.hoverShowDelay); - this.toggleTarget({event, delay}, this.mode !== 'hide'); - event.preventDefault(); - } - - /** Handles hover `mouseleave` event */ - @listen('mouseleave') - protected _onMouseLeave(event: MouseEvent): void { - if (!this.allowHover) return; - if (this.mode === 'show' || this.mode === 'hide') return; - const delay = parseNumber(this.hoverHideDelay); - this.hideTarget({event, delay, trackHover: true}); - event.preventDefault(); - } - - /** Set initial a11y attributes. Do nothing if trigger contains actionable element */ - public initA11y(): void { - if (this.$a11yTarget !== this) return; - if (!this.hasAttribute('role')) this.setAttribute('role', 'button'); - if (this.getAttribute('role') === 'button' && !this.hasAttribute('tabindex')) { - this.setAttribute('tabindex', '0'); - } - } - - /** Update aria attributes */ - public updateA11y(): void { - const target = this.$a11yTarget; - if (!target) return; - - if (this.a11yLabelActive !== null || this.a11yLabelInactive !== null) { - setAttr(target, 'aria-label', this.a11yLabel); - } - setAttr(target, 'aria-expanded', String(this.active)); - if (this.$target && this.$target.id) { - setAttr(target, 'aria-controls', this.$target.id); - } - } } declare global {