UNPKG

@exadel/esl

Version:

Exadel Smart Library (ESL) is the lightweight custom elements library that provide a set of super-flexible components

344 lines (343 loc) 12.6 kB
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 ESLAnchornav_1; import { ExportNs } from '../../esl-utils/environment/export-ns'; import { ESLBaseElement } from '../../esl-base-element/core'; import { attr, decorate, listen, memoize, prop, ready } from '../../esl-utils/decorators'; import { debounce, microtask } from '../../esl-utils/async'; import { getViewportForEl } from '../../esl-utils/dom/scroll'; import { htmlToElement } from '../../esl-utils/dom/api'; import { CSSClassUtils } from '../../esl-utils/dom/class'; import { ESLEventUtils, ESLIntersectionTarget } from '../../esl-event-listener/core'; import { ESLAnchor } from './esl-anchor'; import { buildHierarchyByLevel } from './esl-anchornav.hierarchy'; /** * ESLAnchornav * @author Dmytro Shovchko * * ESLAnchornav is a component that collects content anchors from the page and provides anchor navigation */ let ESLAnchornav = ESLAnchornav_1 = class ESLAnchornav extends ESLBaseElement { constructor() { super(...arguments); this._anchors = []; this._flatAnchors = []; this._items = new Map(); } /** Gets renderer by name */ static getRenderer(name) { return this._renderers.get(name); } static setRenderer(name, renderer) { if (typeof name !== 'string') return this.setRenderer('default', name); if (renderer) this._renderers.set(name, renderer); } /** Gets hierarchy builder by name */ static getHierarchyBuilder(name) { return this._hierarchyBuilders.get(name); } static setHierarchyBuilder(name, builder) { if (typeof name !== 'string') return this.setHierarchyBuilder('level', name); if (builder) this._hierarchyBuilders.set(name, builder); } /** Active anchor */ get active() { return this._active; } set active(value) { if (this._active === value) return; this._active = value; this._onActiveChange(); } /** Indicates whether the component has no anchors to display */ get empty() { return this._anchors.length === 0; } /** Anchors list (flattened for intersection observation) */ get $anchors() { return this._flatAnchors.map(({ $anchor }) => $anchor); } /** Anchornav offset */ get offset() { return this._offset || 0; } set offset(value) { if (this._offset === value) return; this._offset = value; memoize.clear(this, '$viewport'); this.$$on(this._onAnchorIntersection); } /** Anchornav items container */ get $itemsArea() { const $provided = this.querySelector(`[${this.baseTagName}-items]`); if ($provided) return $provided; const $container = document.createElement('div'); $container.setAttribute(this.baseTagName + '-items', ''); this.appendChild($container); return $container; } /** Anchornav viewport (root element for IntersectionObservers checking visibility) */ get $viewport() { return getViewportForEl(this); } /** Permanent anchors to prepend to the list */ get anchorsToPrepend() { return []; } /** Permanent anchors to append to the list */ get anchorsToAppend() { return []; } /** * Finds anchor elements. * Uses {@link ESLTraversingQuery} syntax via {@link ESLBaseElement.$$findAll}. */ findAnchors() { return this.$$findAll(this.anchorSelector); } connectedCallback() { super.connectedCallback(); this.update(); } /** * Updates the component. * Performs a full refresh cycle: recollects anchors list and updates the UI state. * Use this method when the set/order of anchors may have changed. */ update() { const flatAnchors = this.findAnchors() .map(($el, i) => this.getDataFrom($el, i)) .filter((item) => !!(item && item.id && item.title)); flatAnchors.unshift(...this.anchorsToPrepend); flatAnchors.push(...this.anchorsToAppend); this._anchors = this.buildHierarchy(flatAnchors); this._flatAnchors = this.flattenAnchors(this._anchors); memoize.clear(this, '$viewport'); this.rerender(); this.$$on(this._onAnchorIntersection); this.updateContainer(); this.updateActiveAnchor(); this._onUpdateEvent(); } /** Builds the component anchors list markup */ rerender() { const { $itemsArea } = this; const anchors = this.renderAnchors(); $itemsArea.replaceChildren(...anchors); } /** Renders the component anchors list */ renderAnchors() { this._items.clear(); return this._anchors .map((anchor, index) => this.renderItem(anchor, index)) .filter(Boolean); } /** * Renders a single anchor item using the specified or default renderer. * Registers the rendered element in the internal items map. * Use this method when rendering nested items to ensure proper registration. * @param data - anchor data to render * @param index - anchor index (optional) * @param renderer - custom renderer function (optional, uses rendererName by default) * @returns rendered element */ renderItem(data, index, renderer) { const itemRenderer = renderer || ESLAnchornav_1.getRenderer(this.rendererName); if (!itemRenderer) { console.warn(`[ESLAnchornav] Renderer "${this.rendererName}" not found. Item will not be rendered.`); return; } const item = htmlToElement(itemRenderer(data, index !== null && index !== void 0 ? index : 0, this)); if (item) this._items.set(data.id, item); return item; } /** * Gets anchor data from the anchor element * If resolved data is missing required fields (e.g., id), the anchor will be ignored. */ getDataFrom($anchor, index) { var _a; return { id: $anchor.id, title: $anchor.title, data: ((_a = ESLAnchor.get($anchor)) === null || _a === void 0 ? void 0 : _a.data) || {}, $anchor }; } /** * Builds hierarchy from flat anchors list based on groupBy mode. * Override this method to implement custom hierarchy logic. * @param flatAnchors - flat list of anchors in DOM order * @returns hierarchical anchors list (roots only) or flat list if groupBy is empty */ buildHierarchy(flatAnchors) { if (!this.groupBy) return flatAnchors; const builder = ESLAnchornav_1.getHierarchyBuilder(this.groupBy); if (!builder) { console.warn(`[ESLAnchornav] Unknown groupBy mode: "${this.groupBy}". Using flat list.`); return flatAnchors; } return builder(flatAnchors); } /** * Flattens hierarchical anchors list to a flat array in depth-first order. * @param anchors - hierarchical anchors list * @returns flat list of all anchors */ flattenAnchors(anchors) { var _a; const result = []; for (const anchor of anchors) { result.push(anchor); if ((_a = anchor.children) === null || _a === void 0 ? void 0 : _a.length) { result.push(...this.flattenAnchors(anchor.children)); } } return result; } /** Gets initial active anchor */ getInitialActive() { return this._flatAnchors[0]; } /** Updates the active anchor */ updateActiveAnchor() { let active = this.getInitialActive(); const topBoundary = (this.$viewport ? this.$viewport.getBoundingClientRect().y : 0) + this.offset + 1; // Use flat anchors in DOM order for proper active detection this._flatAnchors.forEach((item) => { const { y } = item.$anchor.getBoundingClientRect(); if (y <= topBoundary) active = item; }); if (active) { this.updateActiveClasses(active); this.active = active; } } /** * Updates active classes on navigation items. * Resets all active classes and sets the active class on the current item. * Override this method to implement custom active state logic (e.g., parent activation). * @param active - the active anchor */ updateActiveClasses(active) { this._items.forEach(($item, id) => { CSSClassUtils.toggle($item, this.activeClass, id === active.id); }); } /** * Updates the container state based on whether anchors are present. * Sets the `empty` attribute on the component and applies `emptyClass` to the target element. */ updateContainer() { this.$$attr('empty', this.empty); CSSClassUtils.toggle(this.$$findAll(this.emptyClassTarget), this.emptyClass, this.empty); } /** Handles changing the active anchor */ _onActiveChange() { const detail = { id: this.active.id }; ESLEventUtils.dispatch(this, this.ACTIVECHANGED_EVENT, { detail }); } /** Handles updating the component */ _onUpdateEvent() { ESLEventUtils.dispatch(this, this.UPDATED_EVENT); } _onAnchornavRequest() { this.update(); } _onAnchorIntersection(e) { this.updateActiveAnchor(); } _onAnchorClick(event) { this.updateActiveAnchor(); } }; ESLAnchornav.is = 'esl-anchornav'; ESLAnchornav._renderers = new Map(); ESLAnchornav._hierarchyBuilders = new Map(); __decorate([ prop('esl:anchornav:activechanged') ], ESLAnchornav.prototype, "ACTIVECHANGED_EVENT", void 0); __decorate([ prop('esl:anchornav:updated') ], ESLAnchornav.prototype, "UPDATED_EVENT", void 0); __decorate([ prop([0, 0.01, 0.99, 1]) ], ESLAnchornav.prototype, "INTERSECTION_THRESHOLD", void 0); __decorate([ attr({ defaultValue: 'default', name: 'renderer' }) ], ESLAnchornav.prototype, "rendererName", void 0); __decorate([ attr({ defaultValue: 'active' }) ], ESLAnchornav.prototype, "activeClass", void 0); __decorate([ attr() ], ESLAnchornav.prototype, "emptyClass", void 0); __decorate([ attr({ defaultValue: '' }) ], ESLAnchornav.prototype, "emptyClassTarget", void 0); __decorate([ attr({ defaultValue: `[${ESLAnchor.is}]` }) ], ESLAnchornav.prototype, "anchorSelector", void 0); __decorate([ attr({ defaultValue: '' }) ], ESLAnchornav.prototype, "groupBy", void 0); __decorate([ memoize() ], ESLAnchornav.prototype, "$itemsArea", null); __decorate([ memoize() ], ESLAnchornav.prototype, "$viewport", null); __decorate([ ready ], ESLAnchornav.prototype, "connectedCallback", null); __decorate([ decorate(debounce, 50) ], ESLAnchornav.prototype, "updateActiveAnchor", null); __decorate([ decorate(microtask) ], ESLAnchornav.prototype, "_onActiveChange", null); __decorate([ decorate(microtask) ], ESLAnchornav.prototype, "_onUpdateEvent", null); __decorate([ listen({ event: ESLAnchor.prototype.CHANGE_EVENT, target: document.body }) ], ESLAnchornav.prototype, "_onAnchornavRequest", null); __decorate([ listen({ event: 'intersects', target: (that) => ESLIntersectionTarget.for(that.$anchors, { root: that.$viewport, threshold: that.INTERSECTION_THRESHOLD, rootMargin: `-${that.offset + 1}px 0px 0px 0px` }) }) ], ESLAnchornav.prototype, "_onAnchorIntersection", null); __decorate([ listen({ event: 'click', selector: 'a' }) ], ESLAnchornav.prototype, "_onAnchorClick", null); ESLAnchornav = ESLAnchornav_1 = __decorate([ ExportNs('Anchornav') ], ESLAnchornav); export { ESLAnchornav }; ESLAnchornav.setRenderer((data) => `<a class="esl-anchornav-item" href="#${data.id}">${data.title}</a>`); ESLAnchornav.setHierarchyBuilder('level', buildHierarchyByLevel);