/** * — 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: * * * * * Each
  • 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(); private visibleIds = new Set(); 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 = `
    ${this.renderGridSlots()}
    `; this.bindEvents(); this.mountVisibleWidgets(); } private renderToolbarBtn(w: WidgetDef): string { const active = this.visibleIds.has(w.id); return ``; } private renderGridSlots(): string { // Empty grid cells — widgets will be mounted into the grid via JS with grid-area return `
    `; } 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 {};