Skip to content

Commit

Permalink
Merge pull request #2121 from exadel-inc/feat/base-trigger
Browse files Browse the repository at this point in the history
feat(esl-toggleable): introducing a base trigger class in purpose to simplify and make Trigger-Toggleable constructions more API strict in ESL
  • Loading branch information
ala-n authored Dec 27, 2023
2 parents 2ae882d + 9da43c2 commit 1d7e197
Show file tree
Hide file tree
Showing 6 changed files with 260 additions and 232 deletions.
27 changes: 9 additions & 18 deletions src/modules/esl-share/core/esl-share.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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}`;
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/modules/esl-tab/core/esl-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/modules/esl-trigger/core.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export type {ESLTriggerTagShape} from './core/esl-trigger.shape';

export * from './core/esl-base-trigger';
export * from './core/esl-trigger';
232 changes: 232 additions & 0 deletions src/modules/esl-trigger/core/esl-base-trigger.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
3 changes: 2 additions & 1 deletion src/modules/esl-trigger/core/esl-trigger.shape.ts
Original file line number Diff line number Diff line change
@@ -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<T extends ESLTrigger = ESLTrigger> extends ESLBaseElementShape<T> {
export interface ESLTriggerTagShape<T extends ESLBaseTrigger = ESLTrigger> extends ESLBaseElementShape<T> {
/** Define target Toggleable {@link ESLTraversingQuery} selector. `next` by default */
'target'?: string;

Expand Down
Loading

0 comments on commit 1d7e197

Please sign in to comment.