import { css, html } from "lit"; import { classMap } from "lit/directives/class-map.js"; import { styleMap } from "lit/directives/style-map.js"; import { BaseElement } from "../../core/base-element.ts"; /** * CTVScroll - Vertical scroll container * * @element ct-vscroll * * @attr {boolean} show-scrollbar - Always show scrollbar * @attr {boolean} fade-edges - Show fade effect at edges * @attr {boolean} snap-to-bottom - Automatically scroll to bottom when new content is added * @attr {string} padding - Padding inside scroll area * @attr {string} height - Fixed height of the container * @attr {string} max-height - Maximum height of the container * * @slot - Content to be scrolled vertically * * @example * * *

Long content...

*
*
*/ export class CTVScroll extends BaseElement { static override properties = { showScrollbar: { type: Boolean, reflect: true, attribute: "show-scrollbar", }, fadeEdges: { type: Boolean, reflect: true, attribute: "fade-edges" }, snapToBottom: { type: Boolean, reflect: true, attribute: "snap-to-bottom" }, flex: { type: Boolean, reflect: true }, padding: { type: String }, height: { type: String }, maxHeight: { type: String, attribute: "max-height" }, _atStart: { type: Boolean, state: true }, _atEnd: { type: Boolean, state: true }, }; static override styles = css` :host { display: block; position: relative; overflow: hidden; } :host([flex]) { flex: 1; min-height: 0; } .scroll-wrapper { position: relative; width: 100%; height: 100%; } .scroll-container { overflow-y: auto; overflow-x: hidden; width: 100%; -webkit-overflow-scrolling: touch; scrollbar-width: thin; } /* Hide scrollbar by default */ :host(:not([show-scrollbar])) .scroll-container { scrollbar-width: none; -ms-overflow-style: none; } :host(:not([show-scrollbar])) .scroll-container::-webkit-scrollbar { display: none; } /* Scrollbar styling when visible */ .scroll-container::-webkit-scrollbar { width: 8px; } .scroll-container::-webkit-scrollbar-track { background: var(--muted, #f1f5f9); border-radius: 4px; } .scroll-container::-webkit-scrollbar-thumb { background: var(--muted-foreground, #64748b); border-radius: 4px; } .scroll-container::-webkit-scrollbar-thumb:hover { background: var(--foreground, #475569); } /* Padding utilities */ .p-0 { padding: 0; } .p-1 { padding: 0.25rem; } .p-2 { padding: 0.5rem; } .p-3 { padding: 0.75rem; } .p-4 { padding: 1rem; } .p-5 { padding: 1.25rem; } .p-6 { padding: 1.5rem; } .p-8 { padding: 2rem; } /* Fade edges */ :host([fade-edges]) .scroll-wrapper::before, :host([fade-edges]) .scroll-wrapper::after { content: ""; position: absolute; left: 0; right: 0; height: 2rem; pointer-events: none; z-index: 1; transition: opacity 0.2s; } :host([fade-edges]) .scroll-wrapper::before { top: 0; background: linear-gradient( to bottom, var(--background, white), transparent ); opacity: 0; } :host([fade-edges]) .scroll-wrapper::after { bottom: 0; background: linear-gradient(to top, var(--background, white), transparent); opacity: 0; } :host([fade-edges]) .scroll-wrapper:not(.at-start)::before { opacity: 1; } :host([fade-edges]) .scroll-wrapper:not(.at-end)::after { opacity: 1; } `; declare showScrollbar: boolean; declare fadeEdges: boolean; declare snapToBottom: boolean; declare flex: boolean; declare padding: string; declare height: string; declare maxHeight: string; declare _atStart: boolean; declare _atEnd: boolean; private _scrollContainer: HTMLElement | null = null; private _mutationObserver: MutationObserver | null = null; private _wasAtBottom = true; constructor() { super(); this.showScrollbar = false; this.fadeEdges = false; this.snapToBottom = false; this.flex = false; this.padding = "0"; this.height = ""; this.maxHeight = ""; this._atStart = true; this._atEnd = true; } get scrollContainer(): HTMLElement | null { if (!this._scrollContainer) { this._scrollContainer = this.shadowRoot?.querySelector( ".scroll-container", ) as HTMLElement | null; } return this._scrollContainer; } override firstUpdated() { // Cache reference this._scrollContainer = this.shadowRoot?.querySelector( ".scroll-container", ) as HTMLElement | null; this.updateScrollState(); } override connectedCallback() { super.connectedCallback(); // Set up scroll listener after element is ready this.updateComplete.then(() => { if (this.scrollContainer) { (this.scrollContainer as HTMLElement).addEventListener( "scroll", this.handleScroll, ); } this.setupMutationObserver(); }); // Listen for ct-chat-updated events this.addEventListener("ct-chat-updated", this._handleChatUpdate); } override disconnectedCallback() { super.disconnectedCallback(); if (this.scrollContainer) { (this.scrollContainer as HTMLElement).removeEventListener( "scroll", this.handleScroll, ); } this.cleanupMutationObserver(); this.removeEventListener("ct-chat-updated", this._handleChatUpdate); } private _handleChatUpdate = () => { if (this.snapToBottom && this._wasAtBottom) { // Use requestAnimationFrame to ensure DOM has updated requestAnimationFrame(() => { this.scrollToBottom(); }); } }; private handleScroll = () => { this.updateScrollState(); this.emit("ct-scroll", { scrollTop: (this.scrollContainer as HTMLElement)?.scrollTop || 0, scrollHeight: (this.scrollContainer as HTMLElement)?.scrollHeight || 0, clientHeight: (this.scrollContainer as HTMLElement)?.clientHeight || 0, }); }; private updateScrollState() { if (!this.scrollContainer) return; const { scrollTop, scrollHeight, clientHeight } = this .scrollContainer as HTMLElement; this._atStart = scrollTop <= 0; this._atEnd = scrollTop + clientHeight >= scrollHeight - 1; // Track if user was at bottom for snapToBottom behavior if (this.snapToBottom) { this._wasAtBottom = this._atEnd; } } override render() { const wrapperClasses = { "scroll-wrapper": true, "at-start": this._atStart, "at-end": this._atEnd, }; const containerClasses = { "scroll-container": true, [`p-${this.padding}`]: true, }; const containerStyles = { height: this.height || "100%", "max-height": this.maxHeight || "none", }; return html`
`; } /** * Scroll to a specific vertical position */ scrollToY(y: number, smooth: boolean = true) { if (this.scrollContainer) { (this.scrollContainer as HTMLElement).scrollTo({ top: y, behavior: smooth ? "smooth" : "auto", }); } } /** * Scroll by a specific vertical amount */ scrollByY(y: number, smooth: boolean = true) { if (this.scrollContainer) { (this.scrollContainer as HTMLElement).scrollBy({ top: y, behavior: smooth ? "smooth" : "auto", }); } } /** * Scroll to the bottom of the container */ scrollToBottom(smooth: boolean = true) { if (this.scrollContainer) { const { scrollHeight } = this.scrollContainer as HTMLElement; this.scrollToY(scrollHeight, smooth); } } private setupMutationObserver() { if (!this.snapToBottom) return; this.cleanupMutationObserver(); this._mutationObserver = new MutationObserver(() => { // Only auto-scroll if user was at bottom when content changed if (this._wasAtBottom) { // Use requestAnimationFrame to ensure DOM has updated requestAnimationFrame(() => { this.scrollToBottom(); }); } }); // Observe changes to the slotted content this._mutationObserver.observe(this, { childList: true, subtree: true, characterData: true, }); } private cleanupMutationObserver() { if (this._mutationObserver) { this._mutationObserver.disconnect(); this._mutationObserver = null; } } } globalThis.customElements.define("ct-vscroll", CTVScroll);