@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
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 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);