wip(rschedule): schemas + folk-app-canvas/folk-widget shared components
Schema for the new Calendly-style rSchedule port plus two new shared canvas components (folk-app-canvas, folk-widget) that replace per-rApp tab-based nav with a unified widget-on-canvas surface.
This commit is contained in:
parent
3a614e2866
commit
8042e86815
|
|
@ -0,0 +1,260 @@
|
||||||
|
/**
|
||||||
|
* rSchedule Automerge document schemas — Calendly-style booking.
|
||||||
|
*
|
||||||
|
* Three docs per space (or per user-space):
|
||||||
|
* {space}:schedule:config — settings + availability rules + overrides + gcal state
|
||||||
|
* {space}:schedule:bookings — bookings this space HOSTS (it is the bookee)
|
||||||
|
* {space}:schedule:invitations — bookings this space is INVITED to (as attendee)
|
||||||
|
*
|
||||||
|
* On booking create, server writes to host's `bookings` doc AND each invitee's
|
||||||
|
* `invitations` doc so every party sees the booking in their own schedule view
|
||||||
|
* without cross-space reads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DocSchema } from '../../shared/local-first/document';
|
||||||
|
|
||||||
|
// ── Entity reference (space slug or user DID) ──
|
||||||
|
|
||||||
|
export type EntityKind = 'space' | 'user';
|
||||||
|
|
||||||
|
export interface EntityRef {
|
||||||
|
kind: EntityKind;
|
||||||
|
/** For `space`: the space slug. For `user`: the did:key:... */
|
||||||
|
id: string;
|
||||||
|
/** Display label — denormalized for UI, refreshed on render. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config doc ──
|
||||||
|
|
||||||
|
export interface ScheduleSettings {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
bookingMessage: string;
|
||||||
|
/** Minutes per slot (15, 30, 45, 60, 90, 120). */
|
||||||
|
slotDurationMin: number;
|
||||||
|
bufferBeforeMin: number;
|
||||||
|
bufferAfterMin: number;
|
||||||
|
minNoticeHours: number;
|
||||||
|
maxAdvanceDays: number;
|
||||||
|
timezone: string;
|
||||||
|
autoShiftTimezone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailabilityRule {
|
||||||
|
id: string;
|
||||||
|
/** 0 = Sunday, 6 = Saturday (JS Date convention) */
|
||||||
|
dayOfWeek: number;
|
||||||
|
/** "HH:MM" 24h format, in `settings.timezone` */
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailabilityOverride {
|
||||||
|
id: string;
|
||||||
|
/** ISO date "YYYY-MM-DD" in `settings.timezone` */
|
||||||
|
date: string;
|
||||||
|
/** If `isBlocked=true`, the whole day is unavailable. Otherwise uses startTime/endTime. */
|
||||||
|
isBlocked: boolean;
|
||||||
|
startTime: string | null;
|
||||||
|
endTime: string | null;
|
||||||
|
reason: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoogleAuthMeta {
|
||||||
|
connected: boolean;
|
||||||
|
connectedAt: number | null;
|
||||||
|
email: string | null;
|
||||||
|
calendarIds: string[];
|
||||||
|
/** Opaque sync token used for incremental list calls. */
|
||||||
|
syncToken: string | null;
|
||||||
|
lastSyncAt: number | null;
|
||||||
|
lastSyncStatus: 'ok' | 'error' | null;
|
||||||
|
lastSyncError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached Google Calendar busy events (opaque + non-cancelled). */
|
||||||
|
export interface CachedGcalEvent {
|
||||||
|
id: string;
|
||||||
|
googleEventId: string;
|
||||||
|
calendarId: string;
|
||||||
|
title: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
allDay: boolean;
|
||||||
|
status: 'confirmed' | 'tentative' | 'cancelled';
|
||||||
|
transparency: 'opaque' | 'transparent';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleConfigDoc {
|
||||||
|
meta: {
|
||||||
|
module: string;
|
||||||
|
collection: string;
|
||||||
|
version: number;
|
||||||
|
spaceSlug: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
settings: ScheduleSettings;
|
||||||
|
rules: Record<string, AvailabilityRule>;
|
||||||
|
overrides: Record<string, AvailabilityOverride>;
|
||||||
|
googleAuth: GoogleAuthMeta;
|
||||||
|
googleEvents: Record<string, CachedGcalEvent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Booking doc (host side) ──
|
||||||
|
|
||||||
|
export type BookingStatus = 'confirmed' | 'cancelled' | 'completed';
|
||||||
|
|
||||||
|
export interface BookingAttendee {
|
||||||
|
id: string;
|
||||||
|
entity: EntityRef | null;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
status: 'invited' | 'accepted' | 'declined';
|
||||||
|
invitedAt: number;
|
||||||
|
respondedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Booking {
|
||||||
|
id: string;
|
||||||
|
host: EntityRef;
|
||||||
|
guestName: string;
|
||||||
|
guestEmail: string;
|
||||||
|
guestNote: string;
|
||||||
|
attendees: Record<string, BookingAttendee>;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
timezone: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
meetingLink: string | null;
|
||||||
|
googleEventId: string | null;
|
||||||
|
cancelToken: string;
|
||||||
|
cancellationReason: string | null;
|
||||||
|
reminderSentAt: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleBookingsDoc {
|
||||||
|
meta: {
|
||||||
|
module: string;
|
||||||
|
collection: string;
|
||||||
|
version: number;
|
||||||
|
spaceSlug: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
bookings: Record<string, Booking>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invitations doc (attendee side) ──
|
||||||
|
|
||||||
|
export interface Invitation {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
host: EntityRef;
|
||||||
|
title: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
timezone: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
response: 'invited' | 'accepted' | 'declined';
|
||||||
|
meetingLink: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleInvitationsDoc {
|
||||||
|
meta: {
|
||||||
|
module: string;
|
||||||
|
collection: string;
|
||||||
|
version: number;
|
||||||
|
spaceSlug: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
invitations: Record<string, Invitation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Defaults ──
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: ScheduleSettings = {
|
||||||
|
displayName: '',
|
||||||
|
email: '',
|
||||||
|
bookingMessage: 'Book a time to chat.',
|
||||||
|
slotDurationMin: 30,
|
||||||
|
bufferBeforeMin: 0,
|
||||||
|
bufferAfterMin: 0,
|
||||||
|
minNoticeHours: 2,
|
||||||
|
maxAdvanceDays: 60,
|
||||||
|
timezone: 'America/Vancouver',
|
||||||
|
autoShiftTimezone: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_GOOGLE_AUTH: GoogleAuthMeta = {
|
||||||
|
connected: false,
|
||||||
|
connectedAt: null,
|
||||||
|
email: null,
|
||||||
|
calendarIds: [],
|
||||||
|
syncToken: null,
|
||||||
|
lastSyncAt: null,
|
||||||
|
lastSyncStatus: null,
|
||||||
|
lastSyncError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Schema registrations ──
|
||||||
|
|
||||||
|
export const scheduleConfigSchema: DocSchema<ScheduleConfigDoc> = {
|
||||||
|
module: 'schedule',
|
||||||
|
collection: 'config',
|
||||||
|
version: 1,
|
||||||
|
init: (): ScheduleConfigDoc => ({
|
||||||
|
meta: { module: 'schedule', collection: 'config', version: 1, spaceSlug: '', createdAt: Date.now() },
|
||||||
|
settings: { ...DEFAULT_SETTINGS },
|
||||||
|
rules: {},
|
||||||
|
overrides: {},
|
||||||
|
googleAuth: { ...DEFAULT_GOOGLE_AUTH },
|
||||||
|
googleEvents: {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scheduleBookingsSchema: DocSchema<ScheduleBookingsDoc> = {
|
||||||
|
module: 'schedule',
|
||||||
|
collection: 'bookings',
|
||||||
|
version: 1,
|
||||||
|
init: (): ScheduleBookingsDoc => ({
|
||||||
|
meta: { module: 'schedule', collection: 'bookings', version: 1, spaceSlug: '', createdAt: Date.now() },
|
||||||
|
bookings: {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scheduleInvitationsSchema: DocSchema<ScheduleInvitationsDoc> = {
|
||||||
|
module: 'schedule',
|
||||||
|
collection: 'invitations',
|
||||||
|
version: 1,
|
||||||
|
init: (): ScheduleInvitationsDoc => ({
|
||||||
|
meta: { module: 'schedule', collection: 'invitations', version: 1, spaceSlug: '', createdAt: Date.now() },
|
||||||
|
invitations: {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── DocId helpers ──
|
||||||
|
|
||||||
|
export function scheduleConfigDocId(space: string) {
|
||||||
|
return `${space}:schedule:config` as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleBookingsDocId(space: string) {
|
||||||
|
return `${space}:schedule:bookings` as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleInvitationsDocId(space: string) {
|
||||||
|
return `${space}:schedule:invitations` as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Limits ──
|
||||||
|
|
||||||
|
export const MAX_RULES_PER_SPACE = 50;
|
||||||
|
export const MAX_OVERRIDES_PER_SPACE = 500;
|
||||||
|
export const MAX_GCAL_EVENTS_CACHED = 2000;
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
/**
|
||||||
|
* <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 {};
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
/**
|
||||||
|
* <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 {};
|
||||||
Loading…
Reference in New Issue