/** * — Widget shell for rApp canvas surfaces. * * Provides a consistent title bar (icon + label + controls) and a slotted body. * Used inside to wrap feature views as togglable overlays. * * Attributes: * widget-id — unique id within the canvas (used for layout + toolbar state) * title — display label * icon — emoji/glyph shown in title bar * grid-area — optional CSS grid-area (for grid-mode canvas) * minimized — boolean; collapses body * no-close — hides the close button * * Events: * widget-close — user clicked × * widget-minimize — user toggled – * widget-focus — user clicked header (for z-stacking / detail panels) */ class FolkWidget extends HTMLElement { static get observedAttributes() { return ['title', 'icon', 'minimized', 'grid-area']; } private shadow: ShadowRoot; private _rendered = false; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } connectedCallback() { if (!this._rendered) { this.render(); this._rendered = true; } this.applyGridArea(); } attributeChangedCallback(name: string) { if (!this._rendered) return; if (name === 'title' || name === 'icon') { this.updateHeader(); } else if (name === 'minimized') { this.updateMinimized(); } else if (name === 'grid-area') { this.applyGridArea(); } } private applyGridArea() { const area = this.getAttribute('grid-area'); if (area) this.style.gridArea = area; } private render() { this.shadow.innerHTML = `
${this.esc(this.getAttribute('icon') || '')} ${this.esc(this.getAttribute('title') || 'Widget')}
${this.hasAttribute('no-close') ? '' : ``}
`; this.bindEvents(); this.updateMinimized(); } private updateHeader() { const icon = this.shadow.querySelector('.icon'); const label = this.shadow.querySelector('.label'); if (icon) icon.textContent = this.getAttribute('icon') || ''; if (label) label.textContent = this.getAttribute('title') || 'Widget'; } private updateMinimized() { const w = this.shadow.querySelector('.widget'); if (!w) return; w.classList.toggle('minimized', this.hasAttribute('minimized')); } private bindEvents() { const header = this.shadow.querySelector('.header'); header?.addEventListener('click', (e) => { const target = e.target as HTMLElement; if (target.closest('.controls')) return; this.dispatchEvent(new CustomEvent('widget-focus', { bubbles: true, composed: true, detail: { widgetId: this.getAttribute('widget-id') } })); }); this.shadow.querySelector('[data-action="minimize"]')?.addEventListener('click', (e) => { e.stopPropagation(); const mini = this.hasAttribute('minimized'); if (mini) this.removeAttribute('minimized'); else this.setAttribute('minimized', ''); this.dispatchEvent(new CustomEvent('widget-minimize', { bubbles: true, composed: true, detail: { widgetId: this.getAttribute('widget-id'), minimized: !mini } })); }); this.shadow.querySelector('[data-action="close"]')?.addEventListener('click', (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent('widget-close', { bubbles: true, composed: true, detail: { widgetId: this.getAttribute('widget-id') } })); }); } private getStyles(): string { return ` :host { display: block; min-width: 0; min-height: 0; } .widget { display: flex; flex-direction: column; height: 100%; width: 100%; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); overflow: hidden; } .header { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0.75rem; background: var(--rs-bg-surface-raised, var(--rs-bg-hover)); border-bottom: 1px solid var(--rs-border); cursor: pointer; user-select: none; flex-shrink: 0; } .title { display: flex; align-items: center; gap: 0.5rem; min-width: 0; } .icon { font-size: 1rem; flex-shrink: 0; } .label { font-size: 0.8125rem; font-weight: 600; color: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .controls { display: flex; gap: 0.125rem; flex-shrink: 0; } .ctl { background: transparent; border: none; color: var(--rs-text-secondary); cursor: pointer; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 1rem; line-height: 1; font-weight: 500; } .ctl:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); } .body { flex: 1; min-height: 0; overflow: auto; display: flex; flex-direction: column; } .body > ::slotted(*) { flex: 1; min-height: 0; } .widget.minimized .body { display: none; } .widget.minimized { height: auto !important; } `; } private esc(s: string): string { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } } if (!customElements.get('folk-widget')) customElements.define('folk-widget', FolkWidget); export {};