UNPKG

@v4fire/client

Version:

V4Fire client core library

458 lines (370 loc) • 9.95 kB
/*! * V4Fire Client Core * https://github.com/V4Fire/Client * * Released under the MIT license * https://github.com/V4Fire/Client/blob/master/LICENSE */ import symbolGenerator from 'core/symbol'; import type { ModsDecl, ComponentHooks } from 'core/component'; import { InView } from 'core/dom/in-view'; import iBlock, { Friend } from 'super/i-block/i-block'; import type iHistory from 'traits/i-history/i-history'; import { INITIAL_STAGE } from 'traits/i-history/history/const'; import type { Page, HistoryItem, HistoryConfig, Transition } from 'traits/i-history/history/interface'; export * from 'traits/i-history/history/const'; export * from 'traits/i-history/history/interface'; export const $$ = symbolGenerator(); export default class History extends Friend { override readonly C!: iHistory; /** * Default configuration for a history item */ static defaultConfig: HistoryConfig = { titleThreshold: 0.01, triggerAttr: 'data-history-trigger', pageTriggers: true }; /** * Trait modifiers */ static readonly mods: ModsDecl = { blankHistory: [ 'false', ['true'] ] }; /** * Current store position */ get current(): CanUndef<HistoryItem> { return this.store[this.store.length - 1]; } /** * History length */ get length(): number { return this.store.length; } /** * List of transitions */ protected store: HistoryItem[] = []; /** * History instance configuration */ protected config: HistoryConfig; /** * @param component * @param [config] */ constructor(component: iBlock, config?: HistoryConfig) { super(component); this.config = {...History.defaultConfig, ...config}; } /** * Hooks of the component instance */ protected get componentHooks(): ComponentHooks { return this.meta.hooks; } /** * Initializes the index page * @param [item] - initial history item */ initIndex(item: HistoryItem = {stage: INITIAL_STAGE, options: {}}): void { if (this.store.length > 0) { this.store[0].content?.el.removeAttribute('data-page'); this.store[0] = item; } else { this.store.push(item); } this.calculateCurrentPage(); } /** * Pushes a new stage to the history * * @param stage * @param [opts] - additional options * @emits `history:transition(value: Transition)` */ push(stage: string, opts?: Dictionary): void { const {block} = this; if (block == null) { return; } const currentPage = this.current?.content?.el, els = this.initPage(stage); if (els?.content.el) { const isBelow = block.getElMod(els.content.el, 'page', 'below') === 'true'; if (isBelow || currentPage === els.content.el) { throw new Error(`A page for the stage "${stage}" is already opened`); } this.async.requestAnimationFrame(() => { block.setElMod(els.content.el, 'page', 'turning', 'in'); block.setElMod(currentPage, 'page', 'below', true); void this.ctx.setMod('blankHistory', false); }, {label: $$.addNewPage}); this.store.push({stage, options: opts, ...els}); this.scrollToTop(); this.ctx.emit('history:transition', {page: this.current, type: 'push'}); } else { throw new ReferenceError(`A page for the stage "${stage}" is not defined`); } } /** * Navigates back through the history * @emits `history:transition(value: Transition)` */ back(): CanUndef<HistoryItem> { if (this.store.length === 1) { return; } const current = this.store.pop(); if (current) { if (this.store.length === 1) { void this.ctx.setMod('blankHistory', true); } this.unwindPage(current); const pageBelow = this.store[this.store.length - 1], pageBelowEl = pageBelow.content?.el; this.block?.removeElMod(pageBelowEl, 'page', 'below'); this.ctx.emit('history:transition', <Transition>{page: current, type: 'back'}); } return current; } /** * Clears the history * @emits `history:clear` */ clear(): boolean { if (this.store.length === 0) { return false; } for (let i = this.store.length - 1; i >= 0; i--) { this.unwindPage(this.store[i]); } this.store = []; this.async.requestAnimationFrame(() => { this.block?.removeElMod(this.store[0]?.content?.el, 'page', 'below'); }, {label: $$.pageChange}); const history = this.block?.element<HTMLElement>('history'); if (history?.hasAttribute('data-page')) { history.removeAttribute('data-page'); } this.async.requestAnimationFrame(() => { void this.ctx.setMod('blankHistory', true); this.ctx.emit('history:clear'); }, {label: $$.historyClear}); return true; } /** * Calculates the current page */ protected calculateCurrentPage(): void { this.async.requestAnimationFrame(() => { const {current} = this; if (current == null) { return; } const els = this.initPage(current.stage); Object.assign(this.current, els); const titleH = current.title?.initBoundingRect?.height ?? 0, scrollTop = current.content?.el.scrollTop ?? 0, visible = titleH - scrollTop >= titleH * this.config.titleThreshold; this.initTitleInView(visible); }, {label: $$.calculateCurrentPage}); } /** * Unwinds the passed history item to the initial state * @param item */ protected unwindPage(item: HistoryItem): void { const page = item.content?.el, trigger = item.content?.trigger; const group = { group: item.stage.camelize(), label: $$.unwindPage }; this.async.requestAnimationFrame(() => { const {block} = this; if (trigger) { this.setObserving(trigger, false); } if (page != null && block != null) { block.removeElMod(page, 'page', 'turning'); block.removeElMod(page, 'page', 'below'); } }, group); } /** * Creates a trigger element to observe */ protected createTrigger(): CanUndef<HTMLElement> { if (!this.config.pageTriggers) { return; } const t = document.createElement('div'); t.setAttribute(this.config.triggerAttr, 'true'); this.async.requestAnimationFrame(() => { Object.assign(t.style, { height: (1).px, width: '100%', position: 'absolute', top: 0, zIndex: -1 }); }, {label: $$.createTrigger}); return t; } /** * Sets observing for the specified element * * @param el * @param observe - if false, the observing of the element will be stopped */ protected setObserving(el: HTMLElement, observe: boolean): void { if (!this.config.pageTriggers) { return; } const label = {label: $$.setObserving}; if (observe) { InView.observe(el, { threshold: this.config.titleThreshold, onEnter: () => this.onPageTopVisibilityChange(true), onLeave: () => this.onPageTopVisibilityChange(false), polling: true }); this.async.worker(() => InView.remove(el), label); } else { this.async.terminateWorker(label); } } /** * Initializes a layout for the specified stage and returns a page object * * @param stage * @emits `history:initPage({content: Content, title: Title})` * @emits `history:initPageFail(stage: string)` */ protected initPage(stage: string): CanUndef<Page> { const {async: $a, block} = this; if (block == null) { return; } let page = block.node?.querySelector<HTMLElement>(`[data-page=${stage}]`); if (page == null) { this.ctx.emit('history:initPageFail', stage); if (stage !== INITIAL_STAGE) { return; } page = block.element<HTMLElement>('history'); if (page == null) { return; } page.setAttribute('data-page', stage); } this.async.requestAnimationFrame(() => { if (page == null) { return; } const nm = block.getFullElName('page'); if (!page.classList.contains(nm)) { page.classList.add(nm); } }, {label: $$.initPage}); const title = page.querySelector('[data-title]'), fstChild = Object.get<HTMLElement>(page, 'children.0'); const hasTrigger = Boolean(fstChild?.getAttribute(this.config.triggerAttr)), trigger = hasTrigger ? fstChild! : this.createTrigger(); if (title != null) { if (trigger != null) { this.async.requestAnimationFrame(() => { trigger.style.height = title.clientHeight.px; }, {label: $$.setTriggerHeight}); } $a.on(title, 'click', this.onTitleClick.bind(this)); } if (trigger != null) { this.async.requestAnimationFrame(() => { if (!hasTrigger) { page?.insertAdjacentElement('afterbegin', trigger); } this.setObserving(trigger, true); }, {label: $$.initTrigger}); } const response = { content: { el: page, initBoundingRect: page.getBoundingClientRect(), trigger }, title: { el: title, initBoundingRect: title?.getBoundingClientRect() } }; this.ctx.emit('history:initPage', response); return response; } /** * Scrolls a content to the top * @param [animate] */ protected scrollToTop(animate: boolean = false): void { const content = this.current?.content; if (content != null && (content.el.scrollTop !== 0 || content.el.scrollLeft !== 0)) { const options = {top: 0, left: 0}; if (animate) { Object.assign(options, {behavior: 'smooth'}); } content.el.scrollTo(options); } } /** * Initializes a title in-view state * * @param [visible] * @emits `history:titleInView(visible: boolean)` */ protected initTitleInView(visible?: boolean): void { const {current} = this; this.block?.setElMod(current?.title?.el, 'title', 'in-view', visible); this.ctx.emit('history:titleInView', visible); } /** * Handler: was changed the visibility state of the top of a content * @param state - if true, the top is visible */ protected onPageTopVisibilityChange(state: boolean): void { if (this.current?.title?.el) { this.initTitleInView(state); } this.ctx.onPageTopVisibilityChange(state); } /** * Handler: click on a page title */ protected onTitleClick(): void { this.scrollToTop(true); } }