160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
/**
|
||
* <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 {};
|