@exadel/esl
Version:
Exadel Smart Library (ESL) is the lightweight custom elements library that provide a set of super-flexible components
389 lines (388 loc) • 16.5 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var ESLCarouselAutoplayMixin_1;
import { ExportNs } from '../../../esl-utils/environment/export-ns';
import { listen, memoize } from '../../../esl-utils/decorators';
import { parseTime } from '../../../esl-utils/misc/format';
import { CSSClassUtils } from '../../../esl-utils/dom/class';
import { ESLMediaRuleList } from '../../../esl-media-query/core';
import { ESLIntersectionTarget, ESLIntersectionEvent } from '../../../esl-event-listener/core';
import { ESLCarouselPlugin } from '../esl-carousel.plugin';
import { ESLCarouselSlideEvent } from '../../core/esl-carousel.events';
import { ESLCarouselAutoplayEvent } from './esl-carousel.autoplay.event';
const isUserReason = (reason) => reason.startsWith('user:');
/**
* Autoplay plugin mixin for {@link ESLCarousel}.
* Schedules slide navigation by timeout while allowed by viewport, interaction and config constraints.
*/
let ESLCarouselAutoplayMixin = ESLCarouselAutoplayMixin_1 = class ESLCarouselAutoplayMixin extends ESLCarouselPlugin {
constructor() {
super(...arguments);
/** Manual stop flag */
this._stoppedByUser = false;
/** Manual pause flag */
this._pausedByUser = false;
/** Last known viewport intersection state */
this._inViewport = false;
/** Active cycle timeout id (null if no cycle scheduled) */
this._timeout = null;
/** Remaining time until the current cycle ends */
this._remaining = 0;
/** Current cycle full duration */
this._cycleDuration = 0;
/** Current cycle scheduling start timestamp */
this._cycleStartedAt = null;
/** Last dispatch reason */
this._lastReason = 'system:idle';
/** Last dispatched state snapshot key to suppress duplicate events */
this._lastDispatchKey = '';
}
/** True when a navigation timeout is currently scheduled */
get active() {
return !!this._timeout;
}
/** True when autoplay is paused explicitly by user action and can potentially be resumed */
get paused() {
return this.enabled && !this.active && this._pausedByUser;
}
/** Exclusive summary state for autoplay runtime */
get state() {
if (!this.enabled)
return 'disabled';
if (this.paused)
return 'paused';
if (this.blocked)
return 'blocked';
if (this.active)
return 'active';
return 'idle';
}
/** True when autoplay cannot run due to runtime blockers */
get blocked() {
if (!this._inViewport)
return true;
if (this.config.trackInteraction && (this.hovered || this.focused))
return true;
return this.hasActiveBlockingItems;
}
/**
* Effective enabled state.
* True when autoplay is not manually stopped and global duration is non-negative / valid.
*/
get enabled() {
return !this._stoppedByUser && this.duration >= 0;
}
/** Backward-compatible manual enable / disable API */
set enabled(value) {
value ? this.start() : this.stop();
}
/** Global base duration in ms (raw config parsed). Negative / NaN considered as disabled */
get duration() {
return parseTime(this.config.duration);
}
/**
* Effective current slide duration.
* Tries active slide attribute; falls back to global duration.
* Non-positive result pauses cycle for the slide only (unless global invalid disables plugin).
*/
get effectiveDuration() {
const { $activeSlide } = this.$host;
if (!$activeSlide)
return this.duration;
const value = $activeSlide.getAttribute(ESLCarouselAutoplayMixin_1.SLIDE_DURATION_ATTRIBUTE);
if (!value)
return this.duration;
const parsed = ESLMediaRuleList.parse(value, this.$host.media, parseTime);
if (typeof parsed.value === 'undefined' || isNaN(parsed.value))
return this.duration;
return parsed.value;
}
/** Remaining time of the current/paused cycle */
get remaining() {
if (!this.active || this._cycleStartedAt === null)
return this._remaining;
return Math.max(this._remaining - (Date.now() - this._cycleStartedAt), 0);
}
/** Interaction scope elements used as event subscription targets (memoized) */
get $interactionScope() {
const sel = this.config.interactionScope;
return sel ? this.$$findAll(sel) : [this.$host];
}
/** Effective interaction scope after exclusion rules are applied */
get $effectiveInteractionScope() {
const exclude = this.config.interactionScopeExclude;
if (!exclude)
return this.$interactionScope;
return this.$interactionScope.filter(($el) => !$el.matches(exclude));
}
/** True if active slide contains any blocking items */
get hasActiveBlockingItems() {
const { blockerSelector } = this.config;
return !!blockerSelector && !!this.$$find(blockerSelector);
}
/** True if any scope element is hovered */
get hovered() {
return this.$effectiveInteractionScope.some(($el) => $el.matches('*:hover'));
}
/** True if keyboard-visible focus is within scope */
get focused() {
var _a;
if (!((_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.matches('*:focus-visible')))
return false;
return this.$effectiveInteractionScope.some(($el) => $el.matches('*:focus-within'));
}
/** Backward-compatible alias for runtime allowance */
get allowed() {
return this.canRun;
}
/** Runtime predicate: autoplay may have an active timeout right now */
get canRun() {
var _a;
if (!this.enabled || this._pausedByUser || this.blocked)
return false;
if (!(this.effectiveDuration > 0))
return false;
return !!((_a = this.$host) === null || _a === void 0 ? void 0 : _a.canNavigate(this.config.command));
}
/** True if autoplay can be scheduled for the current slide */
get canSchedule() {
var _a;
const { effectiveDuration } = this;
return effectiveDuration > 0 && !!((_a = this.$host) === null || _a === void 0 ? void 0 : _a.canNavigate(this.config.command));
}
/** True if autoplay may be re-started automatically by runtime conditions */
get canAutoStart() {
return !this._pausedByUser && !this._stoppedByUser && !this.active && this.canRun;
}
/** True if runtime listeners should sync blocking state right now */
get shouldProcessBlockingState() {
return this.enabled || this.active || this._pausedByUser;
}
/** Start autoplay or resume paused cycle */
start(reason = 'user:start:call') {
if (isUserReason(reason)) {
this._stoppedByUser = false;
this._pausedByUser = false;
}
if (!this.enabled || this.blocked || this._pausedByUser)
return this.syncState(reason);
if (this.active)
return this.syncState(reason);
if (!this.canSchedule) {
this._cycleDuration = Math.max(this.effectiveDuration, 0);
this._remaining = 0;
return this.syncState(isUserReason(reason) ? reason : 'system:idle');
}
const duration = this.effectiveDuration;
const remaining = this._remaining > 0 ? this._remaining : duration;
const total = this._cycleDuration > 0 ? this._cycleDuration : duration;
this.schedule(remaining, total, reason);
}
/** Pause autoplay preserving remaining time when possible */
pause(reason = 'user:pause:call') {
if (isUserReason(reason)) {
this._stoppedByUser = false;
this._pausedByUser = true;
}
if (this.active)
this._remaining = this.remaining;
this.clearTimer();
this.syncState(reason);
}
/** Stop autoplay and clear current cycle state */
stop(reason = 'user:stop:call') {
if (isUserReason(reason)) {
this._stoppedByUser = true;
this._pausedByUser = false;
}
this.resetCycle();
this.syncState(reason);
}
/** Toggle autoplay state according to the specified control behaviour */
toggle(behaviour = 'stop') {
if (behaviour === 'pause') {
return this._pausedByUser ? this.start('user:start:control') : this.pause('user:pause:control');
}
return (this._stoppedByUser || !this.enabled) ? this.start('user:start:control') : this.stop('user:stop:control');
}
/** Init lifecycle hook */
onInit() {
this.update();
}
/** React to config changes (rebuild memoized queries, re-evaluate state) */
onConfigChange() {
super.onConfigChange();
memoize.clear(this, '$interactionScope');
this.$$off(this._onBlockingEvent);
this.stop('system:stop:config');
this.update();
if (!this._pausedByUser && !this._stoppedByUser && this.canRun)
this.start('system:start:auto');
}
/** Suspend & cleanup on disconnect */
disconnectedCallback() {
this.clearTimer();
this._remaining = 0;
this._cycleDuration = 0;
this._cycleStartedAt = null;
this.updateMarkers();
super.disconnectedCallback();
}
/** Update classes and listeners and synchronize state markers */
update() {
this.$$on({ auto: true });
this.syncState();
}
/** Update UI markers reflecting effective autoplay state */
updateMarkers() {
const { $container } = this.$host;
$container && CSSClassUtils.toggle($container, this.config.containerCls, this.enabled);
}
/** Reset current cycle state completely */
resetCycle() {
this.clearTimer();
this._remaining = 0;
this._cycleDuration = 0;
}
/** Clear active timer only */
clearTimer() {
if (this._timeout)
window.clearTimeout(this._timeout);
this._timeout = null;
this._cycleStartedAt = null;
}
/** Schedule next autoplay cycle */
schedule(duration, total = duration, reason = 'system:start:auto') {
this.clearTimer();
this._remaining = duration;
this._cycleDuration = total;
this._cycleStartedAt = Date.now();
this._timeout = window.setTimeout(() => this._onCycle(true), duration);
this.syncState(reason);
}
/** Sync current state markers and dispatch autoplay event */
syncState(reason = this._lastReason) {
this._lastReason = reason;
const { enabled, paused, blocked, active, state, effectiveDuration } = this;
const duration = effectiveDuration > 0 ? effectiveDuration : 0;
const remaining = this.active ? this._remaining : this.remaining;
const event = new ESLCarouselAutoplayEvent({ enabled, paused, blocked, active, state, duration, remaining, reason });
const dispatchKey = event.toFingerprint();
if (dispatchKey === this._lastDispatchKey)
return;
this._lastDispatchKey = dispatchKey;
this.updateMarkers();
this.$host.dispatchEvent(event);
}
/** Apply blocking logic according to configured block behaviour */
syncBlockingState() {
if (this.blocked) {
return this.config.blockBehaviour === 'pause' ? this.pause('system:pause:block') : this.stop('system:stop:block');
}
if (this.canAutoStart)
return this.start('system:start:auto');
const isIdling = !this.active && this.enabled && !this._pausedByUser;
this.syncState(isIdling ? 'system:idle' : this._lastReason);
}
/** Internal cycle handler (execute navigation then schedule next cycle if needed) */
_onCycle(exec) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
this.clearTimer();
this._remaining = 0;
if (exec)
yield ((_a = this.$host) === null || _a === void 0 ? void 0 : _a.goTo(this.config.command, { activator: this }).catch(console.debug));
if (this.active || !this.enabled || this._pausedByUser || this.blocked)
return this.syncState();
if (this.canSchedule) {
const { effectiveDuration } = this;
return this.schedule(effectiveDuration, effectiveDuration, 'system:start:auto');
}
this.syncState('system:idle');
});
}
/** Viewport intersection listener controlling runtime allowance */
_onIntersection(e) {
this._inViewport = e.isIntersecting;
if (!this.shouldProcessBlockingState)
return;
this.syncBlockingState();
}
/** Hover/focus interaction listener toggling pause state */
_onInteract() {
if (!this.shouldProcessBlockingState)
return;
this.syncBlockingState();
}
/** Slide change listener (forces cycle restart) */
_onSlideChange() {
this.stop('system:stop:slide-change');
if (this.canAutoStart)
this.start('system:start:auto');
}
/** Subscribe to events that block autoplay */
_onBlockingEvent() {
if (!this.shouldProcessBlockingState)
return;
this.syncBlockingState();
}
};
ESLCarouselAutoplayMixin.is = 'esl-carousel-autoplay';
ESLCarouselAutoplayMixin.DEFAULT_CONFIG = {
duration: 10000,
command: 'slide:next',
intersection: 0.25,
trackInteraction: true,
blockBehaviour: 'stop',
blockerSelector: '::find(esl-share[active], esl-note[active])',
watchEvents: 'esl:change:active'
};
ESLCarouselAutoplayMixin.DEFAULT_CONFIG_KEY = 'duration';
/** Per-slide override attribute name for timeout */
ESLCarouselAutoplayMixin.SLIDE_DURATION_ATTRIBUTE = ESLCarouselAutoplayMixin_1.is + '-timeout';
__decorate([
memoize()
], ESLCarouselAutoplayMixin.prototype, "$interactionScope", null);
__decorate([
listen({ inherit: true })
], ESLCarouselAutoplayMixin.prototype, "onConfigChange", null);
__decorate([
listen({
event: ESLIntersectionEvent.TYPE,
target: ($this) => ESLIntersectionTarget.for($this.$host, { threshold: [$this.config.intersection] })
})
], ESLCarouselAutoplayMixin.prototype, "_onIntersection", null);
__decorate([
listen({
event: 'mouseleave mouseenter focusin focusout',
target: ($this) => $this.$interactionScope,
condition: ($this) => $this.config.trackInteraction
})
], ESLCarouselAutoplayMixin.prototype, "_onInteract", null);
__decorate([
listen(ESLCarouselSlideEvent.AFTER)
], ESLCarouselAutoplayMixin.prototype, "_onSlideChange", null);
__decorate([
listen({
event: ($this) => $this.config.watchEvents,
target: document,
condition: ($this) => !!$this.config.watchEvents
})
], ESLCarouselAutoplayMixin.prototype, "_onBlockingEvent", null);
ESLCarouselAutoplayMixin = ESLCarouselAutoplayMixin_1 = __decorate([
ExportNs('Carousel.Autoplay')
], ESLCarouselAutoplayMixin);
export { ESLCarouselAutoplayMixin };