diff --git a/.commitlintrc.yml b/.commitlintrc.yml index 13705946e..72c6e9510 100644 --- a/.commitlintrc.yml +++ b/.commitlintrc.yml @@ -31,6 +31,7 @@ rules: - - esl-a11y-group - esl-alert + - esl-anchornav - esl-animate - esl-base-element - esl-carousel diff --git a/e2e/tests/__image_snapshots__/homepage-feature-feature-homepage-looks-fine-test-homepage-footer-on-desktop-1-snap.png b/e2e/tests/__image_snapshots__/homepage-feature-feature-homepage-looks-fine-test-homepage-footer-on-desktop-1-snap.png index 7785476a8..a969d4fc8 100644 Binary files a/e2e/tests/__image_snapshots__/homepage-feature-feature-homepage-looks-fine-test-homepage-footer-on-desktop-1-snap.png and b/e2e/tests/__image_snapshots__/homepage-feature-feature-homepage-looks-fine-test-homepage-footer-on-desktop-1-snap.png differ diff --git a/site/src/esl-anchornav/esl-anchornav.less b/site/src/esl-anchornav/esl-anchornav.less new file mode 100644 index 000000000..7a12a0dc7 --- /dev/null +++ b/site/src/esl-anchornav/esl-anchornav.less @@ -0,0 +1,57 @@ +@BG_COLOR: #f7f7f7; +@SHADOW: 1px 2px 3px rgba(0, 0, 0, 0.2); + +esl-anchornav { + [esl-anchornav-items] { + display: flex; + gap: 10px; + flex-wrap: wrap; + } + + .esl-anchornav-item.active { + font-weight: bold; + text-decoration: underline; + } +} + +[esl-anchor].highlighted { + border-top: 1px dotted #f00; +} + +.esl-anchornav-pseudo-fixed { + position: sticky; + top: 0; + right: 0; + padding: 10px 0 10px 10px; + + esl-anchornav { + min-width: 160px; + background: @BG_COLOR; + box-shadow: @SHADOW; + } + + [esl-anchornav-items] { + display: flex; + justify-content: center; + } +} + +[esl-anchornav-sticked] { + background-color: @BG_COLOR; + padding-block: 5px; + + &[sticked] { + box-shadow: @SHADOW; + } + + esl-anchornav { + display: flex; + gap: 10px; + } + + .uip-preview-inner & { + top: -10px; + margin-inline: -10px; + padding-inline: 10px; + } +} diff --git a/site/src/esl-anchornav/esl-anchornav.ts b/site/src/esl-anchornav/esl-anchornav.ts new file mode 100644 index 000000000..ecf066a12 --- /dev/null +++ b/site/src/esl-anchornav/esl-anchornav.ts @@ -0,0 +1,17 @@ +import {ESLAnchor, ESLAnchornav, ESLAnchornavSticked} from '@exadel/esl/modules/esl-anchornav/core'; + +import type {ESLAnchornavRender, ESLAnchorData} from '@exadel/esl/modules/esl-anchornav/core'; + +const demoRenderer: ESLAnchornavRender = (data: ESLAnchorData): Element => { + const a = document.createElement('a'); + a.href = `#${data.id}`; + a.className = 'esl-anchornav-item'; + a.dataset.index = `${data.index + 1}`; + a.textContent = data.title; + return a; +}; + +ESLAnchor.register(); +ESLAnchornav.setRenderer(demoRenderer); +ESLAnchornav.register(); +ESLAnchornavSticked.register(); diff --git a/site/src/localdev.less b/site/src/localdev.less index d4462e58b..125f86088 100644 --- a/site/src/localdev.less +++ b/site/src/localdev.less @@ -42,6 +42,7 @@ @import './esl-share/esl-share.less'; @import './esl-events-demo/esl-events-demo.less'; @import './esl-popup/esl-d-popup-game.less'; +@import './esl-anchornav/esl-anchornav.less'; @import './back-link/back-link'; diff --git a/site/src/localdev.ts b/site/src/localdev.ts index 42a2ea84c..a196de508 100644 --- a/site/src/localdev.ts +++ b/site/src/localdev.ts @@ -135,6 +135,9 @@ ESLOpenState.register(); // Share component loading import (/* webpackChunkName: 'common/esl-share' */'./esl-share/esl-share'); +// Anchornav component loading +import (/* webpackChunkName: 'common/esl-anchornav' */'./esl-anchornav/esl-anchornav'); + if (document.querySelector('uip-root')) { // Init UI Playground import (/* webpackChunkName: "common/playground" */'./playground/ui-playground'); diff --git a/site/static/assets/examples/anchornav.svg b/site/static/assets/examples/anchornav.svg new file mode 100644 index 000000000..0b0b3f03d --- /dev/null +++ b/site/static/assets/examples/anchornav.svg @@ -0,0 +1 @@ + diff --git a/site/views/components/esl-anchornav.njk b/site/views/components/esl-anchornav.njk new file mode 100644 index 000000000..2b5008012 --- /dev/null +++ b/site/views/components/esl-anchornav.njk @@ -0,0 +1,13 @@ +--- +layout: content +title: ESL Anchornav +seoTitle: ESL Anchornav - custom element that collects content anchors from the page and provides anchor navigation +name: ESL Anchornav +tags: [components, beta] +aside: + source: src/modules/esl-anchornav + examples: + - anchornav +--- + +{% mdRender 'src/modules/esl-anchornav/README.md', 'intro' %} diff --git a/site/views/examples/anchornav.njk b/site/views/examples/anchornav.njk new file mode 100644 index 000000000..47cae7f8c --- /dev/null +++ b/site/views/examples/anchornav.njk @@ -0,0 +1,106 @@ +--- +layout: content +title: Anchornav +seoTitle: Anchornav component for prompt navigation to different sections of a page +name: Anchornav +tags: [examples, beta, playground] +icon: examples/anchornav.svg +aside: + components: + - esl-anchornav +--- +{% import 'lorem.njk' as lorem %} + +{% set imageSrcBase = '/assets/' | url %} + +
+
+ + + + + + + + + + + + + + + +
+
diff --git a/src/modules/all.less b/src/modules/all.less index 226a41ad5..bc75ab234 100644 --- a/src/modules/all.less +++ b/src/modules/all.less @@ -28,3 +28,5 @@ @import './esl-share/core.less'; @import './esl-carousel/all.less'; + +@import './esl-anchornav/core.less'; diff --git a/src/modules/all.ts b/src/modules/all.ts index a08219257..4c764ffa5 100644 --- a/src/modules/all.ts +++ b/src/modules/all.ts @@ -53,3 +53,6 @@ export * from './esl-share/core'; // Carousel export * from './esl-carousel/core'; + +// Anchornav +export * from './esl-anchornav/core'; diff --git a/src/modules/esl-anchornav/README.md b/src/modules/esl-anchornav/README.md new file mode 100644 index 000000000..3936a6474 --- /dev/null +++ b/src/modules/esl-anchornav/README.md @@ -0,0 +1,11 @@ +# [ESL](../../../) Anchornav + +Version: *1.0.0-beta*. + +Authors: *Dmytro Shovchko*. + +***Important Notice: the component is under beta version, it is tested and ready to use but be aware of its potential critical API changes.*** + + + +The ESL Anchornav component allows users to quickly jump to specific page content via predefined anchors. The list of anchors is collected from the page dynamically, so any page updates will be processed and the component updates the navigation list. diff --git a/src/modules/esl-anchornav/core.less b/src/modules/esl-anchornav/core.less new file mode 100644 index 000000000..39f804f38 --- /dev/null +++ b/src/modules/esl-anchornav/core.less @@ -0,0 +1,3 @@ +@import './core/esl-anchor.less'; +@import './core/esl-anchornav.less'; +@import './core/esl-anchornav-sticked.less'; diff --git a/src/modules/esl-anchornav/core.ts b/src/modules/esl-anchornav/core.ts new file mode 100644 index 000000000..a38d76e44 --- /dev/null +++ b/src/modules/esl-anchornav/core.ts @@ -0,0 +1,5 @@ +export type * from './core/esl-anchornav-types'; + +export * from './core/esl-anchornav'; +export * from './core/esl-anchornav-sticked'; +export * from './core/esl-anchor'; diff --git a/src/modules/esl-anchornav/core/esl-anchor.less b/src/modules/esl-anchornav/core/esl-anchor.less new file mode 100644 index 000000000..4680d0b58 --- /dev/null +++ b/src/modules/esl-anchornav/core/esl-anchor.less @@ -0,0 +1,4 @@ +[esl-anchor] { + margin-block-end: -2px; + height: 2px; +} diff --git a/src/modules/esl-anchornav/core/esl-anchor.ts b/src/modules/esl-anchornav/core/esl-anchor.ts new file mode 100644 index 000000000..8804e782c --- /dev/null +++ b/src/modules/esl-anchornav/core/esl-anchor.ts @@ -0,0 +1,38 @@ +import {ESLMixinElement} from '../../esl-mixin-element/core'; +import {ExportNs} from '../../esl-utils/environment/export-ns'; +import {prop} from '../../esl-utils/decorators'; +import {ESLEventUtils} from '../../esl-event-listener/core'; + +/** + * ESLAnchor - custom mixin element for setting up anchor for {@link ESLAnchornav} attaching + * + * Use example: + * `
` + */ +@ExportNs('Anchor') +export class ESLAnchor extends ESLMixinElement { + static override is = 'esl-anchor'; + + @prop('esl:anchor:change') public CHANGE_EVENT: string; + + protected override connectedCallback(): void { + super.connectedCallback(); + this.sendRequestEvent(); + } + + protected override disconnectedCallback(): void { + this.sendRequestEvent(); + super.disconnectedCallback(); + } + + /** Sends a broadcast event to Anchornav components to refresh the list of anchors */ + protected sendRequestEvent(): void { + ESLEventUtils.dispatch(document.body, this.CHANGE_EVENT); + } +} + +declare global { + export interface ESLLibrary { + Anchor: typeof ESLAnchor; + } +} diff --git a/src/modules/esl-anchornav/core/esl-anchornav-sticked.less b/src/modules/esl-anchornav/core/esl-anchornav-sticked.less new file mode 100644 index 000000000..54c28c220 --- /dev/null +++ b/src/modules/esl-anchornav/core/esl-anchornav-sticked.less @@ -0,0 +1,4 @@ +[esl-anchornav-sticked] { + position: sticky; + top: 0; +} diff --git a/src/modules/esl-anchornav/core/esl-anchornav-sticked.ts b/src/modules/esl-anchornav/core/esl-anchornav-sticked.ts new file mode 100644 index 000000000..37cbef9e9 --- /dev/null +++ b/src/modules/esl-anchornav/core/esl-anchornav-sticked.ts @@ -0,0 +1,63 @@ +import {ESLMixinElement} from '../../esl-mixin-element/core'; +import {listen} from '../../esl-utils/decorators'; +import {ESLIntersectionTarget, ESLResizeObserverTarget} from '../../esl-event-listener/core'; +import {getViewportForEl} from '../../esl-utils/dom/scroll'; +import {ESLAnchornav} from './esl-anchornav'; + +import type {ESLIntersectionEvent, ESLElementResizeEvent} from '../../esl-event-listener/core'; + +/** + * ESLAnchornavSticked - custom mixin element for sticky positioned of {@link ESLAnchornav} element + * + * Use example: + * `
` + */ +export class ESLAnchornavSticked extends ESLMixinElement { + static override is = 'esl-anchornav-sticked'; + + protected _sticked: boolean = false; + + /** The height of this anchornav container */ + public get anchornavHeight(): number { + return this.$host.getBoundingClientRect().height; + } + + /** Sticked state */ + public get sticked(): boolean { + return this._sticked; + } + public set sticked(value: boolean) { + if (this._sticked === value) return; + this._sticked = value; + this.$$attr('sticked', value); + this._onStateChange(); + } + + /** Childs anchornav element */ + protected get $anchornav(): ESLAnchornav | null { + return this.$host.querySelector(ESLAnchornav.is); + } + + /** Handles changing sticky state */ + protected _onStateChange(): void { + if (!this.$anchornav) return; + this.$anchornav.offset = this.sticked ? this.anchornavHeight : 0; + } + + @listen({ + event: 'intersects', + target: (that: ESLAnchornavSticked) => ESLIntersectionTarget.for(that.$host, { + root: getViewportForEl(that.$host), + rootMargin: '-1px 0px 0px 0px', + threshold: [0.99, 1] + }) + }) + protected _onIntersection(e: ESLIntersectionEvent): void { + this.sticked = e.intersectionRect.y > e.boundingClientRect.y; + } + + @listen({event: 'resize', target: (that: ESLAnchornavSticked) => ESLResizeObserverTarget.for(that.$host)}) + protected _onResize({borderBoxSize}: ESLElementResizeEvent): void { + this._onStateChange(); + } +} diff --git a/src/modules/esl-anchornav/core/esl-anchornav-types.ts b/src/modules/esl-anchornav/core/esl-anchornav-types.ts new file mode 100644 index 000000000..829466a7a --- /dev/null +++ b/src/modules/esl-anchornav/core/esl-anchornav-types.ts @@ -0,0 +1,10 @@ +/** {@link ESLAnchornav} item renderer */ +export type ESLAnchornavRender = (data: ESLAnchorData) => string | Element; + +/** {@link ESLAnchornav} anchor data interface */ +export interface ESLAnchorData { + id: string; + title: string; + index: number; // order number in the anchor list + $anchor: HTMLElement; +} diff --git a/src/modules/esl-anchornav/core/esl-anchornav.less b/src/modules/esl-anchornav/core/esl-anchornav.less new file mode 100644 index 000000000..8e1e60565 --- /dev/null +++ b/src/modules/esl-anchornav/core/esl-anchornav.less @@ -0,0 +1,3 @@ +esl-anchornav { + display: block; +} diff --git a/src/modules/esl-anchornav/core/esl-anchornav.ts b/src/modules/esl-anchornav/core/esl-anchornav.ts new file mode 100644 index 000000000..d268f411d --- /dev/null +++ b/src/modules/esl-anchornav/core/esl-anchornav.ts @@ -0,0 +1,213 @@ +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 {ESLEventUtils, ESLIntersectionTarget} from '../../esl-event-listener/core'; +import {ESLAnchor} from './esl-anchor'; + +import type {DelegatedEvent, ESLIntersectionEvent} from '../../esl-event-listener/core'; +import type {ESLAnchorData, ESLAnchornavRender} from './esl-anchornav-types'; + +/** + * ESLAnchornav + * @author Dmytro Shovchko + * + * ESLAnchornav is a component that collects content anchors from the page and provides anchor navigation + */ +@ExportNs('Anchornav') +export class ESLAnchornav extends ESLBaseElement { + public static override is = 'esl-anchornav'; + public static _renderers: Map = new Map(); + + /** Gets renderer by name */ + public static getRenderer(name: string): ESLAnchornavRender | undefined { + return this._renderers.get(name); + } + + /** Sets renderer */ + public static setRenderer(renderer: ESLAnchornavRender): void; + public static setRenderer(name: string, renderer: ESLAnchornavRender): void; + public static setRenderer(name: string | ESLAnchornavRender, renderer?: ESLAnchornavRender): void { + if (typeof name !== 'string') return this.setRenderer('default', name); + if (typeof name === 'string' && renderer) this._renderers.set(name, renderer); + } + + @prop('esl:anchornav:activechanged') public ACTIVECHANGED_EVENT: string; + @prop('esl:anchornav:updated') public UPDATED_EVENT: string; + @prop('[esl-anchor]') protected ANCHOR_SELECTOR: string; + + /** Item renderer which is used to build inner markup */ + @attr({defaultValue: 'default', name: 'renderer'}) public rendererName: string; + + protected _active: ESLAnchorData; + protected _anchors: ESLAnchorData[] = []; + protected _items: Map = new Map(); + protected _offset: number; + + /** Active anchor */ + public get active(): ESLAnchorData { + return this._active; + } + public set active(value: ESLAnchorData) { + if (this._active === value) return; + this._active = value; + this._onActiveChange(value); + } + + /** Anchors list */ + protected get $anchors(): HTMLElement[] { + return this._anchors.map(({$anchor}) => $anchor); + } + + /** Anchornav offset */ + public get offset(): number { + return this._offset || 0; + } + public set offset(value: number) { + if (this._offset === value) return; + this._offset = value; + memoize.clear(this, '$viewport'); + this.$$on(this._onAnchorIntersection); + } + + /** Anchornav items container */ + @memoize() + protected get $itemsArea(): HTMLElement { + 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) */ + @memoize() + protected get $viewport(): Element | undefined { + return getViewportForEl(this); + } + + @ready + protected override connectedCallback(): void { + super.connectedCallback(); + this._onAnchornavRequest(); + } + + /** Updates the component */ + public update(): void { + memoize.clear(this, '$viewport'); + this.rerender(); + this.$$on(this._onAnchorIntersection); + this.updateActiveAnchor(); + this._onUpdateEvent(); + } + + /** Builds the component anchors list markup */ + protected rerender(): void { + const {$itemsArea} = this; + const anchors = this.renderAnchors(); + $itemsArea.replaceChildren(...anchors); + } + + // TODO: move to esl-utils helpers + /** Converts html string to Element */ + protected htmlToElement(html: string): Element { + return (new DOMParser()).parseFromString(html, 'text/html').body.children[0]; + } + + /** Renders the component anchors list */ + protected renderAnchors(): Element[] { + const itemRenderer = ESLAnchornav.getRenderer(this.rendererName); + this._items.clear(); + return itemRenderer ? this._anchors.map((anchor) => { + let item = itemRenderer(anchor); + if (typeof item === 'string') item = this.htmlToElement(item); + this._items.set(anchor.id, item); + return item; + }) : []; + } + + /** Gets anchor data from the anchor element */ + protected getDataFrom($anchor: HTMLElement, index: number): ESLAnchorData { + return { + id: $anchor.id, + title: $anchor.title, + index, + $anchor + }; + } + + /** Gets initial active anchor */ + protected getInitialActive(): ESLAnchorData { + return this._anchors[0]; + } + + /** Updates the active anchor */ + @decorate(debounce, 50) + protected updateActiveAnchor(): void { + let active: ESLAnchorData = this.getInitialActive(); + const topBoundary = (this.$viewport ? this.$viewport.getBoundingClientRect().y : 0) + this.offset + 1; + this._anchors.forEach((item) => { + const {y} = item.$anchor.getBoundingClientRect(); + if (y <= topBoundary) active = item; + }); + if (active) { + this._items.forEach(($item, id) => $item.classList.toggle('active', id === active.id)); + this.active = active; + } + } + + /** Handles changing the active anchor */ + @decorate(microtask) + protected _onActiveChange(active: ESLAnchorData): void { + const detail = {id: active.id}; + ESLEventUtils.dispatch(this, this.ACTIVECHANGED_EVENT, {detail}); + } + + /** Handles updating the component */ + @decorate(microtask) + protected _onUpdateEvent(): void { + ESLEventUtils.dispatch(this, this.UPDATED_EVENT); + } + + @listen({ + event: ESLAnchor.prototype.CHANGE_EVENT, + target: document.body + }) + protected _onAnchornavRequest(): void { + this._anchors = [...document.querySelectorAll(this.ANCHOR_SELECTOR)].map(this.getDataFrom); + this.update(); + } + + @listen({ + event: 'intersects', + target: (that: ESLAnchornav) => ESLIntersectionTarget.for(that.$anchors, { + root: that.$viewport, + threshold: [0, 0.01, 0.99, 1], + rootMargin: `-${that.offset + 1}px 0px 0px 0px` + }) + }) + protected _onAnchorIntersection(e: ESLIntersectionEvent): void { + this.updateActiveAnchor(); + } + + @listen({ + event: 'click', + selector: 'a' + }) + protected _onAnchorClick(event: DelegatedEvent): void { + this.updateActiveAnchor(); + } +} + +ESLAnchornav.setRenderer((data: ESLAnchorData) => `${data.title}`); + +declare global { + export interface ESLLibrary { + Anchornav: typeof ESLAnchornav; + } + export interface HTMLElementTagNameMap { + 'esl-anchornav': ESLAnchornav; + } +} diff --git a/src/modules/esl-utils/dom/scroll/parent.ts b/src/modules/esl-utils/dom/scroll/parent.ts index 0d97a9829..7882b0891 100644 --- a/src/modules/esl-utils/dom/scroll/parent.ts +++ b/src/modules/esl-utils/dom/scroll/parent.ts @@ -44,3 +44,11 @@ export function isScrollable(element: Element): boolean { const {overflow, overflowX, overflowY} = getComputedStyle(element); return /auto|scroll|overlay|hidden/.test(overflow + overflowY + overflowX); } + +/** + * Get the element that is the viewport for the specified element. + * @param node - element for which to get the viewport + */ +export function getViewportForEl(node: Element): Element | undefined { + return getListScrollParents(node).find((el) => el.scrollHeight !== el.clientHeight); +}