rspace-online/shared/components/folk-app-canvas.ts

255 lines
8.4 KiB
TypeScript

/**
* <folk-app-canvas> — Canvas host for an rApp's feature widgets.
*
* Replaces per-rApp tab-based navigation with a unified canvas surface where
* each feature is a togglable widget. Supports grid mode (default — snap to
* CSS grid template) and free mode (optional — absolute positioning; future).
*
* Attributes:
* app-id — rApp identifier (used for layout persistence key, e.g. "rtasks")
* space — current space slug
* layout — JSON string of { id, gridArea, minimized }[] (optional; in-memory default)
*
* Child structure:
* <folk-app-canvas>
* <template slot="widget-registry"> — declarative widget menu (see below)
* <li data-widget-id="board" data-title="Board" data-icon="📋" data-grid-area="board" data-default="1">
* <folk-tasks-board></folk-tasks-board>
* </li>
* ...
* </template>
* </folk-app-canvas>
*
* Each <li> in the registry describes a widget definition. On canvas mount,
* widgets marked data-default="1" are added to the visible grid. Toolbar
* buttons toggle additional widgets.
*
* Grid template: responsive. Two-column on desktop, single-column on mobile.
* Consumers can override via `grid-template` attribute.
*/
import './folk-widget';
interface WidgetDef {
id: string;
title: string;
icon: string;
gridArea: string;
element: HTMLElement;
default: boolean;
}
class FolkAppCanvas extends HTMLElement {
private shadow: ShadowRoot;
private widgets = new Map<string, WidgetDef>();
private visibleIds = new Set<string>();
private _rendered = false;
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.collectWidgetRegistry();
this.loadInitialVisibility();
this.render();
this._rendered = true;
}
private collectWidgetRegistry() {
const tpl = this.querySelector('template[slot="widget-registry"]') as HTMLTemplateElement | null;
if (!tpl) {
console.warn('[folk-app-canvas] no widget-registry template found');
return;
}
const nodes = tpl.content.querySelectorAll('li[data-widget-id]');
nodes.forEach((li) => {
const id = (li as HTMLElement).dataset.widgetId!;
const title = (li as HTMLElement).dataset.title || id;
const icon = (li as HTMLElement).dataset.icon || '';
const gridArea = (li as HTMLElement).dataset.gridArea || id;
const isDefault = (li as HTMLElement).dataset.default === '1';
// Take the first element child as the widget content
const content = (li as HTMLElement).firstElementChild as HTMLElement | null;
if (!content) return;
this.widgets.set(id, { id, title, icon, gridArea, element: content.cloneNode(true) as HTMLElement, default: isDefault });
});
}
private loadInitialVisibility() {
// URL override: ?widgets=board,activity
const params = new URLSearchParams(window.location.search);
const widgetsParam = params.get('widgets');
if (widgetsParam) {
widgetsParam.split(',').forEach(id => {
if (this.widgets.has(id.trim())) this.visibleIds.add(id.trim());
});
return;
}
// Otherwise use defaults
this.widgets.forEach((w, id) => {
if (w.default) this.visibleIds.add(id);
});
}
private render() {
this.shadow.innerHTML = `
<style>${this.getStyles()}</style>
<div class="canvas-root">
<nav class="toolbar" part="toolbar">
<div class="toolbar-title">
<span class="toolbar-label">Widgets</span>
</div>
<div class="toolbar-buttons">
${Array.from(this.widgets.values()).map(w => this.renderToolbarBtn(w)).join('')}
</div>
<div class="toolbar-meta">
<button class="meta-btn" data-action="reset" title="Reset to default layout">Reset</button>
</div>
</nav>
<div class="grid" part="grid">
${this.renderGridSlots()}
</div>
</div>`;
this.bindEvents();
this.mountVisibleWidgets();
}
private renderToolbarBtn(w: WidgetDef): string {
const active = this.visibleIds.has(w.id);
return `<button class="tb-btn ${active ? 'active' : ''}" data-action="toggle" data-widget-id="${this.esc(w.id)}" title="${this.esc(w.title)}">
<span class="tb-icon">${this.esc(w.icon)}</span>
<span class="tb-label">${this.esc(w.title)}</span>
</button>`;
}
private renderGridSlots(): string {
// Empty grid cells — widgets will be mounted into the grid via JS with grid-area
return `<div class="grid-slots" id="grid-slots"></div>`;
}
private mountVisibleWidgets() {
const slots = this.shadow.getElementById('grid-slots');
if (!slots) return;
slots.innerHTML = '';
this.visibleIds.forEach(id => {
const def = this.widgets.get(id);
if (!def) return;
const wrapper = document.createElement('folk-widget');
wrapper.setAttribute('widget-id', id);
wrapper.setAttribute('title', def.title);
wrapper.setAttribute('icon', def.icon);
wrapper.setAttribute('grid-area', def.gridArea);
wrapper.appendChild(def.element.cloneNode(true));
wrapper.addEventListener('widget-close', () => {
this.visibleIds.delete(id);
this.render();
});
slots.appendChild(wrapper);
});
}
private bindEvents() {
this.shadow.querySelectorAll('[data-action="toggle"]').forEach(btn => {
btn.addEventListener('click', () => {
const id = (btn as HTMLElement).dataset.widgetId!;
if (this.visibleIds.has(id)) this.visibleIds.delete(id);
else this.visibleIds.add(id);
this.render();
});
});
this.shadow.querySelector('[data-action="reset"]')?.addEventListener('click', () => {
this.visibleIds.clear();
this.widgets.forEach((w, id) => { if (w.default) this.visibleIds.add(id); });
this.render();
});
}
private getStyles(): string {
// Default grid template: designed for up-to-4 widgets in a 2x2 arrangement.
// Consuming rApps should override via ::part(grid) { grid-template: ... }
return `
:host { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; background: var(--rs-bg-base); }
.canvas-root { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.toolbar {
display: flex; align-items: center; gap: 1rem;
padding: 0.5rem 1rem;
background: var(--rs-bg-surface);
border-bottom: 1px solid var(--rs-border);
flex-shrink: 0;
overflow-x: auto;
}
.toolbar-title { display: flex; align-items: center; flex-shrink: 0; }
.toolbar-label { font-size: 0.75rem; font-weight: 600; color: var(--rs-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.toolbar-buttons { display: flex; gap: 0.375rem; flex: 1; flex-wrap: wrap; }
.toolbar-meta { display: flex; gap: 0.375rem; flex-shrink: 0; }
.tb-btn {
display: inline-flex; align-items: center; gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid var(--rs-border);
border-radius: 999px;
background: transparent;
color: var(--rs-text-secondary);
cursor: pointer;
font-size: 0.8125rem;
font-weight: 500;
transition: all 0.12s;
white-space: nowrap;
}
.tb-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
.tb-btn.active {
background: rgba(99,102,241,0.12);
border-color: var(--rs-primary, #6366f1);
color: var(--rs-text-primary);
}
.tb-icon { font-size: 0.95rem; line-height: 1; }
.meta-btn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--rs-border);
border-radius: 6px;
background: transparent;
color: var(--rs-text-secondary);
cursor: pointer;
font-size: 0.75rem;
}
.meta-btn:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
.grid { flex: 1; min-height: 0; overflow: hidden; padding: 1rem; }
.grid-slots {
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-rows: 1fr 1fr;
grid-auto-flow: dense;
gap: 1rem;
width: 100%;
height: 100%;
min-height: 0;
}
/* Named grid areas — rApps can reference via widget grid-area="board" etc.
If an area isn't defined in the template, the widget falls back to auto-placement. */
@media (max-width: 900px) {
.grid-slots {
grid-template-columns: 1fr;
grid-template-rows: auto;
grid-auto-rows: minmax(260px, auto);
}
.toolbar { padding: 0.5rem 0.75rem; }
.tb-label { display: none; }
.tb-btn { padding: 0.375rem 0.5rem; }
}
`;
}
private esc(s: string): string {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
}
if (!customElements.get('folk-app-canvas')) customElements.define('folk-app-canvas', FolkAppCanvas);
export {};