// — A reusable chat / conversation panel with message bubbles, // text input, send button, and typing indicator. // // Attributes: // title — panel header title (default "Chat") // subtitle — panel header subtitle (default "") // placeholder — input placeholder text (default "Type a message...") // typing — boolean, shows typing indicator and disables input // collapsible — boolean, enables collapse/expand toggle // collapsed — boolean, collapses the panel to a tab // // Properties: // messages — ArsChatMessage[] (set via JS only; array does not reflect to attribute) // // Events: // ars-chat-panel:send — composed CustomEvent with detail { message } // ars-chat-panel:clear — composed CustomEvent with no detail export interface ArsChatMessage { role: "assistant" | "system" | "user"; content: string; } class ArsChatPanel extends HTMLElement { private _messages: ArsChatMessage[] = []; private _draftValue = ""; private _expandTimeout: ReturnType | null = null; static get observedAttributes() { return ["title", "subtitle", "placeholder", "typing", "collapsible", "collapsed"]; } constructor() { super(); this.attachShadow({ mode: "open" }); } connectedCallback() { this.#render(); // If initially collapsed, the button uses position:fixed and needs // accurate viewport coordinates. Re-render once after layout. if (this.collapsed) { requestAnimationFrame(() => this.#render()); } } disconnectedCallback() { if (this._expandTimeout) { clearTimeout(this._expandTimeout); this._expandTimeout = null; } } attributeChangedCallback() { if (this.shadowRoot) { this.#render(); } } // --- Property accessors --- get title(): string { return this.getAttribute("title") || "Chat"; } set title(value: string) { this.setAttribute("title", value); } get subtitle(): string { return this.getAttribute("subtitle") || ""; } set subtitle(value: string) { this.setAttribute("subtitle", value); } get placeholder(): string { return this.getAttribute("placeholder") || "Type a message..."; } set placeholder(value: string) { this.setAttribute("placeholder", value); } get typing(): boolean { return this.hasAttribute("typing"); } set typing(value: boolean) { this.toggleAttribute("typing", value); } get collapsible(): boolean { return this.hasAttribute("collapsible"); } set collapsible(value: boolean) { this.toggleAttribute("collapsible", value); } get collapsed(): boolean { return this.hasAttribute("collapsed"); } set collapsed(value: boolean) { this.toggleAttribute("collapsed", value); } get messages(): ArsChatMessage[] { return this._messages.map((m) => ({ ...m })); } set messages(value: ArsChatMessage[]) { this._messages = Array.isArray(value) ? value.map((m) => ({ ...m })) : []; if (this.shadowRoot) { this.#render(); } } // --- Rendering --- #render() { if (!this.shadowRoot) { return; } const messages = this._messages; const isTyping = this.typing; // After the first mount, patch only the dynamic parts so the
${isTyping ? "Typing..." : ""}
`; const textarea = this.shadowRoot.querySelector("textarea"); const sendButton = this.shadowRoot.querySelector("button.send"); const clearButton = this.shadowRoot.querySelector("button.clear"); const messagesContainer = this.shadowRoot.querySelector(".messages"); const collapseToggle = this.shadowRoot.querySelector(".collapse-toggle"); if (messagesContainer) { messagesContainer.scrollTop = messagesContainer.scrollHeight; } textarea?.addEventListener("input", () => { this._draftValue = textarea.value; textarea.style.height = "auto"; textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px"; }); textarea?.addEventListener("keydown", (event) => { if (event.key !== "Enter" || event.shiftKey || this.typing) { return; } event.preventDefault(); this.#dispatchSend(); }); sendButton?.addEventListener("click", () => { this.#dispatchSend(); }); clearButton?.addEventListener("click", () => { this.dispatchEvent( new CustomEvent("ars-chat-panel:clear", { bubbles: true, composed: true, }), ); }); collapseToggle?.addEventListener("click", () => { this.collapsed = !this.collapsed; }); } #dispatchSend() { const message = this._draftValue.trim(); if (!message) { return; } this.dispatchEvent( new CustomEvent("ars-chat-panel:send", { bubbles: true, composed: true, detail: { message }, }), ); this._draftValue = ""; this.#render(); } static #escapeHtml(value: unknown): string { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } static #arrowUpIcon(): string { return ``; } static #chevronRightIcon(): string { return ``; } static #messageIcon(): string { return ``; } static #styles(): string { return ` :host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden; font-family: var(--arswc-font-family-sans, system-ui, sans-serif); } .panel { box-sizing: border-box; position: relative; display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto; gap: var(--arswc-spacing-sm, 8px); width: 100%; height: 100%; padding: var(--arswc-spacing-md, 16px); border: 1px solid var(--arswc-color-border, #d5dde8); border-radius: var(--arswc-radius-md, 10px); background: var(--arswc-color-surface, #f6f8fb); color: var(--arswc-color-text, #1b2430); transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1); } .panel.collapsed { transform: translateX(100%); pointer-events: none; } .panel.has-collapse-toggle .header { padding-left: 36px; } .collapse-toggle { position: absolute; left: 8px; top: 12px; width: 32px; height: 32px; display: grid; place-items: center; border: none; border-radius: var(--arswc-radius-sm, 6px); background: color-mix(in srgb, var(--arswc-color-accent, #2563eb) 15%, transparent); color: var(--arswc-color-accent, #2563eb); cursor: pointer; pointer-events: auto; z-index: 1; box-sizing: border-box; transition: background 120ms ease, color 120ms ease, left 280ms cubic-bezier(0.4, 0, 0.2, 1); } .collapse-toggle:hover { background: color-mix(in srgb, var(--arswc-color-accent, #2563eb) 25%, transparent); } .collapse-toggle.collapsed { /* position, left/right/top are set via inline styles (position:fixed) */ width: 56px; height: 72px; border-radius: 10px 0 0 10px; background: var(--arswc-color-surface, #f6f8fb); border: 1px solid var(--arswc-color-border, #d5dde8); border-right: none; } .header { display: flex; align-items: start; justify-content: space-between; gap: var(--arswc-spacing-sm, 8px); } .title { font-size: var(--arswc-font-size-md, 0.875rem); font-weight: 700; } .subtitle { margin-top: 4px; color: var(--arswc-color-muted, #64748b); font-size: var(--arswc-font-size-sm, 0.75rem); line-height: 1.35; } .clear { border: 1px solid var(--arswc-color-danger, #dc2626); border-radius: 999px; padding: var(--arswc-spacing-sm, 8px) var(--arswc-spacing-md, 16px); background: color-mix(in srgb, var(--arswc-color-danger, #dc2626) 10%, transparent); color: var(--arswc-color-danger, #dc2626); cursor: pointer; font: inherit; font-size: var(--arswc-font-size-sm, 0.75rem); } /* .clear:disabled intentionally unstyled — the component already sets disabled=true functionally; we keep the visual appearance unchanged so the UI doesn't flicker during agent typing. */ .messages { overflow: auto; display: grid; align-content: start; gap: var(--arswc-spacing-sm, 8px); min-height: 0; padding-right: 4px; } .message { padding: var(--arswc-spacing-sm, 8px) var(--arswc-spacing-md, 16px); border-radius: var(--arswc-radius-md, 10px); line-height: 1.45; white-space: pre-wrap; word-break: break-word; border: 1px solid var(--arswc-color-border, #d5dde8); } .message-user { background: color-mix(in srgb, var(--arswc-color-accent, #2563eb) 15%, transparent); color: var(--arswc-color-accent, #2563eb); justify-self: end; } .message-assistant { background: var(--arswc-color-bg, #ffffff); color: var(--arswc-color-text, #1b2430); } .message-system { background: var(--arswc-color-surface, #f6f8fb); color: var(--arswc-color-muted, #64748b); } .input-row { position: relative; display: flex; align-items: flex-end; } textarea { box-sizing: border-box; width: 100%; min-height: 40px; max-height: 120px; padding: var(--arswc-spacing-sm, 8px) 44px var(--arswc-spacing-sm, 8px) var(--arswc-spacing-md, 16px); border: 1px solid var(--arswc-color-border, #d5dde8); border-radius: var(--arswc-radius-sm, 6px); background: var(--arswc-color-bg, #ffffff); color: var(--arswc-color-text, #1b2430); font: inherit; resize: none; outline: none; line-height: 1.4; } textarea:focus-visible { outline: none; border-color: var(--arswc-color-accent, #2563eb); box-shadow: var(--arswc-focus-ring, 0 0 0 3px rgba(37, 99, 235, 0.3)); } /* textarea:disabled intentionally unstyled — the component already sets disabled=true functionally; we keep the visual appearance unchanged so the UI doesn't flicker during agent typing. */ button.send { position: absolute; right: 6px; bottom: 6px; width: 28px; height: 28px; padding: 0; border: none; border-radius: 50%; background: linear-gradient( 180deg, var(--arswc-button-primary-bg-start, #3b82f6), var(--arswc-button-primary-bg-end, #2563eb) ); color: var(--arswc-button-primary-color, #ffffff); cursor: pointer; display: grid; place-items: center; } button.send:disabled { opacity: 0.5; cursor: not-allowed; } .typing { color: var(--arswc-color-muted, #64748b); font-size: var(--arswc-font-size-sm, 0.75rem); min-height: 1.2em; } `; } } export { ArsChatPanel, ArsChatPanel as default };