255 lines
8.4 KiB
TypeScript
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 {};
|