rspace-online/shared/components/folk-widget.ts

160 lines
5.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-widget> — Widget shell for rApp canvas surfaces.
*
* Provides a consistent title bar (icon + label + controls) and a slotted body.
* Used inside <folk-app-canvas> 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 = `
<style>${this.getStyles()}</style>
<div class="widget" part="widget">
<header class="header" part="header">
<div class="title">
<span class="icon" part="icon">${this.esc(this.getAttribute('icon') || '')}</span>
<span class="label" part="label">${this.esc(this.getAttribute('title') || 'Widget')}</span>
</div>
<div class="controls">
<button class="ctl" data-action="minimize" title="Minimize" aria-label="Minimize"></button>
${this.hasAttribute('no-close') ? '' : `<button class="ctl" data-action="close" title="Close" aria-label="Close">×</button>`}
</div>
</header>
<div class="body" part="body">
<slot></slot>
</div>
</div>`;
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 {};