feat(elements): add eco-provider-frame and dataprovider interfaces; improve virtual keyboard interactions; add demos, exports and bump dev dependencies

This commit is contained in:
2026-01-12 15:16:01 +00:00
parent bf4fcfac71
commit ee45fb01a2
21 changed files with 4262 additions and 271 deletions

View File

@@ -211,6 +211,10 @@ export class EcoApplauncherKeyboard extends DeesElement {
background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 5% 28%)')};
}
.key:focus {
outline: none;
}
.key.special {
background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')};
font-size: 16px;
@@ -375,10 +379,12 @@ export class EcoApplauncherKeyboard extends DeesElement {
return html`
<div
class="key ${type} ${widthClass} ${isActive ? 'active' : ''}"
tabindex="-1"
@pointerdown=${(e: PointerEvent) => this.handlePointerDown(e, config)}
@pointerup=${(e: PointerEvent) => this.handlePointerUp(e, config)}
@pointerleave=${(e: PointerEvent) => this.handlePointerLeave(e, config)}
@pointermove=${(e: PointerEvent) => this.handlePointerMove(e, config)}
@mousedown=${(e: MouseEvent) => e.preventDefault()}
>
${displayValue}
</div>
@@ -418,6 +424,7 @@ export class EcoApplauncherKeyboard extends DeesElement {
private handlePointerDown(e: PointerEvent, config: IKeyConfig): void {
e.preventDefault();
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
@@ -483,6 +490,7 @@ export class EcoApplauncherKeyboard extends DeesElement {
private handlePointerUp(e: PointerEvent, config: IKeyConfig): void {
e.preventDefault();
e.stopPropagation();
this.clearLongPressTimer();
this.keyPreview = null;

View File

@@ -644,7 +644,11 @@ export class EcoApplauncher extends DeesElement {
<div class="launcher-container">
${this.mode === 'login' ? '' : this.renderTopBar()}
${this.renderMainContent()}
<div class="keyboard-area ${this.keyboardVisible ? 'visible' : ''}">
<div
class="keyboard-area ${this.keyboardVisible ? 'visible' : ''}"
tabindex="-1"
@mousedown=${(e: MouseEvent) => e.preventDefault()}
>
<eco-applauncher-keyboard
?visible=${this.keyboardVisible}
@key-press=${this.handleKeyboardKeyPress}
@@ -962,7 +966,9 @@ export class EcoApplauncher extends DeesElement {
return html`
<div
class="status-item clickable ${this.keyboardVisible ? 'active' : ''}"
tabindex="-1"
@click=${this.handleKeyboardToggle}
@mousedown=${(e: MouseEvent) => e.preventDefault()}
>
<dees-icon .icon=${'lucide:keyboard'} .iconSize=${18}></dees-icon>
</div>

View File

@@ -0,0 +1,46 @@
import { html } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
width: 100%;
height: 400px;
background: hsl(240 10% 4%);
border-radius: 12px;
overflow: hidden;
padding: 16px;
box-sizing: border-box;
}
.demo-info {
margin-bottom: 16px;
padding: 12px;
background: hsl(240 6% 12%);
border-radius: 8px;
color: hsl(0 0% 70%);
font-size: 14px;
}
.demo-frame {
width: 100%;
height: calc(100% - 80px);
}
</style>
<div class="demo-container">
<div class="demo-info">
The provider frame loads external web apps that implement the ecobridge provider protocol.
In this demo, no provider URL is set, so it shows the loading state.
</div>
<div class="demo-frame">
<eco-provider-frame
providerId="demo-provider"
providerName="Demo Provider"
providerUrl=""
@provider-ready=${(e: CustomEvent) => console.log('Provider ready:', e.detail)}
@provider-features-changed=${(e: CustomEvent) => console.log('Features changed:', e.detail)}
@provider-response=${(e: CustomEvent) => console.log('Response:', e.detail)}
@provider-error=${(e: CustomEvent) => console.log('Error:', e.detail)}
></eco-provider-frame>
</div>
</div>
`;

View File

@@ -0,0 +1,502 @@
import {
customElement,
DeesElement,
type TemplateResult,
html,
property,
css,
cssManager,
state,
query,
} from '@design.estate/dees-element';
import { DeesIcon } from '@design.estate/dees-catalog';
import type {
IDataProvider,
IProviderMessage,
IEcobridgeMessage,
IProviderReadyPayload,
IProviderResponsePayload,
IProviderDataOfferPayload,
IFeatureChangeRequest,
TProviderFeature,
TProviderStatus,
ISendDataPayload,
IRequestDataPayload,
} from '../interfaces/dataprovider.js';
import { demo } from './eco-provider-frame.demo.js';
// Ensure components are registered
DeesIcon;
declare global {
interface HTMLElementTagNameMap {
'eco-provider-frame': EcoProviderFrame;
}
}
@customElement('eco-provider-frame')
export class EcoProviderFrame extends DeesElement {
public static demo = demo;
public static demoGroup = 'Elements';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
position: relative;
}
.provider-frame-container {
width: 100%;
height: 100%;
position: relative;
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 8%)')};
border-radius: 8px;
overflow: hidden;
}
iframe {
width: 100%;
height: 100%;
border: none;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: ${cssManager.bdTheme('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.8)')};
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')};
z-index: 10;
}
.loading-overlay dees-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: ${cssManager.bdTheme('rgba(255,255,255,0.95)', 'rgba(0,0,0,0.9)')};
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 70%)')};
z-index: 10;
padding: 32px;
text-align: center;
}
.error-overlay .error-icon {
color: hsl(0 72% 50%);
}
.error-title {
font-size: 16px;
font-weight: 600;
color: hsl(0 72% 50%);
}
.error-message {
font-size: 14px;
max-width: 400px;
line-height: 1.5;
}
.retry-button {
margin-top: 8px;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
background: hsl(217 91% 60%);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease;
}
.retry-button:hover {
background: hsl(217 91% 55%);
}
.disconnected-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: ${cssManager.bdTheme('rgba(255,255,255,0.95)', 'rgba(0,0,0,0.9)')};
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
z-index: 10;
}
`,
];
@property({ type: String })
accessor providerId = '';
@property({ type: String })
accessor providerUrl = '';
@property({ type: String })
accessor providerName = '';
@property({ type: Array })
accessor confirmedFeatures: TProviderFeature[] = [];
@property({ type: Boolean })
accessor sandboxed = true;
@state()
accessor status: TProviderStatus = 'loading';
@state()
accessor error: string | null = null;
@state()
accessor currentFeatures: TProviderFeature[] = [];
@query('iframe')
accessor iframe: HTMLIFrameElement | null;
private messageHandler: ((e: MessageEvent) => void) | null = null;
private pendingRequests: Map<string, {
resolve: (value: IProviderResponsePayload) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}> = new Map();
async connectedCallback(): Promise<void> {
await super.connectedCallback();
this.setupMessageListener();
}
async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
this.cleanupMessageListener();
this.clearPendingRequests();
}
private setupMessageListener(): void {
this.messageHandler = (event: MessageEvent) => {
this.handleProviderMessage(event);
};
window.addEventListener('message', this.messageHandler);
}
private cleanupMessageListener(): void {
if (this.messageHandler) {
window.removeEventListener('message', this.messageHandler);
this.messageHandler = null;
}
}
private clearPendingRequests(): void {
for (const [requestId, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject(new Error('Provider disconnected'));
}
this.pendingRequests.clear();
}
public render(): TemplateResult {
return html`
<div class="provider-frame-container">
${this.providerUrl ? html`
<iframe
src=${this.providerUrl}
sandbox=${this.sandboxed ? 'allow-scripts allow-same-origin allow-forms' : ''}
@load=${this.handleIframeLoad}
@error=${this.handleIframeError}
></iframe>
` : ''}
${this.renderOverlay()}
</div>
`;
}
private renderOverlay(): TemplateResult | null {
switch (this.status) {
case 'loading':
return html`
<div class="loading-overlay">
<dees-icon .icon=${'lucide:loader'} .iconSize=${32}></dees-icon>
<span>Loading provider...</span>
</div>
`;
case 'error':
return html`
<div class="error-overlay">
<dees-icon class="error-icon" .icon=${'lucide:alertCircle'} .iconSize=${48}></dees-icon>
<span class="error-title">Connection Error</span>
<span class="error-message">${this.error || 'Failed to connect to provider'}</span>
<button class="retry-button" @click=${this.retry}>Retry</button>
</div>
`;
case 'disconnected':
return html`
<div class="disconnected-overlay">
<dees-icon .icon=${'lucide:unplug'} .iconSize=${32}></dees-icon>
<span>Provider disconnected</span>
</div>
`;
default:
return null;
}
}
private handleIframeLoad(): void {
// Start waiting for provider-ready message
// If not received within timeout, show error
setTimeout(() => {
if (this.status === 'loading') {
this.status = 'error';
this.error = 'Provider did not respond. Make sure it implements the ecobridge provider protocol.';
}
}, 10000); // 10 second timeout for provider to declare ready
}
private handleIframeError(): void {
this.status = 'error';
this.error = 'Failed to load provider URL';
}
private handleProviderMessage(event: MessageEvent): void {
// Verify origin matches provider URL
if (!this.providerUrl) return;
try {
const providerOrigin = new URL(this.providerUrl).origin;
if (event.origin !== providerOrigin) return;
} catch {
return;
}
const message = event.data as IProviderMessage;
if (!message || typeof message.type !== 'string') return;
// Only accept messages from this provider
if (message.providerId && message.providerId !== this.providerId) return;
switch (message.type) {
case 'provider-ready':
this.handleProviderReady(message.payload as IProviderReadyPayload);
break;
case 'provider-features':
this.handleFeaturesUpdate(message.payload as { features: TProviderFeature[] });
break;
case 'provider-response':
this.handleProviderResponse(message.requestId!, message.payload as IProviderResponsePayload);
break;
case 'provider-error':
this.handleProviderError(message.payload as { error: string });
break;
case 'data-offer':
this.handleDataOffer(message.payload as IProviderDataOfferPayload);
break;
}
}
private handleProviderReady(payload: IProviderReadyPayload): void {
this.currentFeatures = payload.features || [];
// Check for feature changes
if (this.confirmedFeatures.length > 0) {
const added = this.currentFeatures.filter(f => !this.confirmedFeatures.includes(f));
const removed = this.confirmedFeatures.filter(f => !this.currentFeatures.includes(f));
if (added.length > 0 || removed.length > 0) {
const request: IFeatureChangeRequest = {
providerId: this.providerId,
providerName: payload.name || this.providerName,
addedFeatures: added,
removedFeatures: removed,
currentFeatures: this.currentFeatures,
};
this.dispatchEvent(new CustomEvent('provider-features-changed', {
detail: { request },
bubbles: true,
composed: true,
}));
}
}
this.status = 'connected';
const provider: IDataProvider = {
id: this.providerId,
name: payload.name || this.providerName,
url: this.providerUrl,
features: this.currentFeatures,
confirmedFeatures: this.confirmedFeatures,
icon: payload.icon,
lastSeen: new Date(),
status: 'connected',
enabled: true,
};
this.dispatchEvent(new CustomEvent('provider-ready', {
detail: { provider },
bubbles: true,
composed: true,
}));
}
private handleFeaturesUpdate(payload: { features: TProviderFeature[] }): void {
const previousFeatures = [...this.currentFeatures];
this.currentFeatures = payload.features || [];
const added = this.currentFeatures.filter(f => !previousFeatures.includes(f));
const removed = previousFeatures.filter(f => !this.currentFeatures.includes(f));
if (added.length > 0 || removed.length > 0) {
const request: IFeatureChangeRequest = {
providerId: this.providerId,
providerName: this.providerName,
addedFeatures: added,
removedFeatures: removed,
currentFeatures: this.currentFeatures,
};
this.dispatchEvent(new CustomEvent('provider-features-changed', {
detail: { request },
bubbles: true,
composed: true,
}));
}
}
private handleProviderResponse(requestId: string, response: IProviderResponsePayload): void {
const pending = this.pendingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(requestId);
pending.resolve(response);
}
this.dispatchEvent(new CustomEvent('provider-response', {
detail: { requestId, response },
bubbles: true,
composed: true,
}));
}
private handleProviderError(payload: { error: string }): void {
this.dispatchEvent(new CustomEvent('provider-error', {
detail: { error: payload.error },
bubbles: true,
composed: true,
}));
}
private handleDataOffer(offer: IProviderDataOfferPayload): void {
this.dispatchEvent(new CustomEvent('provider-data-offer', {
detail: { offer },
bubbles: true,
composed: true,
}));
}
// Public API
public retry(): void {
this.status = 'loading';
this.error = null;
if (this.iframe) {
this.iframe.src = this.providerUrl;
}
}
public sendMessage(message: IEcobridgeMessage): void {
if (!this.iframe?.contentWindow) {
console.warn('Cannot send message: iframe not ready');
return;
}
try {
const origin = new URL(this.providerUrl).origin;
this.iframe.contentWindow.postMessage(message, origin);
} catch (e) {
console.error('Failed to send message to provider:', e);
}
}
public async sendData(data: ISendDataPayload, timeoutMs = 30000): Promise<IProviderResponsePayload> {
const requestId = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error('Request timeout'));
}, timeoutMs);
this.pendingRequests.set(requestId, { resolve, reject, timeout });
this.sendMessage({
type: 'send-data',
requestId,
payload: data,
});
});
}
public async requestData(request: IRequestDataPayload, timeoutMs = 30000): Promise<IProviderResponsePayload> {
const requestId = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error('Request timeout'));
}, timeoutMs);
this.pendingRequests.set(requestId, { resolve, reject, timeout });
this.sendMessage({
type: 'request-data',
requestId,
payload: request,
});
});
}
public ping(): void {
this.sendMessage({
type: 'ping',
requestId: crypto.randomUUID(),
});
}
public hasFeature(feature: TProviderFeature): boolean {
return this.confirmedFeatures.includes(feature);
}
public getStatus(): TProviderStatus {
return this.status;
}
}

View File

@@ -0,0 +1 @@
export * from './eco-provider-frame.js';

View File

@@ -6,3 +6,4 @@ export * from './00group-applauncher/index.js';
// Standalone Components
export * from './eco-screensaver/index.js';
export * from './eco-provider-frame/index.js';

View File

@@ -0,0 +1,158 @@
/**
* Data Provider System Interfaces
*
* Data providers are external web apps that can be registered to provide
* or receive data from ecobridge applications. They communicate via
* postMessage API in sandboxed iframes.
*/
// Provider feature types - what capabilities a provider can offer
export type TProviderFeature =
| 'scan-destination' // Can receive scanned documents
| 'media-source' // Can provide media URLs for playback
| 'document-storage' // Can store/retrieve documents
| 'print-destination'; // Can receive print jobs
// Provider connection status
export type TProviderStatus = 'connected' | 'disconnected' | 'loading' | 'error';
/**
* Data Provider configuration and state
*/
export interface IDataProvider {
id: string;
name: string;
url: string;
features: TProviderFeature[]; // Features declared by provider
confirmedFeatures: TProviderFeature[]; // Features user has approved
icon?: string; // Base64 data URL or icon URL
lastSeen: Date;
status: TProviderStatus;
enabled: boolean;
}
/**
* Provider registration payload (sent by provider on ready)
*/
export interface IProviderRegistration {
name: string;
features: TProviderFeature[];
icon?: string;
version?: string;
}
/**
* Message types from Provider to Ecobridge
*/
export type TProviderToEcobridgeMessageType =
| 'provider-ready' // Provider loaded and declaring features
| 'provider-features' // Provider updating its features
| 'provider-response' // Response to an ecobridge request
| 'provider-error' // Error occurred in provider
| 'data-offer'; // Provider offering data (e.g., media URL)
/**
* Message from Provider to Ecobridge
*/
export interface IProviderMessage {
type: TProviderToEcobridgeMessageType;
providerId: string;
requestId?: string; // For responses to specific requests
payload: unknown;
}
/**
* Provider ready message payload
*/
export interface IProviderReadyPayload {
name: string;
features: TProviderFeature[];
icon?: string;
version?: string;
}
/**
* Provider response payload
*/
export interface IProviderResponsePayload {
success: boolean;
data?: unknown;
error?: string;
}
/**
* Provider data offer payload (e.g., for media-source)
*/
export interface IProviderDataOfferPayload {
type: 'media-url' | 'document' | 'file-list';
data: unknown;
metadata?: Record<string, unknown>;
}
/**
* Message types from Ecobridge to Provider
*/
export type TEcobridgeToProviderMessageType =
| 'request-features' // Ask provider to declare features
| 'send-data' // Send data to provider (e.g., scan)
| 'request-data' // Request data from provider (e.g., media URL)
| 'ping'; // Health check
/**
* Message from Ecobridge to Provider
*/
export interface IEcobridgeMessage {
type: TEcobridgeToProviderMessageType;
requestId: string;
payload?: unknown;
}
/**
* Send data payload (e.g., sending scan to provider)
*/
export interface ISendDataPayload {
dataType: 'scan' | 'document' | 'print-job';
data: string; // Base64 encoded data
format: string; // File format (pdf, jpeg, etc.)
filename?: string;
metadata?: Record<string, unknown>;
}
/**
* Request data payload (e.g., requesting media from provider)
*/
export interface IRequestDataPayload {
dataType: 'media-url' | 'document' | 'file-list';
filter?: Record<string, unknown>;
}
/**
* Feature change event - when provider's features differ from confirmed
*/
export interface IFeatureChangeRequest {
providerId: string;
providerName: string;
addedFeatures: TProviderFeature[];
removedFeatures: TProviderFeature[];
currentFeatures: TProviderFeature[];
}
/**
* Provider store schema for persistence
*/
export interface IProviderStore {
providers: IDataProvider[];
lastUpdated: Date;
}
/**
* Events dispatched by eco-provider-frame
*/
export interface IProviderFrameEvents {
'provider-ready': CustomEvent<{ provider: IDataProvider }>;
'provider-features-changed': CustomEvent<{ request: IFeatureChangeRequest }>;
'provider-response': CustomEvent<{ requestId: string; response: IProviderResponsePayload }>;
'provider-data-offer': CustomEvent<{ offer: IProviderDataOfferPayload }>;
'provider-error': CustomEvent<{ error: string }>;
'provider-disconnected': CustomEvent<{ providerId: string }>;
}

View File

@@ -3,3 +3,4 @@ export * from './appbarmenuitem.js';
export * from './menugroup.js';
export * from './appconfig.js';
export * from './secondarymenu.js';
export * from './dataprovider.js';