import { attr, godown, htmlSlot, styles } from "@godown/element"; import iconCaretLeft from "../../internal/icons/caret-left.js"; import iconCaretRight from "../../internal/icons/caret-right.js"; import { type TemplateResult, css, html } from "lit"; import { property, query } from "lit/decorators.js"; import { GlobalStyle } from "../../internal/global-style.js"; const protoName = "carousel"; function getWidth(e) { return e.getBoundingClientRect().width; } /** * {@linkcode Carousel} make the content display as a carousel. * * When this component is `firstUpdated`, * clone the first and last element and make the matching element visible when switching index. * * @slot - Carousel items, should maintain the same size. * @fires change - Fires when the index changes. * @category display */ @godown(protoName) @styles(css` :host { display: block; transition: 0.3s; } [part="root"] { direction: ltr; overflow: hidden; } [part="root"], [part="move-root"] { height: 100%; width: 100%; display: flex; position: relative; transition: inherit; } [part="prev"], [part="next"] { height: 100%; width: 1.5em; z-index: 1; position: absolute; display: flex; align-items: center; justify-content: center; user-select: none; } [part="prev"] { left: 0; } [part="next"] { right: 0; } slot::slotted(*) { flex-shrink: 0 !important; } `) class Carousel extends GlobalStyle { /** * The index of the current item. */ @property({ type: Number }) index = 0; /** * The duration of the transition. */ @property({ type: Number }) autoChange = 0; @query("[part=move-root]") protected _moveRoot: HTMLElement; intervalID: number; private __cloneFirst: HTMLElement | undefined; private __cloneLast: HTMLElement | undefined; protected _offset: number; protected render(): TemplateResult<1> { return html`
${iconCaretLeft()}
${htmlSlot()}
${iconCaretRight()}
`; } connectedCallback(): void { super.connectedCallback(); if (this.children.length) { this.__cloneFirst?.remove(); this.__cloneLast?.remove(); this.__cloneLast = this.firstElementChild.cloneNode(true) as HTMLElement; this.__cloneFirst = this.lastElementChild.cloneNode(true) as HTMLElement; this.appendChild(this.__cloneLast); this.insertBefore(this.__cloneFirst, this.firstElementChild); } this.observers.add(this, ResizeObserver, () => { this._offset = this._computeOffset(); this._doTranslateX(`${this._offset}px`, true); }); } protected async firstUpdated(): Promise { await this.updateComplete; this.show(this.index, true); } attributeChangedCallback(name: string, _old: string | null, value: string | null): void { super.attributeChangedCallback(name, _old, value); if (name === "index" && this.isConnected) { this.show(this.index); } } show(i: number, n?: boolean): void { i = this.normalizeIndex(i); this.index = i; this._offset = this._computeOffset(); this._doTranslateX(`${this._offset}px`, n); this.dispatchCustomEvent("change", i); this.timeouts.remove(this.intervalID); if (this.autoChange > 0) { this.intervalID = this.timeouts.add( setInterval(() => { this.next(); }, this.autoChange), ); } } next(): void { if (this.index === this.childElementCount - 3) { this._doTranslateX("0", true); this.show(0); } else { this.show(this.index + 1); } } prev(): void { if (this.index === 0) { this._doTranslateX(`-${this.childElementCount - 1}00%`, true); this.show(this.children.length - 3); } else { this.show(this.index - 1); } } protected _doTranslateX(xValue: string, noTransition?: boolean): void { this._moveRoot.style.transform = `translateX(${xValue})`; this._moveRoot.style.transition = noTransition ? "none" : ""; } protected _computeOffset(): number { let offset = 0; for (let childIndex = 0; childIndex <= this.index; childIndex++) { offset -= getWidth(this.children[childIndex]); } offset += (getWidth(this) - getWidth(this.children[this.index + 1])) / 2; return offset; } normalizeIndex(i: number): number { if (i < 0) { return 0; } if (i > this.children.length - 3) { return this.children.length - 3; } return i; } } export default Carousel; export { Carousel };