24 Commits

Author SHA1 Message Date
5d27406967 v3.37.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-12 15:16:01 +00:00
ee45fb01a2 feat(elements): add eco-provider-frame and dataprovider interfaces; improve virtual keyboard interactions; add demos, exports and bump dev dependencies 2026-01-12 15:16:01 +00:00
bf4fcfac71 3.36.4
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-12 14:00:36 +00:00
ab601bfd7a 3.36.3
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-12 13:54:44 +00:00
c0ef860bd6 3.36.2
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-12 13:38:08 +00:00
debf9829af v3.36.1
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-12 13:31:56 +00:00
e27141c686 fix(): no changes 2026-01-12 13:31:56 +00:00
e346d582ee fix(applauncher): call action after setting view, not instead 2026-01-12 13:31:35 +00:00
5d858d722c v3.36.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-12 12:04:13 +00:00
0e9a025d95 feat(eco-view-peripherals): encapsulate networkRanges as internal @state and add public getter/setter methods 2026-01-12 12:04:13 +00:00
aa7dcf5339 v3.35.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-12 11:47:54 +00:00
bc170d5d75 feat(peripherals): Add peripherals settings panel with network range management, network scanning, and manual device probe; update peripheral types and adjust UI/styling; overhaul README with expanded docs, quick start, and updated company/contact information 2026-01-12 11:47:54 +00:00
fb39ff161a v3.34.4
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-12 11:00:43 +00:00
2f6b9e0e5c fix(catalog): no changes (empty diff) — no files modified 2026-01-12 11:00:43 +00:00
6c0a4b3e6e v3.34.3
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-12 10:58:08 +00:00
0a11c3e65e fix(catalog): no changes detected 2026-01-12 10:58:08 +00:00
72900086cd update 2026-01-12 10:57:54 +00:00
c55cd25a88 update 2026-01-12 10:57:46 +00:00
fafa98bc19 update 2026-01-06 10:17:05 +00:00
942896a3ef v3.34.2
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-06 09:58:40 +00:00
79f41a6001 fix(applauncher): throttle inactivity timer resets in menus, optimize sound slider updates, and adjust keyboard layout/keys 2026-01-06 09:58:40 +00:00
dcc3e18474 update 2026-01-06 09:47:03 +00:00
2dbc9e35c6 v3.34.1
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-06 09:11:35 +00:00
9d07d4ca88 fix(elements/applauncher): add eco app launcher components, wifi/sound/battery menus, demos and new eco-screensaver; replace dees-screensaver (breaking API change) 2026-01-06 09:11:35 +00:00
120 changed files with 14414 additions and 5284 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -1,5 +1,68 @@
# Changelog
## 2026-01-12 - 3.37.0 - feat(elements)
add eco-provider-frame and dataprovider interfaces; improve virtual keyboard interactions; add demos, exports and bump dev dependencies
- Add new eco-provider-frame web component (iframe-based provider protocol) with messaging API (sendData, requestData, ping) and events for provider-ready, features-changed, provider-response, provider-error
- Introduce comprehensive dataprovider TypeScript interfaces (ts_web/elements/interfaces/dataprovider.ts) describing messages, payloads, provider state and store schema
- Improve virtual keyboard and launcher interactions: add tabindex, preventDefault on mousedown, stopPropagation on pointer handlers, remove key focus outline to fix touch/mouse behavior
- Add demos and new exports for provider-frame, browser and scan views/components (demo files and index exports)
- Update build/dev config: add npmextra.json tsbundle config and bump dependencies: @design.estate/dees-catalog -> ^3.34.1, @git.zone/tsbuild -> ^4.1.0, @git.zone/tsbundle -> ^2.8.1, @types/node -> ^25.0.6
## 2026-01-12 - 3.36.1 - fix()
no changes
- No files changed in this commit
- No release required
## 2026-01-12 - 3.36.0 - feat(eco-view-peripherals)
encapsulate networkRanges as internal @state and add public getter/setter methods
- Replaced @property({ type: Array }) with @state() for networkRanges to treat it as internal reactive state.
- Added setNetworkRanges(ranges: INetworkRange[]) and getNetworkRanges(): INetworkRange[] to provide a controlled public API for updating and reading network ranges.
- Methods use array spread to return/copy arrays, preventing external mutation of internal state.
- Added JSDoc comments for the new methods.
## 2026-01-12 - 3.35.0 - feat(peripherals)
Add peripherals settings panel with network range management, network scanning, and manual device probe; update peripheral types and adjust UI/styling; overhaul README with expanded docs, quick start, and updated company/contact information
- Add 'settings' peripheral category and new INetworkRange type; change IPeripheralDevice.type to exclude 'settings' to separate settings metadata from actual devices
- Implement network range management UI: add/remove ranges, display network list, scan networks (with isScanning state) and related controls
- Add "Add Device by IP" probe workflow and input UI to allow manual device discovery by IP
- Introduce new styles and header/button UI (settings-section, icon-button, network-list/network-item, etc.) for eco-view-peripherals
- Adjust categoryLabels type to exclude 'settings' to keep device grouping consistent
- Major README rewrite: expanded features list, installation examples (pnpm/npm), quick start code samples, developer commands, and updated company information to Task Venture Capital GmbH with new contact email
## 2026-01-12 - 3.34.4 - fix(catalog)
no changes (empty diff) — no files modified
- No files were modified; git diff is empty
- No dependency or source changes detected
- No version bump required
## 2026-01-12 - 3.34.3 - fix(catalog)
no changes detected
- Git diff contains no changes; no files modified
- No version bump required
## 2026-01-06 - 3.34.2 - fix(applauncher)
throttle inactivity timer resets in menus, optimize sound slider updates, and adjust keyboard layout/keys
- Add lastActivityTime and throttle resetInactivityTimer to only reset if 5+ seconds have passed in battery, sound, and wifi menus to reduce frequent resets from continuous input.
- Remove @mousemove listener in menu containers and rely on mousedown + throttled resets to lower event noise.
- Debounce slider mousemove handling in sound menu using requestAnimationFrame and pendingPercentage to batch setVolume calls and cancel RAF on mouseup, preventing excessive volume updates.
- Add up/down arrow keys to virtual keyboard, reduce space key width from 4 to 3, and add .key.wide-3 CSS class to support the new sizing.
## 2026-01-06 - 3.34.1 - fix(elements/applauncher)
add eco app launcher components, wifi/sound/battery menus, demos and new eco-screensaver; replace dees-screensaver (breaking API change)
- Add eco-applauncher group and subcomponents: eco-applauncher, eco-applauncher-wifimenu, eco-applauncher-soundmenu, eco-applauncher-batterymenu
- Add demos for applauncher, wifi menu, sound menu and battery menu; include mock data for networks/devices/apps
- Introduce eco-screensaver component and demo; remove legacy dees-screensaver implementation and export
- Update elements index and z-index key (replace 'dees-screensaver' with 'eco-screensaver')
- Menus dispatch events and include interactive behavior: wifi-toggle, network-select, volume-change, device-select, battery-saver-toggle, settings-click, menu-close
## 2026-01-06 - 3.34.0 - feat(dees-screensaver)
improve screensaver activation, visuals, and dismissal animations

View File

@@ -2,9 +2,28 @@
import * as deesWccTools from '@design.estate/dees-wcctools';
import * as deesDomTools from '@design.estate/dees-domtools';
// elements and pages
// elements, pages, and views
import * as elements from '../ts_web/elements/index.js';
import * as pages from '../ts_web/pages/index.js';
import * as views from '../ts_web/views/index.js';
deesWccTools.setupWccTools(elements as any, pages);
deesWccTools.setupWccTools({
sections: [
{
name: 'Pages',
type: 'pages',
items: pages,
},
{
name: 'Elements',
type: 'elements',
items: elements,
},
{
name: 'Views',
type: 'elements',
items: views,
},
],
});
deesDomTools.elementBasic.setup();

View File

@@ -1,39 +1,35 @@
{
"@git.zone/tsbundle": {
"bundles": [
{
"from": "ts_web/index.ts",
"to": "dist_bundle/bundle.js",
"bundler": "esbuild",
"production": true
}
]
},
"@git.zone/cli": {
"projectType": "wcc",
"module": {
"githost": "code.foss.global",
"gitscope": "design.estate",
"gitrepo": "dees-catalog",
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
"npmPackagename": "@design.estate/dees-catalog",
"gitscope": "ecobridge.xyz",
"gitrepo": "catalog",
"description": "A web component catalog for building ecobridge application interfaces with specialized components for the ecobridge ecosystem.",
"npmPackagename": "@ecobridge.xyz/catalog",
"license": "MIT",
"projectDomain": "design.estate",
"projectDomain": "ecobridge.xyz",
"keywords": [
"Web Components",
"User Interface",
"UI Library",
"Component Library",
"JavaScript",
"TypeScript",
"Dynamic Components",
"Modular Architecture",
"Reusable Components",
"Web Development",
"Application UI",
"Ecobridge",
"App Launcher",
"Desktop Interface",
"Custom Elements",
"Shadow DOM",
"UI Elements",
"Dashboard Interfaces",
"Form Handling",
"Data Display",
"Visualization",
"Charting",
"Interactive Components",
"Responsive Design",
"Web Applications",
"Modern Web",
"Frontend Development"
"LitElement"
]
},
"release": {
@@ -45,7 +41,7 @@
}
},
"@git.zone/tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Lossless GmbH. The names and logos associated with Lossless GmbH and any related products or services are trademarks of Lossless GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Lossless GmbH's Trademark Guidelines, and any usage must be approved in writing by Lossless GmbH.\n\n### Company Information\n\nLossless GmbH\n\nFor any legal inquiries or if you require further information, please contact us via the official channels.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Lossless GmbH of any derivative works.\n"
},
"@ship.zone/szci": {
"npmGlobalTools": []

View File

@@ -1,6 +1,6 @@
{
"name": "@ecobridge.xyz/catalog",
"version": "3.34.0",
"version": "3.37.0",
"private": false,
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
"main": "dist_ts_web/index.js",
@@ -15,7 +15,7 @@
"author": "Lossless GmbH",
"license": "MIT",
"dependencies": {
"@design.estate/dees-catalog": "^3.33.0",
"@design.estate/dees-catalog": "^3.34.1",
"@design.estate/dees-domtools": "^2.3.7",
"@design.estate/dees-element": "^2.1.5",
"@push.rocks/smartpromise": "^4.2.3",
@@ -23,12 +23,12 @@
},
"devDependencies": {
"@design.estate/dees-wcctools": "^3.7.1",
"@git.zone/tsbuild": "^4.0.2",
"@git.zone/tsbundle": "^2.6.3",
"@git.zone/tsbuild": "^4.1.0",
"@git.zone/tsbundle": "^2.8.1",
"@git.zone/tstest": "^3.1.4",
"@git.zone/tswatch": "^2.3.13",
"@push.rocks/projectinfo": "^5.0.2",
"@types/node": "^25.0.3"
"@types/node": "^25.0.6"
},
"files": [
"ts/**/*",

1814
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

1707
readme.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,784 +0,0 @@
# UI Components Playbook
This playbook provides comprehensive guidance for creating and maintaining UI components in the @design.estate/dees-catalog library. Follow these patterns and best practices to ensure consistency, maintainability, and quality.
## Table of Contents
1. [Component Creation Checklist](#component-creation-checklist)
2. [Architectural Patterns](#architectural-patterns)
3. [Component Types and Base Classes](#component-types-and-base-classes)
4. [Theming System](#theming-system)
5. [Event Handling](#event-handling)
6. [State Management](#state-management)
7. [Form Components](#form-components)
8. [Overlay Components](#overlay-components)
9. [Complex Components](#complex-components)
10. [Performance Optimization](#performance-optimization)
11. [Focus Management](#focus-management)
12. [Demo System](#demo-system)
13. [Common Pitfalls and Anti-patterns](#common-pitfalls-and-anti-patterns)
14. [Code Examples](#code-examples)
## Component Creation Checklist
When creating a new component, follow this checklist:
- [ ] Choose the appropriate base class (`DeesElement` or `DeesInputBase`)
- [ ] Use `@customElement('dees-componentname')` decorator
- [ ] Implement consistent theming with `cssManager.bdTheme()`
- [ ] Create demo function in separate `.demo.ts` file
- [ ] Export component from `ts_web/elements/index.ts`
- [ ] Use proper TypeScript types and interfaces (prefix with `I` for interfaces, `T` for types)
- [ ] Implement proper event handling with bubbling and composition
- [ ] Consider mobile responsiveness
- [ ] Add focus states for accessibility
- [ ] Clean up resources in `destroy()` method
- [ ] Follow lowercase naming convention for files
- [ ] Add z-index registry support if it's an overlay component
## Architectural Patterns
### Base Component Structure
```typescript
import { customElement, property, state, css, TemplateResult, html } from '@design.estate/dees-element';
import { DeesElement } from '@design.estate/dees-element';
import * as cssManager from './00colors.js';
import * as demoFunc from './dees-componentname.demo.js';
@customElement('dees-componentname')
export class DeesComponentName extends DeesElement {
// Static demo reference
public static demo = demoFunc.demoFunc;
// Public properties (reactive, can be set via attributes)
@property({ type: String })
public label: string = '';
@property({ type: Boolean, reflect: true })
public disabled: boolean = false;
// Internal state (reactive, but not exposed as attributes)
@state()
private internalState: string = '';
// Static styles with theme support
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
`
];
// Render method
public render(): TemplateResult {
return html`
<div class="main-container">
<!-- Component content -->
</div>
`;
}
// Lifecycle methods
public connectedCallback() {
super.connectedCallback();
// Setup that needs DOM access
}
public async firstUpdated() {
// One-time initialization after first render
}
// Cleanup
public destroy() {
// Clean up listeners, observers, registrations
super.destroy();
}
}
```
### Advanced Patterns
#### 1. Separation of Concerns (Complex Components)
For complex components like WYSIWYG editors, separate concerns into handler classes:
```typescript
export class DeesComplexComponent extends DeesElement {
// Orchestrator pattern - main component coordinates handlers
private inputHandler: InputHandler;
private stateHandler: StateHandler;
private renderHandler: RenderHandler;
constructor() {
super();
this.inputHandler = new InputHandler(this);
this.stateHandler = new StateHandler(this);
this.renderHandler = new RenderHandler(this);
}
}
```
#### 2. Singleton Pattern (Global Components)
For global UI elements like menus:
```typescript
export class DeesGlobalMenu extends DeesElement {
private static instance: DeesGlobalMenu;
public static getInstance(): DeesGlobalMenu {
if (!DeesGlobalMenu.instance) {
DeesGlobalMenu.instance = new DeesGlobalMenu();
document.body.appendChild(DeesGlobalMenu.instance);
}
return DeesGlobalMenu.instance;
}
}
```
#### 3. Registry Pattern (Z-Index Management)
Use centralized registries for global state:
```typescript
class ComponentRegistry {
private static instance: ComponentRegistry;
private registry = new WeakMap<HTMLElement, number>();
public register(element: HTMLElement, value: number) {
this.registry.set(element, value);
}
public unregister(element: HTMLElement) {
this.registry.delete(element);
}
}
```
## Component Types and Base Classes
### Standard Component (extends DeesElement)
Use for most UI components:
- Buttons, badges, icons
- Layout components
- Data display components
- Overlay components
### Form Input Component (extends DeesInputBase)
Use for all form inputs:
- Text inputs, dropdowns, checkboxes
- Date pickers, file uploads
- Rich text editors
**Required implementations:**
```typescript
export class DeesInputCustom extends DeesInputBase<ValueType> {
// Required: Get current value
public getValue(): ValueType {
return this.value;
}
// Required: Set value programmatically
public setValue(value: ValueType): void {
this.value = value;
this.changeSubject.next(this); // Notify form
}
// Optional: Custom validation
public async validate(): Promise<boolean> {
// Custom validation logic
return true;
}
}
```
## Theming System
### DO: Use Theme Functions
Always use `cssManager.bdTheme()` for colors that change between themes:
```typescript
// ✅ CORRECT
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333333')};
// ❌ INCORRECT
background: #ffffff; // Hard-coded color
color: var(--custom-color); // Custom CSS variable
```
### DO: Use Consistent Color Values
Reference shared color constants when possible:
```typescript
// From 00colors.ts
background: ${cssManager.bdTheme(colors.bright.background, colors.dark.background)};
```
## Event Handling
### DO: Dispatch Custom Events Properly
```typescript
// ✅ CORRECT - Events bubble and cross shadow DOM
this.dispatchEvent(new CustomEvent('dees-componentname-change', {
detail: { value: this.value },
bubbles: true,
composed: true
}));
// ❌ INCORRECT - Event won't propagate properly
this.dispatchEvent(new CustomEvent('change', {
detail: { value: this.value }
// Missing bubbles and composed
}));
```
### DO: Use Event Delegation
For dynamic content, use event delegation:
```typescript
// ✅ CORRECT - Single listener for all items
this.addEventListener('click', (e: MouseEvent) => {
const item = (e.target as HTMLElement).closest('.item');
if (item) {
this.handleItemClick(item);
}
});
// ❌ INCORRECT - Multiple listeners
this.items.forEach(item => {
item.addEventListener('click', () => this.handleItemClick(item));
});
```
## State Management
### DO: Use Appropriate Property Decorators
```typescript
// Public API - use @property
@property({ type: String })
public label: string;
// Internal state - use @state
@state()
private isLoading: boolean = false;
// Reflect to attribute when needed
@property({ type: Boolean, reflect: true })
public disabled: boolean = false;
```
### DON'T: Manipulate State in Render
```typescript
// ❌ INCORRECT - Side effects in render
public render() {
this.counter++; // Don't modify state
return html`<div>${this.counter}</div>`;
}
// ✅ CORRECT - Pure render function
public render() {
return html`<div>${this.counter}</div>`;
}
```
## Form Components
### DO: Extend DeesInputBase
All form inputs must extend the base class:
```typescript
export class DeesInputNew extends DeesInputBase<string> {
// Inherits: key, label, value, required, disabled, validationState
}
```
### DO: Emit Changes Consistently
```typescript
private handleInput(e: Event) {
this.value = (e.target as HTMLInputElement).value;
this.changeSubject.next(this); // Notify form system
}
```
### DO: Support Standard Form Properties
```typescript
// All form inputs should support:
@property() public key: string;
@property() public label: string;
@property() public required: boolean = false;
@property() public disabled: boolean = false;
@property() public validationState: 'valid' | 'warn' | 'invalid';
```
## Overlay Components
### DO: Use Z-Index Registry
Never hardcode z-index values:
```typescript
// ✅ CORRECT
import { zIndexRegistry } from './00zindex.js';
public async show() {
this.modalZIndex = zIndexRegistry.getNextZIndex();
zIndexRegistry.register(this, this.modalZIndex);
this.style.zIndex = `${this.modalZIndex}`;
}
public async hide() {
zIndexRegistry.unregister(this);
}
// ❌ INCORRECT
public async show() {
this.style.zIndex = '9999'; // Hardcoded z-index
}
```
### DO: Use Window Layers
For modal backdrops:
```typescript
import { DeesWindowLayer } from './dees-windowlayer.js';
private windowLayer: DeesWindowLayer;
public async show() {
this.windowLayer = new DeesWindowLayer();
this.windowLayer.zIndex = zIndexRegistry.getNextZIndex();
document.body.append(this.windowLayer);
}
```
## Complex Components
### DO: Use Handler Classes
For complex logic, separate into specialized handlers:
```typescript
// wysiwyg/handlers/input.handler.ts
export class InputHandler {
constructor(private component: DeesInputWysiwyg) {}
public handleInput(event: InputEvent) {
// Specialized input handling
}
}
// Main component orchestrates
export class DeesInputWysiwyg extends DeesInputBase {
private inputHandler = new InputHandler(this);
}
```
### DO: Use Programmatic Rendering
For performance-critical updates that shouldn't trigger re-renders:
```typescript
// ✅ CORRECT - Direct DOM manipulation when needed
private updateBlockContent(blockId: string, content: string) {
const blockElement = this.shadowRoot.querySelector(`#${blockId}`);
if (blockElement) {
blockElement.textContent = content; // Direct update
}
}
// ❌ INCORRECT - Triggering full re-render
private updateBlockContent(blockId: string, content: string) {
this.blocks.find(b => b.id === blockId).content = content;
this.requestUpdate(); // Unnecessary re-render
}
```
## Performance Optimization
### DO: Debounce Expensive Operations
```typescript
private resizeTimeout: number;
private handleResize = () => {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = window.setTimeout(() => {
this.updateLayout();
}, 250);
};
```
### DO: Use Observers Efficiently
```typescript
// Clean up observers
public disconnectedCallback() {
super.disconnectedCallback();
this.resizeObserver?.disconnect();
this.mutationObserver?.disconnect();
}
```
### DO: Implement Virtual Scrolling
For large lists:
```typescript
// Only render visible items
private getVisibleItems() {
const scrollTop = this.scrollContainer.scrollTop;
const containerHeight = this.scrollContainer.clientHeight;
const itemHeight = 50;
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
return this.items.slice(startIndex, endIndex);
}
```
## Focus Management
### DO: Handle Focus Timing
```typescript
// ✅ CORRECT - Wait for render
async focusInput() {
await this.updateComplete;
await new Promise(resolve => requestAnimationFrame(resolve));
this.inputElement?.focus();
}
// ❌ INCORRECT - Focus too early
focusInput() {
this.inputElement?.focus(); // Element might not exist
}
```
### DO: Prevent Focus Loss
```typescript
// For global menus
constructor() {
super();
// Prevent focus loss when clicking menu
this.addEventListener('mousedown', (e) => {
e.preventDefault();
});
}
```
### DO: Implement Blur Debouncing
```typescript
private blurTimeout: number;
private handleBlur = () => {
clearTimeout(this.blurTimeout);
this.blurTimeout = window.setTimeout(() => {
// Check if truly blurred
if (!this.contains(document.activeElement)) {
this.handleTrueBlur();
}
}, 100);
};
```
## Demo System
### DO: Create Comprehensive Demos
Every component needs a demo:
```typescript
// dees-button.demo.ts
import { html } from '@design.estate/dees-element';
export const demoFunc = () => html`
<dees-button>Default Button</dees-button>
<dees-button type="primary">Primary Button</dees-button>
<dees-button type="danger" disabled>Disabled Danger</dees-button>
`;
// In component file
import * as demoFunc from './dees-button.demo.js';
export class DeesButton extends DeesElement {
public static demo = demoFunc.demoFunc;
}
```
### DO: Include All Variants
Show all component states and variations in demos:
- Default state
- Different types/variants
- Disabled state
- Loading state
- Error states
- Edge cases (long text, empty content)
## Common Pitfalls and Anti-patterns
### ❌ DON'T: Hardcode Z-Index Values
```typescript
// ❌ WRONG
this.style.zIndex = '9999';
// ✅ CORRECT
this.style.zIndex = `${zIndexRegistry.getNextZIndex()}`;
```
### ❌ DON'T: Skip Base Classes
```typescript
// ❌ WRONG - Form input without base class
export class DeesInputCustom extends DeesElement {
// Missing standard form functionality
}
// ✅ CORRECT
export class DeesInputCustom extends DeesInputBase<string> {
// Inherits all form functionality
}
```
### ❌ DON'T: Forget Theme Support
```typescript
// ❌ WRONG
background-color: #ffffff;
color: #000000;
// ✅ CORRECT
background-color: ${cssManager.bdTheme('#ffffff', '#09090b')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
```
### ❌ DON'T: Create Components Without Demos
```typescript
// ❌ WRONG
export class DeesComponent extends DeesElement {
// No demo property
}
// ✅ CORRECT
export class DeesComponent extends DeesElement {
public static demo = demoFunc.demoFunc;
}
```
### ❌ DON'T: Emit Non-Bubbling Events
```typescript
// ❌ WRONG
this.dispatchEvent(new CustomEvent('change', {
detail: this.value
}));
// ✅ CORRECT
this.dispatchEvent(new CustomEvent('change', {
detail: this.value,
bubbles: true,
composed: true
}));
```
### ❌ DON'T: Skip Cleanup
```typescript
// ❌ WRONG
public connectedCallback() {
window.addEventListener('resize', this.handleResize);
}
// ✅ CORRECT
public connectedCallback() {
super.connectedCallback();
window.addEventListener('resize', this.handleResize);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('resize', this.handleResize);
}
```
### ❌ DON'T: Use Inline Styles for Theming
```typescript
// ❌ WRONG
<div style="background-color: ${this.darkMode ? '#000' : '#fff'}">
// ✅ CORRECT
<div class="themed-container">
// In styles:
.themed-container {
background-color: ${cssManager.bdTheme('#ffffff', '#000000')};
}
```
### ❌ DON'T: Forget Mobile Responsiveness
```typescript
// ❌ WRONG
:host {
width: 800px; // Fixed width
}
// ✅ CORRECT
:host {
width: 100%;
max-width: 800px;
}
@media (max-width: 768px) {
:host {
/* Mobile adjustments */
}
}
```
## Code Examples
### Example: Creating a New Button Variant
```typescript
// dees-special-button.ts
import { customElement, property, css, html } from '@design.estate/dees-element';
import { DeesElement } from '@design.estate/dees-element';
import * as cssManager from './00colors.js';
import * as demoFunc from './dees-special-button.demo.js';
@customElement('dees-special-button')
export class DeesSpecialButton extends DeesElement {
public static demo = demoFunc.demoFunc;
@property({ type: String })
public text: string = 'Click me';
@property({ type: Boolean, reflect: true })
public loading: boolean = false;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: inline-block;
}
.button {
padding: 8px 16px;
background: ${cssManager.bdTheme('#0066ff', '#0044cc')};
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
}
:host([loading]) .button {
opacity: 0.7;
cursor: not-allowed;
}
`
];
public render() {
return html`
<button class="button" ?disabled=${this.loading} @click=${this.handleClick}>
${this.loading ? html`<dees-spinner size="small"></dees-spinner>` : this.text}
</button>
`;
}
private handleClick() {
this.dispatchEvent(new CustomEvent('special-click', {
bubbles: true,
composed: true
}));
}
}
```
### Example: Creating a Form Input
```typescript
// dees-input-special.ts
export class DeesInputSpecial extends DeesInputBase<string> {
public static demo = demoFunc.demoFunc;
public render() {
return html`
<dees-label .label=${this.label} .required=${this.required}>
<input
type="text"
.value=${this.value || ''}
?disabled=${this.disabled}
@input=${this.handleInput}
@blur=${this.handleBlur}
/>
</dees-label>
`;
}
private handleInput(e: Event) {
this.value = (e.target as HTMLInputElement).value;
this.changeSubject.next(this);
}
private handleBlur() {
this.dispatchEvent(new CustomEvent('blur', {
bubbles: true,
composed: true
}));
}
public getValue(): string {
return this.value;
}
public setValue(value: string): void {
this.value = value;
this.changeSubject.next(this);
}
}
```
## Summary
This playbook represents the collective wisdom and patterns found in the @design.estate/dees-catalog component library. Following these guidelines will help you create components that are:
- **Consistent**: Following established patterns
- **Maintainable**: Easy to understand and modify
- **Performant**: Optimized for real-world use
- **Accessible**: Usable by everyone
- **Theme-aware**: Supporting light and dark modes
- **Well-integrated**: Working seamlessly with the component ecosystem
Remember: When in doubt, look at existing components for examples. The codebase itself is the best documentation of these patterns in action.

View File

@@ -1,12 +0,0 @@
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import * as deesCatalog from '../ts_web/index.js';
tap.test('should create a working button', async () => {
const button: deesCatalog.DeesButton = await webhelpers.fixture(
webhelpers.html`<dees-button></dees-button>`
);
expect(button).toBeInstanceOf(deesCatalog.DeesButton);
});
export default tap.start();

View File

@@ -1,35 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu/dees-contextmenu.js';
import { demoFunc } from '../ts_web/elements/dees-contextmenu/dees-contextmenu.demo.js';
tap.test('should render context menu demo', async () => {
// Create demo container
const demoContainer = document.createElement('div');
document.body.appendChild(demoContainer);
// Render the demo
const demoContent = demoFunc();
// Create a temporary element to hold the rendered template
const tempDiv = document.createElement('div');
tempDiv.innerHTML = demoContent.strings.join('');
// Check that panels are rendered
const panels = tempDiv.querySelectorAll('dees-panel');
expect(panels.length).toEqual(4);
// Check panel headings
expect(panels[0].getAttribute('heading')).toEqual('Basic Context Menu with Nested Submenus');
expect(panels[1].getAttribute('heading')).toEqual('Component-Specific Context Menu');
expect(panels[2].getAttribute('heading')).toEqual('Advanced Context Menu Example');
expect(panels[3].getAttribute('heading')).toEqual('Static Context Menu (Always Visible)');
// Check that static context menu exists
const staticMenu = tempDiv.querySelector('dees-contextmenu');
expect(staticMenu).toBeTruthy();
// Clean up
demoContainer.remove();
});
export default tap.start();

View File

@@ -1,93 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu/dees-contextmenu.js';
tap.test('should close all parent menus when clicking action in nested submenu', async () => {
let actionCalled = false;
// Create a test element
const testDiv = document.createElement('div');
testDiv.style.width = '300px';
testDiv.style.height = '300px';
testDiv.style.background = '#f0f0f0';
testDiv.innerHTML = 'Right-click for nested menu test';
document.body.appendChild(testDiv);
// Simulate right-click to open context menu
const contextMenuEvent = new MouseEvent('contextmenu', {
clientX: 150,
clientY: 150,
bubbles: true,
cancelable: true
});
// Open context menu with nested structure
DeesContextmenu.openContextMenuWithOptions(contextMenuEvent, [
{
name: 'Parent Item',
iconName: 'folder',
action: async () => {}, // Parent items with submenus need an action
submenu: [
{
name: 'Child Item',
iconName: 'file',
action: async () => {
actionCalled = true;
console.log('Child action called');
}
},
{
name: 'Another Child',
iconName: 'fileText',
action: async () => console.log('Another child')
}
]
},
{
name: 'Regular Item',
iconName: 'box',
action: async () => console.log('Regular item')
}
]);
// Wait for main menu to appear
await new Promise(resolve => setTimeout(resolve, 150));
// Check main menu exists
const mainMenu = document.querySelector('dees-contextmenu');
expect(mainMenu).toBeInstanceOf(DeesContextmenu);
// Hover over "Parent Item" to trigger submenu
const parentItem = mainMenu!.shadowRoot!.querySelector('.menuitem');
expect(parentItem).toBeTruthy();
parentItem!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
// Wait for submenu to appear
await new Promise(resolve => setTimeout(resolve, 300));
// Check submenu exists
const allMenus = document.querySelectorAll('dees-contextmenu');
expect(allMenus.length).toEqual(2); // Main menu and submenu
const submenu = allMenus[1];
expect(submenu).toBeTruthy();
// Click on "Child Item" in submenu
const childItem = submenu.shadowRoot!.querySelector('.menuitem');
expect(childItem).toBeTruthy();
childItem!.click();
// Wait for menus to close (windowLayer destruction takes 300ms + context menu 100ms)
await new Promise(resolve => setTimeout(resolve, 600));
// Verify action was called
expect(actionCalled).toEqual(true);
// Verify all menus are closed
const remainingMenus = document.querySelectorAll('dees-contextmenu');
expect(remainingMenus.length).toEqual(0);
// Clean up
testDiv.remove();
});
export default tap.start();

View File

@@ -1,71 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu/dees-contextmenu.js';
import { DeesElement, customElement, html } from '@design.estate/dees-element';
// Create a test element with shadow DOM
@customElement('test-shadow-element')
class TestShadowElement extends DeesElement {
public getContextMenuItems() {
return [
{ name: 'Shadow Item 1', iconName: 'box', action: async () => console.log('Shadow 1') },
{ name: 'Shadow Item 2', iconName: 'package', action: async () => console.log('Shadow 2') }
];
}
render() {
return html`
<div style="padding: 40px; background: #eee; border-radius: 8px;">
<h3>Shadow DOM Content</h3>
<p>Right-click anywhere inside this shadow DOM</p>
</div>
`;
}
}
tap.test('should show context menu when right-clicking inside shadow DOM', async () => {
// Create the shadow DOM element
const shadowElement = document.createElement('test-shadow-element');
document.body.appendChild(shadowElement);
// Wait for element to be ready
await shadowElement.updateComplete;
// Get the content inside shadow DOM
const shadowContent = shadowElement.shadowRoot!.querySelector('div');
expect(shadowContent).toBeTruthy();
// Simulate right-click on content inside shadow DOM
const contextMenuEvent = new MouseEvent('contextmenu', {
clientX: 100,
clientY: 100,
bubbles: true,
cancelable: true,
composed: true // Important for shadow DOM
});
shadowContent!.dispatchEvent(contextMenuEvent);
// Wait for context menu to appear
await new Promise(resolve => setTimeout(resolve, 100));
// Check if context menu is created
const contextMenu = document.querySelector('dees-contextmenu');
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
// Check if menu items from shadow element are rendered
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
expect(menuItems.length).toBeGreaterThanOrEqual(2);
// Check menu item text
const menuTexts = Array.from(menuItems).map(item =>
item.querySelector('.menuitem-text')?.textContent
);
expect(menuTexts).toContain('Shadow Item 1');
expect(menuTexts).toContain('Shadow Item 2');
// Clean up
contextMenu!.remove();
shadowElement.remove();
});
export default tap.start();

View File

@@ -1,77 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu/dees-contextmenu.js';
tap.test('should show context menu with nested submenu', async () => {
// Create a test element with context menu items
const testDiv = document.createElement('div');
testDiv.style.width = '200px';
testDiv.style.height = '200px';
testDiv.style.background = '#eee';
testDiv.innerHTML = 'Right-click me';
// Add getContextMenuItems method
(testDiv as any).getContextMenuItems = () => {
return [
{
name: 'Change Type',
iconName: 'type',
submenu: [
{ name: 'Paragraph', iconName: 'text', action: () => console.log('Paragraph') },
{ name: 'Heading 1', iconName: 'heading1', action: () => console.log('Heading 1') },
{ name: 'Heading 2', iconName: 'heading2', action: () => console.log('Heading 2') },
{ divider: true },
{ name: 'Code Block', iconName: 'fileCode', action: () => console.log('Code') },
{ name: 'Quote', iconName: 'quote', action: () => console.log('Quote') }
]
},
{ divider: true },
{
name: 'Delete',
iconName: 'trash2',
action: () => console.log('Delete')
}
];
};
document.body.appendChild(testDiv);
// Simulate right-click
const contextMenuEvent = new MouseEvent('contextmenu', {
clientX: 100,
clientY: 100,
bubbles: true,
cancelable: true
});
testDiv.dispatchEvent(contextMenuEvent);
// Wait for context menu to appear
await new Promise(resolve => setTimeout(resolve, 100));
// Check if context menu is created
const contextMenu = document.querySelector('dees-contextmenu');
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
// Check if menu items are rendered
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
expect(menuItems.length).toEqual(2); // "Change Type" and "Delete"
// Hover over "Change Type" to trigger submenu
const changeTypeItem = menuItems[0] as HTMLElement;
changeTypeItem.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
// Wait for submenu to appear
await new Promise(resolve => setTimeout(resolve, 300));
// Check if submenu is created
const submenus = document.querySelectorAll('dees-contextmenu');
expect(submenus.length).toEqual(2); // Main menu and submenu
// Clean up
contextMenu!.remove();
const submenu = submenus[1];
if (submenu) submenu.remove();
testDiv.remove();
});
export default tap.start();

View File

@@ -1,28 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
resolveWidgetPlacement,
collectCollisions,
} from '../ts_web/elements/dees-dashboardgrid/layout.ts';
import type { DashboardWidget } from '../ts_web/elements/dees-dashboardgrid/types.ts';
tap.test('dashboardgrid does not overlap widgets after swap attempt', async () => {
const widgets: DashboardWidget[] = [
{ id: 'w0', x: 6, y: 5, w: 1, h: 3 },
{ id: 'w1', x: 6, y: 1, w: 1, h: 3 },
{ id: 'w2', x: 3, y: 0, w: 2, h: 2 },
{ id: 'w3', x: 9, y: 0, w: 1, h: 2 },
{ id: 'w4', x: 4, y: 3, w: 1, h: 2 },
];
const placement = resolveWidgetPlacement(widgets, 'w0', { x: 6, y: 3 }, 12);
expect(placement).toBeTruthy();
const layout = placement!.widgets;
for (const widget of layout) {
const collisions = collectCollisions(layout, widget, widget.x, widget.y, widget.w, widget.h);
expect(collisions).toBeEmptyArray();
}
});
export default tap.start();

View File

@@ -0,0 +1,35 @@
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import * as ecoCatalog from '../ts_web/index.js';
tap.test('should export EcoApplauncher component', async () => {
expect(ecoCatalog.EcoApplauncher).toBeTypeOf('function');
});
tap.test('should export EcoScreensaver component', async () => {
expect(ecoCatalog.EcoScreensaver).toBeTypeOf('function');
});
tap.test('should export applauncher sub-components', async () => {
expect(ecoCatalog.EcoApplauncherWifimenu).toBeTypeOf('function');
expect(ecoCatalog.EcoApplauncherBatterymenu).toBeTypeOf('function');
expect(ecoCatalog.EcoApplauncherSoundmenu).toBeTypeOf('function');
expect(ecoCatalog.EcoApplauncherKeyboard).toBeTypeOf('function');
expect(ecoCatalog.EcoApplauncherPowermenu).toBeTypeOf('function');
});
tap.test('should create a working EcoApplauncher instance', async () => {
const applauncher: ecoCatalog.EcoApplauncher = await webhelpers.fixture(
webhelpers.html`<eco-applauncher></eco-applauncher>`
);
expect(applauncher).toBeInstanceOf(ecoCatalog.EcoApplauncher);
});
tap.test('should create a working EcoScreensaver instance', async () => {
const screensaver: ecoCatalog.EcoScreensaver = await webhelpers.fixture(
webhelpers.html`<eco-screensaver></eco-screensaver>`
);
expect(screensaver).toBeInstanceOf(ecoCatalog.EcoScreensaver);
});
export default tap.start();

View File

@@ -1,183 +0,0 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesWysiwygBlock } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-wysiwyg-block.js';
import { WysiwygSelection } from '../ts_web/elements/00group-input/dees-input-wysiwyg/wysiwyg.selection.js';
tap.test('Shadow DOM containment should work correctly', async () => {
console.log('=== Testing Shadow DOM Containment ===');
// Wait for custom element to be defined
await customElements.whenDefined('dees-wysiwyg-block');
// Create a WYSIWYG block component - set properties BEFORE attaching to DOM
const block = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
// Set the block data before attaching to DOM so firstUpdated() sees them
block.block = {
id: 'test-1',
type: 'paragraph',
content: 'Hello world test content'
};
block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
// Now attach to DOM and wait for render
document.body.appendChild(block);
await block.updateComplete;
// Wait for firstUpdated to populate the container
await new Promise(resolve => setTimeout(resolve, 50));
// Get the paragraph element inside Shadow DOM
const container = block.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
expect(paragraphBlock).toBeTruthy();
console.log('Found paragraph block:', paragraphBlock);
console.log('Paragraph text content:', paragraphBlock.textContent);
// Focus the paragraph
paragraphBlock.focus();
// Manually set cursor position
const textNode = paragraphBlock.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
// Set cursor at position 11 (after "Hello world")
range.setStart(textNode, 11);
range.setEnd(textNode, 11);
selection?.removeAllRanges();
selection?.addRange(range);
console.log('Set cursor at position 11');
// Test the containment check
console.log('\n--- Testing containment ---');
const currentSelection = window.getSelection();
if (currentSelection && currentSelection.rangeCount > 0) {
const selRange = currentSelection.getRangeAt(0);
console.log('Selection range:', {
startContainer: selRange.startContainer,
startOffset: selRange.startOffset,
containerText: selRange.startContainer.textContent
});
// Test regular contains (should fail across Shadow DOM)
const regularContains = paragraphBlock.contains(selRange.startContainer);
console.log('Regular contains:', regularContains);
// Test Shadow DOM-aware contains
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selRange.startContainer);
console.log('Shadow DOM contains:', shadowDOMContains);
// Since we're setting selection within the same shadow DOM, both should be true
expect(regularContains).toBeTrue();
expect(shadowDOMContains).toBeTrue();
}
// Test getSplitContent
console.log('\n--- Testing getSplitContent ---');
const splitResult = block.getSplitContent();
console.log('Split result:', splitResult);
expect(splitResult).toBeTruthy();
if (splitResult) {
console.log('Before:', JSON.stringify(splitResult.before));
console.log('After:', JSON.stringify(splitResult.after));
// Expected split at position 11
expect(splitResult.before).toEqual('Hello world');
expect(splitResult.after).toEqual(' test content');
}
}
// Clean up
document.body.removeChild(block);
});
tap.test('Shadow DOM containment across different shadow roots', async () => {
console.log('=== Testing Cross Shadow Root Containment ===');
// Create parent component with WYSIWYG editor
const parentDiv = document.createElement('div');
parentDiv.innerHTML = `
<dees-input-wysiwyg>
<dees-wysiwyg-block></dees-wysiwyg-block>
</dees-input-wysiwyg>
`;
document.body.appendChild(parentDiv);
// Wait for components to be ready
await new Promise(resolve => setTimeout(resolve, 100));
const wysiwygInput = parentDiv.querySelector('dees-input-wysiwyg') as any;
const blockElement = wysiwygInput?.shadowRoot?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
if (blockElement) {
// Set block data
blockElement.block = {
id: 'test-2',
type: 'paragraph',
content: 'Cross shadow DOM test'
};
blockElement.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
await blockElement.updateComplete;
// Get the paragraph inside the nested shadow DOM
const container = blockElement.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
if (paragraphBlock) {
console.log('Found nested paragraph block');
// Focus and set selection
paragraphBlock.focus();
const textNode = paragraphBlock.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
range.setStart(textNode, 5);
range.setEnd(textNode, 5);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
// Test containment from parent's perspective
const selRange = selection?.getRangeAt(0);
if (selRange) {
// This should fail because it crosses shadow DOM boundary
const regularContains = wysiwygInput.contains(selRange.startContainer);
console.log('Parent regular contains:', regularContains);
expect(regularContains).toBeFalse();
// This should work with our Shadow DOM-aware method
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(wysiwygInput, selRange.startContainer);
console.log('Parent shadow DOM contains:', shadowDOMContains);
expect(shadowDOMContains).toBeTrue();
}
}
}
}
// Clean up
document.body.removeChild(parentDiv);
});
export default tap.start();

View File

@@ -1,146 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as deesCatalog from '../ts_web/index.js';
tap.test('tabs indicator positioning - detailed measurements', async () => {
// Create tabs element with different length labels
const tabsElement = new deesCatalog.DeesAppuiTabs();
tabsElement.tabs = [
{ key: 'Home', iconName: 'lucide:home', action: () => {} },
{ key: 'Analytics Dashboard', iconName: 'lucide:lineChart', action: () => {} },
{ key: 'User Settings', iconName: 'lucide:settings', action: () => {} },
];
document.body.appendChild(tabsElement);
await tabsElement.updateComplete;
// Wait for fonts and indicator initialization
await new Promise(resolve => setTimeout(resolve, 200));
// Get all elements
const shadowRoot = tabsElement.shadowRoot;
const wrapper = shadowRoot.querySelector('.tabs-wrapper') as HTMLElement;
const container = shadowRoot.querySelector('.tabsContainer') as HTMLElement;
const tabs = shadowRoot.querySelectorAll('.tab');
const firstTab = tabs[0] as HTMLElement;
const firstContent = firstTab.querySelector('.tab-content') as HTMLElement;
const indicator = shadowRoot.querySelector('.tabIndicator') as HTMLElement;
// Verify all elements exist
expect(wrapper).toBeInstanceOf(HTMLElement);
expect(container).toBeInstanceOf(HTMLElement);
expect(firstTab).toBeInstanceOf(HTMLElement);
expect(firstContent).toBeInstanceOf(HTMLElement);
expect(indicator).toBeInstanceOf(HTMLElement);
// Get all measurements
const wrapperRect = wrapper.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const tabRect = firstTab.getBoundingClientRect();
const contentRect = firstContent.getBoundingClientRect();
const indicatorRect = indicator.getBoundingClientRect();
console.log('\n=== DETAILED MEASUREMENTS ===');
console.log('Document body left:', document.body.getBoundingClientRect().left);
console.log('Wrapper left:', wrapperRect.left);
console.log('Container left:', containerRect.left);
console.log('Tab left:', tabRect.left);
console.log('Content left:', contentRect.left);
console.log('Indicator left (actual):', indicatorRect.left);
console.log('\n=== RELATIVE POSITIONS ===');
console.log('Container padding (container - wrapper):', containerRect.left - wrapperRect.left);
console.log('Tab position in container:', tabRect.left - containerRect.left);
console.log('Content position in tab:', contentRect.left - tabRect.left);
console.log('Content relative to wrapper:', contentRect.left - wrapperRect.left);
console.log('Indicator relative to wrapper (actual):', indicatorRect.left - wrapperRect.left);
console.log('\n=== WIDTHS ===');
console.log('Tab width:', tabRect.width);
console.log('Content width:', contentRect.width);
console.log('Indicator width:', indicatorRect.width);
console.log('\n=== STYLES (what we set) ===');
console.log('Indicator style.left:', indicator.style.left);
console.log('Indicator style.width:', indicator.style.width);
console.log('\n=== CALCULATIONS ===');
const expectedIndicatorLeft = contentRect.left - wrapperRect.left - 4; // We subtract 4 to center
const expectedIndicatorWidth = contentRect.width + 8; // We add 8 in the code
console.log('Expected indicator left:', expectedIndicatorLeft);
console.log('Expected indicator width:', expectedIndicatorWidth);
console.log('Actual indicator left (from style):', parseFloat(indicator.style.left));
console.log('Actual indicator width (from style):', parseFloat(indicator.style.width));
console.log('\n=== VISUAL ALIGNMENT CHECK ===');
const tabCenter = tabRect.left + (tabRect.width / 2);
const contentCenter = contentRect.left + (contentRect.width / 2);
const indicatorCenter = indicatorRect.left + (indicatorRect.width / 2);
console.log('Tab center:', tabCenter);
console.log('Content center:', contentCenter);
console.log('Indicator center:', indicatorCenter);
console.log('Content offset from tab center:', contentCenter - tabCenter);
console.log('Indicator offset from content center:', indicatorCenter - contentCenter);
console.log('Indicator offset from tab center:', indicatorCenter - tabCenter);
console.log('---');
console.log('Indicator extends left of content by:', contentRect.left - indicatorRect.left);
console.log('Indicator extends right of content by:', (indicatorRect.left + indicatorRect.width) - (contentRect.left + contentRect.width));
// Check if icons are rendering
const icon = firstContent.querySelector('dees-icon');
console.log('\n=== ICON CHECK ===');
console.log('Icon element found:', icon ? 'YES' : 'NO');
if (icon) {
const iconRect = icon.getBoundingClientRect();
console.log('Icon width:', iconRect.width);
console.log('Icon height:', iconRect.height);
console.log('Icon visible:', iconRect.width > 0 && iconRect.height > 0 ? 'YES' : 'NO');
}
// Verify indicator is visible
expect(indicator.style.opacity).toEqual('1');
// Verify positioning calculations
expect(parseFloat(indicator.style.left)).toBeCloseTo(expectedIndicatorLeft, 1);
expect(parseFloat(indicator.style.width)).toBeCloseTo(expectedIndicatorWidth, 1);
// Verify visual centering on content (should be perfectly centered)
expect(Math.abs(indicatorCenter - contentCenter)).toBeLessThan(1);
document.body.removeChild(tabsElement);
});
tap.test('tabs indicator should move when tab is clicked', async () => {
// Create tabs element
const tabsElement = new deesCatalog.DeesAppuiTabs();
tabsElement.tabs = [
{ key: 'Home', iconName: 'lucide:home', action: () => {} },
{ key: 'Analytics', iconName: 'lucide:barChart', action: () => {} },
{ key: 'Settings', iconName: 'lucide:settings', action: () => {} },
];
document.body.appendChild(tabsElement);
await tabsElement.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
const shadowRoot = tabsElement.shadowRoot;
const tabs = shadowRoot.querySelectorAll('.tab');
const indicator = shadowRoot.querySelector('.tabIndicator') as HTMLElement;
// Get initial position
const initialLeft = parseFloat(indicator.style.left);
// Click second tab
(tabs[1] as HTMLElement).click();
await tabsElement.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Position should have changed
const newLeft = parseFloat(indicator.style.left);
expect(newLeft).not.toEqual(initialLeft);
expect(newLeft).toBeGreaterThan(initialLeft);
document.body.removeChild(tabsElement);
});
export default tap.start();

View File

@@ -1,9 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
tap.test('should create wysiwyg editor', async () => {
const editor = new DeesInputWysiwyg();
expect(editor).toBeInstanceOf(DeesInputWysiwyg);
});
export default tap.start();

View File

@@ -1,85 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg block movement during drag', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
// Start dragging block 1
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: () => {},
setDragImage: () => {}
},
clientY: 50,
preventDefault: () => {},
} as any;
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
// Wait for dragging class
await new Promise(resolve => setTimeout(resolve, 20));
// Verify drag state
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
// Check that drag height was calculated
console.log('Checking drag height...');
const dragHandler = element.dragDropHandler as any;
console.log('draggedBlockHeight:', dragHandler.draggedBlockHeight);
console.log('draggedBlockContentHeight:', dragHandler.draggedBlockContentHeight);
// Manually call updateBlockPositions to simulate drag movement
console.log('Simulating drag movement...');
const updateBlockPositions = dragHandler.updateBlockPositions.bind(dragHandler);
// Simulate dragging down past block 2
const block2 = editorContent.querySelector('[data-block-id="block2"]') as HTMLElement;
const block2Rect = block2.getBoundingClientRect();
const dragToY = block2Rect.bottom + 10;
console.log('Dragging to Y position:', dragToY);
updateBlockPositions(dragToY);
// Check if blocks have moved
await new Promise(resolve => setTimeout(resolve, 50));
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
console.log('Block states after drag:');
blocks.forEach((block, i) => {
const classes = block.className;
const offset = (block as HTMLElement).style.getPropertyValue('--drag-offset');
console.log(`Block ${i}: classes="${classes}", offset="${offset}"`);
});
// Check that at least one block has move class
const movedUpBlocks = editorContent.querySelectorAll('.block-wrapper.move-up');
const movedDownBlocks = editorContent.querySelectorAll('.block-wrapper.move-down');
console.log('Moved up blocks:', movedUpBlocks.length);
console.log('Moved down blocks:', movedDownBlocks.length);
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
});
export default tap.start();

View File

@@ -1,69 +0,0 @@
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import * as deesCatalog from '../ts_web/index.js';
import { BlockRegistry } from '../ts_web/elements/00group-input/dees-input-wysiwyg/blocks/block.registry.js';
import { DeesWysiwygBlock } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-wysiwyg-block.js';
// Import block registration to ensure handlers are registered
import '../ts_web/elements/00group-input/dees-input-wysiwyg/wysiwyg.blockregistration.js';
tap.test('Debug: should create empty wysiwyg block component', async () => {
try {
console.log('Creating DeesWysiwygBlock...');
const block: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
console.log('Block created:', block);
expect(block).toBeDefined();
expect(block).toBeInstanceOf(DeesWysiwygBlock);
console.log('Initial block property:', block.block);
console.log('Initial handlers property:', block.handlers);
} catch (error) {
console.error('Error creating block:', error);
throw error;
}
});
tap.test('Debug: should set properties step by step', async () => {
try {
console.log('Step 1: Creating component...');
const block: DeesWysiwygBlock = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
expect(block).toBeDefined();
console.log('Step 2: Setting handlers...');
block.handlers = {
onInput: () => console.log('onInput'),
onKeyDown: () => console.log('onKeyDown'),
onFocus: () => console.log('onFocus'),
onBlur: () => console.log('onBlur'),
onCompositionStart: () => console.log('onCompositionStart'),
onCompositionEnd: () => console.log('onCompositionEnd')
};
console.log('Handlers set:', block.handlers);
console.log('Step 3: Setting block data...');
block.block = {
id: 'test-block',
type: 'divider',
content: ' '
};
console.log('Block set:', block.block);
console.log('Step 4: Appending to body...');
document.body.appendChild(block);
console.log('Step 5: Waiting for update...');
await block.updateComplete;
console.log('Update complete');
console.log('Step 6: Checking shadowRoot...');
expect(block.shadowRoot).toBeDefined();
console.log('ShadowRoot exists');
} catch (error) {
console.error('Error in step-by-step test:', error);
throw error;
}
});
export default tap.start();

View File

@@ -1,240 +0,0 @@
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import * as deesCatalog from '../ts_web/index.js';
import { BlockRegistry } from '../ts_web/elements/00group-input/dees-input-wysiwyg/blocks/block.registry.js';
import { DeesWysiwygBlock } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-wysiwyg-block.js';
// Import block registration to ensure handlers are registered
import '../ts_web/elements/00group-input/dees-input-wysiwyg/wysiwyg.blockregistration.js';
tap.test('BlockRegistry should have registered handlers', async () => {
// Test divider handler
const dividerHandler = BlockRegistry.getHandler('divider');
expect(dividerHandler).toBeDefined();
expect(dividerHandler?.type).toEqual('divider');
// Test paragraph handler
const paragraphHandler = BlockRegistry.getHandler('paragraph');
expect(paragraphHandler).toBeDefined();
expect(paragraphHandler?.type).toEqual('paragraph');
// Test heading handlers
const heading1Handler = BlockRegistry.getHandler('heading-1');
expect(heading1Handler).toBeDefined();
expect(heading1Handler?.type).toEqual('heading-1');
const heading2Handler = BlockRegistry.getHandler('heading-2');
expect(heading2Handler).toBeDefined();
expect(heading2Handler?.type).toEqual('heading-2');
const heading3Handler = BlockRegistry.getHandler('heading-3');
expect(heading3Handler).toBeDefined();
expect(heading3Handler?.type).toEqual('heading-3');
// Test that getAllTypes returns all registered types
const allTypes = BlockRegistry.getAllTypes();
expect(allTypes).toContain('divider');
expect(allTypes).toContain('paragraph');
expect(allTypes).toContain('heading-1');
expect(allTypes).toContain('heading-2');
expect(allTypes).toContain('heading-3');
});
tap.test('should render divider block using handler', async () => {
// Wait for custom element to be defined
await customElements.whenDefined('dees-wysiwyg-block');
// Create element and set properties BEFORE attaching to DOM
const dividerBlock = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
// Set required handlers
dividerBlock.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
// Set a divider block
dividerBlock.block = {
id: 'test-divider',
type: 'divider',
content: ' '
};
// Attach to DOM and wait for render
document.body.appendChild(dividerBlock);
await dividerBlock.updateComplete;
// Wait for firstUpdated to populate the container
await new Promise(resolve => setTimeout(resolve, 50));
// Check that the divider is rendered
const dividerElement = dividerBlock.shadowRoot?.querySelector('.block.divider');
expect(dividerElement).toBeTruthy();
expect(dividerElement?.getAttribute('tabindex')).toEqual('0');
// Check for the hr element (divider uses <hr> not .divider-icon)
const hr = dividerBlock.shadowRoot?.querySelector('hr');
expect(hr).toBeTruthy();
// Clean up
document.body.removeChild(dividerBlock);
});
tap.test('should render paragraph block using handler', async () => {
// Wait for custom element to be defined
await customElements.whenDefined('dees-wysiwyg-block');
// Create element and set properties BEFORE attaching to DOM
const paragraphBlock = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
// Set required handlers
paragraphBlock.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
// Set a paragraph block
paragraphBlock.block = {
id: 'test-paragraph',
type: 'paragraph',
content: 'Test paragraph content'
};
// Attach to DOM and wait for render
document.body.appendChild(paragraphBlock);
await paragraphBlock.updateComplete;
// Wait for firstUpdated to populate the container
await new Promise(resolve => setTimeout(resolve, 50));
// Check that the paragraph is rendered
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
expect(paragraphElement).toBeTruthy();
expect(paragraphElement?.getAttribute('contenteditable')).toEqual('true');
expect(paragraphElement?.textContent).toEqual('Test paragraph content');
// Clean up
document.body.removeChild(paragraphBlock);
});
tap.test('should render heading blocks using handler', async () => {
// Wait for custom element to be defined
await customElements.whenDefined('dees-wysiwyg-block');
// Test heading-1 - set properties BEFORE attaching to DOM
const heading1Block = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
heading1Block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
heading1Block.block = {
id: 'test-h1',
type: 'heading-1',
content: 'Heading 1 Test'
};
document.body.appendChild(heading1Block);
await heading1Block.updateComplete;
// Wait for firstUpdated to populate the container
await new Promise(resolve => setTimeout(resolve, 50));
const h1Element = heading1Block.shadowRoot?.querySelector('.block.heading-1');
expect(h1Element).toBeTruthy();
expect(h1Element?.textContent).toEqual('Heading 1 Test');
// Clean up heading-1
document.body.removeChild(heading1Block);
// Test heading-2 - set properties BEFORE attaching to DOM
const heading2Block = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
heading2Block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
heading2Block.block = {
id: 'test-h2',
type: 'heading-2',
content: 'Heading 2 Test'
};
document.body.appendChild(heading2Block);
await heading2Block.updateComplete;
// Wait for firstUpdated to populate the container
await new Promise(resolve => setTimeout(resolve, 50));
const h2Element = heading2Block.shadowRoot?.querySelector('.block.heading-2');
expect(h2Element).toBeTruthy();
expect(h2Element?.textContent).toEqual('Heading 2 Test');
// Clean up heading-2
document.body.removeChild(heading2Block);
});
tap.test('paragraph block handler methods should work', async () => {
// Wait for custom element to be defined
await customElements.whenDefined('dees-wysiwyg-block');
// Create element and set properties BEFORE attaching to DOM
const paragraphBlock = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
// Set required handlers
paragraphBlock.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
paragraphBlock.block = {
id: 'test-methods',
type: 'paragraph',
content: 'Initial content'
};
document.body.appendChild(paragraphBlock);
await paragraphBlock.updateComplete;
// Wait for firstUpdated to populate the container
await new Promise(resolve => setTimeout(resolve, 50));
// Test getContent
const content = paragraphBlock.getContent();
expect(content).toEqual('Initial content');
// Test setContent
paragraphBlock.setContent('Updated content');
await paragraphBlock.updateComplete;
expect(paragraphBlock.getContent()).toEqual('Updated content');
// Test that the DOM is updated
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
expect(paragraphElement?.textContent).toEqual('Updated content');
// Clean up
document.body.removeChild(paragraphBlock);
});
export default tap.start();

View File

@@ -1,98 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu/dees-contextmenu.js';
tap.test('should change block type via context menu', async () => {
// Create WYSIWYG editor with a paragraph
const wysiwygEditor = new DeesInputWysiwyg();
wysiwygEditor.value = '<p>This is a test paragraph</p>';
document.body.appendChild(wysiwygEditor);
// Wait for editor to be ready
await wysiwygEditor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get the first block
const firstBlock = wysiwygEditor.blocks[0];
expect(firstBlock.type).toEqual('paragraph');
// Get the block element
const firstBlockWrapper = wysiwygEditor.shadowRoot!.querySelector('.block-wrapper');
expect(firstBlockWrapper).toBeTruthy();
const blockComponent = firstBlockWrapper!.querySelector('dees-wysiwyg-block') as any;
expect(blockComponent).toBeTruthy();
await blockComponent.updateComplete;
// Get the editable content inside the block's shadow DOM
const editableBlock = blockComponent.shadowRoot!.querySelector('.block');
expect(editableBlock).toBeTruthy();
// Simulate right-click on the editable block
const contextMenuEvent = new MouseEvent('contextmenu', {
clientX: 200,
clientY: 200,
bubbles: true,
cancelable: true,
composed: true
});
editableBlock!.dispatchEvent(contextMenuEvent);
// Wait for context menu to appear
await new Promise(resolve => setTimeout(resolve, 100));
// Check if context menu is created
const contextMenu = document.querySelector('dees-contextmenu');
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
// Find "Change Type" menu item
const menuItems = Array.from(contextMenu!.shadowRoot!.querySelectorAll('.menuitem'));
const changeTypeItem = menuItems.find(item =>
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Change Type'
);
expect(changeTypeItem).toBeTruthy();
// Hover over "Change Type" to trigger submenu
changeTypeItem!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
// Wait for submenu to appear
await new Promise(resolve => setTimeout(resolve, 300));
// Check if submenu is created
const allMenus = document.querySelectorAll('dees-contextmenu');
expect(allMenus.length).toEqual(2);
const submenu = allMenus[1];
const submenuItems = Array.from(submenu.shadowRoot!.querySelectorAll('.menuitem'));
// Find "Heading 1" option
const heading1Item = submenuItems.find(item =>
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Heading 1'
);
expect(heading1Item).toBeTruthy();
// Click on "Heading 1"
(heading1Item as HTMLElement).click();
// Wait for menu to close and block to update
await new Promise(resolve => setTimeout(resolve, 300));
// Verify block type has changed
expect(wysiwygEditor.blocks[0].type).toEqual('heading-1');
// Verify DOM has been updated
const updatedBlockComponent = wysiwygEditor.shadowRoot!
.querySelector('.block-wrapper')!
.querySelector('dees-wysiwyg-block') as any;
await updatedBlockComponent.updateComplete;
const updatedBlock = updatedBlockComponent.shadowRoot!.querySelector('.block');
expect(updatedBlock?.classList.contains('heading-1')).toEqual(true);
// Clean up
wysiwygEditor.remove();
});
export default tap.start();

View File

@@ -1,68 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu/dees-contextmenu.js';
tap.test('should show context menu on WYSIWYG blocks', async () => {
// Create WYSIWYG editor
const wysiwygEditor = new DeesInputWysiwyg();
wysiwygEditor.value = '<p>Test paragraph</p><h1>Test heading</h1>';
document.body.appendChild(wysiwygEditor);
// Wait for editor to be ready
await wysiwygEditor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get the first block element
const firstBlockWrapper = wysiwygEditor.shadowRoot!.querySelector('.block-wrapper');
expect(firstBlockWrapper).toBeTruthy();
const blockComponent = firstBlockWrapper!.querySelector('dees-wysiwyg-block') as any;
expect(blockComponent).toBeTruthy();
// Wait for block to be ready
await blockComponent.updateComplete;
// Get the editable content inside the block's shadow DOM
const editableBlock = blockComponent.shadowRoot!.querySelector('.block');
expect(editableBlock).toBeTruthy();
// Simulate right-click on the editable block
const contextMenuEvent = new MouseEvent('contextmenu', {
clientX: 200,
clientY: 200,
bubbles: true,
cancelable: true,
composed: true // Important for shadow DOM
});
editableBlock!.dispatchEvent(contextMenuEvent);
// Wait for context menu to appear
await new Promise(resolve => setTimeout(resolve, 100));
// Check if context menu is created
const contextMenu = document.querySelector('dees-contextmenu');
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
// Check if menu items from WYSIWYG block are rendered
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
const menuTexts = Array.from(menuItems).map(item =>
item.querySelector('.menuitem-text')?.textContent?.trim()
);
// Should have "Change Type" and "Delete Block" items
expect(menuTexts).toContain('Change Type');
expect(menuTexts).toContain('Delete Block');
// Check if "Change Type" has submenu indicator
const changeTypeItem = Array.from(menuItems).find(item =>
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Change Type'
);
expect(changeTypeItem?.classList.contains('has-submenu')).toEqual(true);
// Clean up
contextMenu!.remove();
wysiwygEditor.remove();
});
export default tap.start();

View File

@@ -1,95 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg drag handler initialization', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
// Wait for element to be ready
await element.updateComplete;
// Check that drag handler is initialized
expect(element.dragDropHandler).toBeTruthy();
// Set initial content with multiple blocks
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'First paragraph' },
{ id: 'block2', type: 'paragraph', content: 'Second paragraph' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
// Check that editor content ref exists
console.log('editorContentRef:', element.editorContentRef);
expect(element.editorContentRef).toBeTruthy();
// Check that blocks are rendered
const blockWrappers = element.shadowRoot!.querySelectorAll('.block-wrapper');
console.log('Number of block wrappers:', blockWrappers.length);
expect(blockWrappers.length).toEqual(2);
// Check drag handles
const dragHandles = element.shadowRoot!.querySelectorAll('.drag-handle');
console.log('Number of drag handles:', dragHandles.length);
expect(dragHandles.length).toEqual(2);
// Clean up
document.body.removeChild(element);
});
tap.test('wysiwyg drag start behavior', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const dragHandle = element.shadowRoot!.querySelector('.drag-handle') as HTMLElement;
expect(dragHandle).toBeTruthy();
// Check that drag handle has draggable attribute
console.log('Drag handle draggable:', dragHandle.draggable);
expect(dragHandle.draggable).toBeTrue();
// Test drag handler state before drag
console.log('Initial drag state:', element.dragDropHandler.dragState);
expect(element.dragDropHandler.dragState.draggedBlockId).toBeNull();
// Try to manually call handleDragStart
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: (type: string, data: string) => {
console.log('setData called with:', type, data);
},
setDragImage: (img: any, x: number, y: number) => {
console.log('setDragImage called');
}
},
clientY: 100,
preventDefault: () => {},
} as any;
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
// Check drag state after drag start
console.log('Drag state after start:', element.dragDropHandler.dragState);
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
});
export default tap.start();

View File

@@ -1,133 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg drag visual feedback - block movement', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
// Manually start drag
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: (type: string, data: string) => {},
setDragImage: (img: any, x: number, y: number) => {}
},
clientY: 50,
preventDefault: () => {},
} as any;
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
// Wait for dragging class
await new Promise(resolve => setTimeout(resolve, 20));
// Check dragging state
console.log('Block 1 classes:', block1.className);
console.log('Editor content classes:', editorContent.className);
expect(block1.classList.contains('dragging')).toBeTrue();
expect(editorContent.classList.contains('dragging')).toBeTrue();
// Check drop indicator exists
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
console.log('Drop indicator:', dropIndicator);
expect(dropIndicator).toBeTruthy();
// Test block movement calculation
console.log('Testing updateBlockPositions...');
// Access private method for testing
const updateBlockPositions = element.dragDropHandler['updateBlockPositions'].bind(element.dragDropHandler);
// Simulate dragging to different position
updateBlockPositions(150); // Move down
// Check if blocks have move classes
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
console.log('Block classes after move:');
blocks.forEach((block, i) => {
console.log(`Block ${i}:`, block.className, 'transform:', (block as HTMLElement).style.getPropertyValue('--drag-offset'));
});
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
});
tap.test('wysiwyg drop indicator positioning', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Paragraph 1' },
{ id: 'block2', type: 'heading-2', content: 'Heading 2' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
// Start dragging first block
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: (type: string, data: string) => {},
setDragImage: (img: any, x: number, y: number) => {}
},
clientY: 50,
preventDefault: () => {},
} as any;
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
// Wait for initialization
await new Promise(resolve => setTimeout(resolve, 20));
// Get drop indicator
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
expect(dropIndicator).toBeTruthy();
// Check initial display state
console.log('Drop indicator initial display:', dropIndicator.style.display);
// Trigger updateBlockPositions to see drop indicator
const updateBlockPositions = element.dragDropHandler['updateBlockPositions'].bind(element.dragDropHandler);
updateBlockPositions(100);
// Check drop indicator position
console.log('Drop indicator after update:');
console.log('- display:', dropIndicator.style.display);
console.log('- top:', dropIndicator.style.top);
console.log('- height:', dropIndicator.style.height);
expect(dropIndicator.style.display).toEqual('block');
expect(dropIndicator.style.top).toBeTruthy();
expect(dropIndicator.style.height).toBeTruthy();
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
});
export default tap.start();

View File

@@ -1,145 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg drag and drop should work correctly', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
// Wait for element to be ready
await element.updateComplete;
// Set initial content with multiple blocks
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'First paragraph' },
{ id: 'block2', type: 'heading-2', content: 'Test Heading' },
{ id: 'block3', type: 'paragraph', content: 'Second paragraph' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
// Wait for nested block components to also complete their updates
await new Promise(resolve => setTimeout(resolve, 50));
// Check that blocks are rendered
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
expect(editorContent).toBeTruthy();
const blockWrappers = editorContent.querySelectorAll('.block-wrapper');
expect(blockWrappers.length).toEqual(3);
// Test drag handles exist for non-divider blocks
const dragHandles = editorContent.querySelectorAll('.drag-handle');
expect(dragHandles.length).toEqual(3);
// Get references to specific blocks
const firstBlock = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
const secondBlock = editorContent.querySelector('[data-block-id="block2"]') as HTMLElement;
const firstDragHandle = firstBlock.querySelector('.drag-handle') as HTMLElement;
expect(firstBlock).toBeTruthy();
expect(secondBlock).toBeTruthy();
expect(firstDragHandle).toBeTruthy();
// Verify drag drop handler exists
expect(element.dragDropHandler).toBeTruthy();
expect(element.dragDropHandler.dragState).toBeTruthy();
// Test drag initialization - synthetic DragEvents may not fully work in all browsers
console.log('Testing drag initialization...');
// Create drag event
const dragStartEvent = new DragEvent('dragstart', {
dataTransfer: new DataTransfer(),
clientY: 100,
bubbles: true
});
// Simulate drag start
firstDragHandle.dispatchEvent(dragStartEvent);
// Wait for setTimeout in drag start
await new Promise(resolve => setTimeout(resolve, 50));
// Note: Synthetic DragEvents may not fully initialize drag state in all test environments
// The test verifies the structure and that events can be dispatched
console.log('Drag state after start:', element.dragDropHandler.dragState.draggedBlockId);
// Test drag end cleanup
const dragEndEvent = new DragEvent('dragend', {
bubbles: true
});
document.dispatchEvent(dragEndEvent);
// Wait for cleanup
await new Promise(resolve => setTimeout(resolve, 150));
// Clean up
document.body.removeChild(element);
});
tap.test('wysiwyg drag and drop visual feedback', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
// Wait for nested block components to also complete their updates
await new Promise(resolve => setTimeout(resolve, 50));
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
const dragHandle1 = block1.querySelector('.drag-handle') as HTMLElement;
// Start dragging block 1
const dragStartEvent = new DragEvent('dragstart', {
dataTransfer: new DataTransfer(),
clientY: 50,
bubbles: true
});
dragHandle1.dispatchEvent(dragStartEvent);
// Wait for dragging class
await new Promise(resolve => setTimeout(resolve, 20));
// Simulate dragging down
const dragOverEvent = new DragEvent('dragover', {
dataTransfer: new DataTransfer(),
clientY: 150, // Move down past block 2
bubbles: true,
cancelable: true
});
// Trigger the global drag over handler
element.dragDropHandler['handleGlobalDragOver'](dragOverEvent);
// Check that transform is applied to dragged block
const transform = block1.style.transform;
console.log('Dragged block transform:', transform);
expect(transform).toContain('translateY');
// Check drop indicator position
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
if (dropIndicator) {
const indicatorStyle = dropIndicator.style;
console.log('Drop indicator position:', indicatorStyle.top, 'display:', indicatorStyle.display);
}
// Clean up
document.body.removeChild(element);
});
export default tap.start();

View File

@@ -1,124 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg drag full flow without await', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
// Mock drag event
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: (type: string, data: string) => {
console.log('setData:', type, data);
},
setDragImage: (img: any, x: number, y: number) => {
console.log('setDragImage');
}
},
clientY: 100,
preventDefault: () => {},
} as any;
console.log('Starting drag...');
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
console.log('Drag started');
// Check immediate state
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
// Instead of await with setTimeout, use a done callback
return new Promise<void>((resolve) => {
console.log('Setting up delayed check...');
// Use regular setTimeout
setTimeout(() => {
console.log('In setTimeout callback');
try {
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
console.log('Block has dragging class:', block1?.classList.contains('dragging'));
console.log('Editor has dragging class:', editorContent?.classList.contains('dragging'));
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
resolve();
} catch (error) {
console.error('Error in setTimeout:', error);
throw error;
}
}, 50);
});
});
tap.test('identify the crash point', async () => {
console.log('Test started');
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
console.log('Element created');
await element.updateComplete;
console.log('Setting blocks');
element.blocks = [{ id: 'block1', type: 'paragraph', content: 'Test' }];
element.renderBlocksProgrammatically();
console.log('Waiting for update');
await element.updateComplete;
console.log('Creating mock event');
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: () => {},
setDragImage: () => {}
},
clientY: 100,
preventDefault: () => {},
} as any;
console.log('Calling handleDragStart');
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
console.log('handleDragStart completed');
// Try different wait methods
console.log('About to wait...');
// Method 1: Direct promise
await Promise.resolve();
console.log('Promise.resolve completed');
// Method 2: setTimeout 0
await new Promise(resolve => setTimeout(resolve, 0));
console.log('setTimeout 0 completed');
// Method 3: requestAnimationFrame
await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)));
console.log('requestAnimationFrame completed');
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
console.log('Cleanup completed');
});
export default tap.start();

View File

@@ -1,108 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg drop indicator creation', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
// Check editorContentRef
console.log('editorContentRef exists:', !!element.editorContentRef);
console.log('editorContentRef tagName:', element.editorContentRef?.tagName);
expect(element.editorContentRef).toBeTruthy();
// Check initial state - no drop indicator
let dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
console.log('Drop indicator before drag:', dropIndicator);
expect(dropIndicator).toBeFalsy();
// Manually call createDropIndicator
try {
console.log('Calling createDropIndicator...');
element.dragDropHandler['createDropIndicator']();
console.log('createDropIndicator succeeded');
} catch (error) {
console.error('Error creating drop indicator:', error);
throw error;
}
// Check drop indicator was created
dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
console.log('Drop indicator after creation:', dropIndicator);
console.log('Drop indicator parent:', dropIndicator?.parentElement?.className);
expect(dropIndicator).toBeTruthy();
expect(dropIndicator!.style.display).toEqual('none');
// Clean up
document.body.removeChild(element);
});
tap.test('wysiwyg drag initialization with drop indicator', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
// Mock drag event
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: (type: string, data: string) => {
console.log('setData:', type, data);
},
setDragImage: (img: any, x: number, y: number) => {
console.log('setDragImage');
}
},
clientY: 100,
preventDefault: () => {},
} as any;
console.log('Starting drag...');
try {
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
console.log('Drag start succeeded');
} catch (error) {
console.error('Error during drag start:', error);
throw error;
}
// Wait for async operations
await new Promise(resolve => setTimeout(resolve, 20));
// Check drop indicator exists
const dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
console.log('Drop indicator after drag start:', dropIndicator);
expect(dropIndicator).toBeTruthy();
// Check drag state
console.log('Drag state:', element.dragDropHandler.dragState);
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
});
export default tap.start();

View File

@@ -1,114 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg global event listeners', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
console.log('Block 1 found:', !!block1);
// Set up drag state manually without using handleDragStart
element.dragDropHandler['draggedBlockId'] = 'block1';
element.dragDropHandler['draggedBlockElement'] = block1;
element.dragDropHandler['initialMouseY'] = 100;
// Create drop indicator manually
element.dragDropHandler['createDropIndicator']();
// Test adding global event listeners
console.log('Adding event listeners...');
const handleGlobalDragOver = element.dragDropHandler['handleGlobalDragOver'];
const handleGlobalDragEnd = element.dragDropHandler['handleGlobalDragEnd'];
try {
document.addEventListener('dragover', handleGlobalDragOver);
console.log('dragover listener added');
document.addEventListener('dragend', handleGlobalDragEnd);
console.log('dragend listener added');
} catch (error) {
console.error('Error adding event listeners:', error);
throw error;
}
// Test firing a dragover event
console.log('Creating dragover event...');
const dragOverEvent = new Event('dragover', {
bubbles: true,
cancelable: true
});
Object.defineProperty(dragOverEvent, 'clientY', { value: 150 });
console.log('Dispatching dragover event...');
document.dispatchEvent(dragOverEvent);
console.log('dragover event dispatched');
// Clean up
document.removeEventListener('dragover', handleGlobalDragOver);
document.removeEventListener('dragend', handleGlobalDragEnd);
document.body.removeChild(element);
});
tap.test('wysiwyg setTimeout in drag start', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
// Set drag state
element.dragDropHandler['draggedBlockId'] = 'block1';
element.dragDropHandler['draggedBlockElement'] = block1;
console.log('Testing setTimeout callback...');
// Test the setTimeout callback directly
try {
if (block1) {
console.log('Adding dragging class to block...');
block1.classList.add('dragging');
console.log('Block classes:', block1.className);
}
if (editorContent) {
console.log('Adding dragging class to editor...');
editorContent.classList.add('dragging');
console.log('Editor classes:', editorContent.className);
}
} catch (error) {
console.error('Error in setTimeout callback:', error);
throw error;
}
expect(block1.classList.contains('dragging')).toBeTrue();
expect(editorContent.classList.contains('dragging')).toBeTrue();
// Clean up
document.body.removeChild(element);
});
export default tap.start();

View File

@@ -1,329 +0,0 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-wysiwyg-block.js';
tap.test('Keyboard: Arrow navigation between blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import multiple blocks
editor.importBlocks([
{ id: 'block-1', type: 'paragraph', content: 'First paragraph' },
{ id: 'block-2', type: 'paragraph', content: 'Second paragraph' },
{ id: 'block-3', type: 'paragraph', content: 'Third paragraph' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus first block at end
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const firstBlockContainer = firstBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const firstParagraph = firstBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
// Focus and set cursor at end of first block
firstParagraph.focus();
const textNode = firstParagraph.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, textNode.textContent?.length || 0);
range.setEnd(textNode, textNode.textContent?.length || 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press ArrowRight to move to second block
const arrowRightEvent = new KeyboardEvent('keydown', {
key: 'ArrowRight',
code: 'ArrowRight',
bubbles: true,
cancelable: true,
composed: true
});
firstParagraph.dispatchEvent(arrowRightEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if second block is focused
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
// Check if the second paragraph has focus
const activeElement = secondBlockComponent.shadowRoot?.activeElement;
expect(activeElement).toEqual(secondParagraph);
console.log('Arrow navigation test complete');
});
tap.test('Keyboard: Backspace merges blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import two blocks
editor.importBlocks([
{ id: 'merge-1', type: 'paragraph', content: 'First' },
{ id: 'merge-2', type: 'paragraph', content: 'Second' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus second block at beginning
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="merge-2"]');
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
// Focus and set cursor at beginning
secondParagraph.focus();
const textNode = secondParagraph.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press Backspace to merge with previous block
const backspaceEvent = new KeyboardEvent('keydown', {
key: 'Backspace',
code: 'Backspace',
bubbles: true,
cancelable: true,
composed: true
});
secondParagraph.dispatchEvent(backspaceEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if blocks were merged
expect(editor.blocks.length).toEqual(1);
expect(editor.blocks[0].content).toContain('First');
expect(editor.blocks[0].content).toContain('Second');
console.log('Backspace merge test complete');
});
tap.test('Keyboard: Delete key on non-editable blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import blocks including a divider
editor.importBlocks([
{ id: 'para-1', type: 'paragraph', content: 'Before divider' },
{ id: 'div-1', type: 'divider', content: '' },
{ id: 'para-2', type: 'paragraph', content: 'After divider' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus the divider block
const dividerBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="div-1"]');
const dividerBlockComponent = dividerBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const dividerBlockContainer = dividerBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const dividerElement = dividerBlockContainer?.querySelector('.block.divider') as HTMLElement;
// Non-editable blocks need to be focused differently
dividerElement?.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Press Delete to remove the divider
const deleteEvent = new KeyboardEvent('keydown', {
key: 'Delete',
code: 'Delete',
bubbles: true,
cancelable: true,
composed: true
});
dividerElement.dispatchEvent(deleteEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if divider was removed
expect(editor.blocks.length).toEqual(2);
expect(editor.blocks.find(b => b.type === 'divider')).toBeUndefined();
console.log('Delete key on non-editable block test complete');
});
tap.test('Keyboard: Tab key in code block', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a code block
editor.importBlocks([
{ id: 'code-1', type: 'code', content: 'function test() {', metadata: { language: 'javascript' } }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus code block - code blocks use .code-editor instead of .block.code
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const codeBlockContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const codeElement = codeBlockContainer?.querySelector('.code-editor') as HTMLElement;
expect(codeElement).toBeTruthy();
// Focus and set cursor at end
codeElement.focus();
const textNode = codeElement.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, textNode.textContent?.length || 0);
range.setEnd(textNode, textNode.textContent?.length || 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press Tab to insert spaces
const tabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
code: 'Tab',
bubbles: true,
cancelable: true,
composed: true
});
codeElement.dispatchEvent(tabEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if spaces were inserted
const updatedContent = codeElement.textContent || '';
expect(updatedContent).toContain(' '); // Tab should insert 2 spaces
console.log('Tab in code block test complete');
});
tap.test('Keyboard: ArrowUp/Down navigation', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import multiple blocks
editor.importBlocks([
{ id: 'nav-1', type: 'paragraph', content: 'First line' },
{ id: 'nav-2', type: 'paragraph', content: 'Second line' },
{ id: 'nav-3', type: 'paragraph', content: 'Third line' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Verify blocks were created
expect(editor.blocks.length).toEqual(3);
// Focus second block
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-2"]');
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
expect(secondParagraph).toBeTruthy();
secondParagraph.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Verify keyboard handler exists
expect(editor.keyboardHandler).toBeTruthy();
// Press ArrowUp - event is dispatched (focus change may not occur in synthetic events)
const arrowUpEvent = new KeyboardEvent('keydown', {
key: 'ArrowUp',
code: 'ArrowUp',
bubbles: true,
cancelable: true,
composed: true
});
secondParagraph.dispatchEvent(arrowUpEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Get first block references
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-1"]');
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const firstBlockContainer = firstBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const firstParagraph = firstBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
expect(firstParagraph).toBeTruthy();
// Note: Synthetic keyboard events don't reliably trigger native browser focus changes
// in automated tests. The handler is invoked but focus may not actually move.
// This test verifies the structure exists and events can be dispatched.
console.log('ArrowUp/Down navigation test complete');
});
tap.test('Keyboard: Formatting shortcuts', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a paragraph
editor.importBlocks([
{ id: 'format-1', type: 'paragraph', content: 'Test formatting' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus and select text
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="format-1"]');
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const blockContainer = blockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paragraph = blockContainer?.querySelector('.block.paragraph') as HTMLElement;
paragraph.focus();
// Select "formatting"
const textNode = paragraph.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 5); // After "Test "
range.setEnd(textNode, 15); // After "formatting"
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press Cmd/Ctrl+B for bold
const boldEvent = new KeyboardEvent('keydown', {
key: 'b',
code: 'KeyB',
metaKey: true, // Use metaKey for Mac, ctrlKey for Windows/Linux
bubbles: true,
cancelable: true,
composed: true
});
paragraph.dispatchEvent(boldEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if bold was applied
const content = paragraph.innerHTML;
expect(content).toContain('<strong>') || expect(content).toContain('<b>');
console.log('Formatting shortcuts test complete');
});
export default tap.start();

View File

@@ -1,152 +0,0 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-wysiwyg-block.js';
tap.test('Phase 3: Quote block should render and work correctly', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a quote block
editor.importBlocks([
{ id: 'quote-1', type: 'quote', content: 'This is a famous quote' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check if quote block was rendered
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
expect(quoteBlockComponent).toBeTruthy();
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
expect(quoteElement).toBeTruthy();
expect(quoteElement?.textContent).toEqual('This is a famous quote');
// Check if styles are applied (border-left for quote)
const computedStyle = window.getComputedStyle(quoteElement);
expect(computedStyle.borderLeftStyle).toEqual('solid');
expect(computedStyle.fontStyle).toEqual('italic');
});
tap.test('Phase 3: Code block should render and handle tab correctly', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a code block
editor.importBlocks([
{ id: 'code-1', type: 'code', content: 'const x = 42;', metadata: { language: 'javascript' } }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check if code block was rendered - code blocks use .code-editor instead of .block.code
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const codeContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const codeElement = codeContainer?.querySelector('.code-editor') as HTMLElement;
expect(codeElement).toBeTruthy();
expect(codeElement?.textContent).toEqual('const x = 42;');
// Check if language selector is shown
const languageSelector = codeContainer?.querySelector('.language-selector') as HTMLSelectElement;
expect(languageSelector).toBeTruthy();
expect(languageSelector?.value).toEqual('javascript');
// Check if monospace font is applied - code-editor is a <code> element
const computedStyle = window.getComputedStyle(codeElement);
// Font family may vary by platform, so just check it contains something
expect(computedStyle.fontFamily).toBeTruthy();
});
tap.test('Phase 3: List block should render correctly', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a list block
editor.importBlocks([
{ id: 'list-1', type: 'list', content: 'First item\nSecond item\nThird item' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check if list block was rendered
const listBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="list-1"]');
const listBlockComponent = listBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const listContainer = listBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const listElement = listContainer?.querySelector('.block.list') as HTMLElement;
expect(listElement).toBeTruthy();
// Check if list items were created
const listItems = listElement?.querySelectorAll('li');
expect(listItems?.length).toEqual(3);
expect(listItems?.[0].textContent).toEqual('First item');
expect(listItems?.[1].textContent).toEqual('Second item');
expect(listItems?.[2].textContent).toEqual('Third item');
// Check if it's an unordered list by default
const ulElement = listElement?.querySelector('ul');
expect(ulElement).toBeTruthy();
});
tap.test('Phase 3: Quote block split should work', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a quote block
editor.importBlocks([
{ id: 'quote-split', type: 'quote', content: 'To be or not to be' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get the quote block
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-split"]');
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
// Focus and set cursor after "To be"
quoteElement.focus();
const textNode = quoteElement.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 5); // After "To be"
range.setEnd(textNode, 5);
selection?.removeAllRanges();
selection?.addRange(range);
await new Promise(resolve => setTimeout(resolve, 100));
// Press Enter to split
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
cancelable: true,
composed: true
});
quoteElement.dispatchEvent(enterEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if split happened correctly
expect(editor.blocks.length).toEqual(2);
expect(editor.blocks[0].content).toEqual('To be');
expect(editor.blocks[1].content).toEqual(' or not to be');
expect(editor.blocks[1].type).toEqual('paragraph'); // New block should be paragraph
}
});
export default tap.start();

View File

@@ -1,112 +0,0 @@
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import { BlockRegistry } from '../ts_web/elements/00group-input/dees-input-wysiwyg/blocks/block.registry.js';
import { DividerBlockHandler } from '../ts_web/elements/00group-input/dees-input-wysiwyg/blocks/content/divider.block.js';
import { ParagraphBlockHandler } from '../ts_web/elements/00group-input/dees-input-wysiwyg/blocks/text/paragraph.block.js';
import { HeadingBlockHandler } from '../ts_web/elements/00group-input/dees-input-wysiwyg/blocks/text/heading.block.js';
// Import block registration to ensure handlers are registered
import '../ts_web/elements/00group-input/dees-input-wysiwyg/wysiwyg.blockregistration.js';
tap.test('BlockRegistry should register and retrieve handlers', async () => {
// Test divider handler
const dividerHandler = BlockRegistry.getHandler('divider');
expect(dividerHandler).toBeDefined();
expect(dividerHandler).toBeInstanceOf(DividerBlockHandler);
expect(dividerHandler?.type).toEqual('divider');
// Test paragraph handler
const paragraphHandler = BlockRegistry.getHandler('paragraph');
expect(paragraphHandler).toBeDefined();
expect(paragraphHandler).toBeInstanceOf(ParagraphBlockHandler);
expect(paragraphHandler?.type).toEqual('paragraph');
// Test heading handlers
const heading1Handler = BlockRegistry.getHandler('heading-1');
expect(heading1Handler).toBeDefined();
expect(heading1Handler).toBeInstanceOf(HeadingBlockHandler);
expect(heading1Handler?.type).toEqual('heading-1');
const heading2Handler = BlockRegistry.getHandler('heading-2');
expect(heading2Handler).toBeDefined();
expect(heading2Handler).toBeInstanceOf(HeadingBlockHandler);
expect(heading2Handler?.type).toEqual('heading-2');
const heading3Handler = BlockRegistry.getHandler('heading-3');
expect(heading3Handler).toBeDefined();
expect(heading3Handler).toBeInstanceOf(HeadingBlockHandler);
expect(heading3Handler?.type).toEqual('heading-3');
});
tap.test('Block handlers should render content correctly', async () => {
const testBlock = {
id: 'test-1',
type: 'paragraph' as const,
content: 'Test paragraph content'
};
const handler = BlockRegistry.getHandler('paragraph');
expect(handler).toBeDefined();
if (handler) {
const rendered = handler.render(testBlock, false);
// The render() method returns the HTML template structure
// Content is set later in setup()
expect(rendered).toContain('contenteditable="true"');
expect(rendered).toContain('data-block-type="paragraph"');
expect(rendered).toContain('data-block-id="test-1"');
expect(rendered).toContain('class="block paragraph"');
}
});
tap.test('Divider handler should render correctly', async () => {
const dividerBlock = {
id: 'test-divider',
type: 'divider' as const,
content: ' '
};
const handler = BlockRegistry.getHandler('divider');
expect(handler).toBeDefined();
if (handler) {
const rendered = handler.render(dividerBlock, false);
expect(rendered).toContain('class="block divider"');
expect(rendered).toContain('tabindex="0"');
expect(rendered).toContain('<hr>');
expect(rendered).toContain('data-block-id="test-divider"');
}
});
tap.test('Heading handlers should render with correct levels', async () => {
const headingBlock = {
id: 'test-h1',
type: 'heading-1' as const,
content: 'Test Heading'
};
const handler = BlockRegistry.getHandler('heading-1');
expect(handler).toBeDefined();
if (handler) {
const rendered = handler.render(headingBlock, false);
// The render() method returns the HTML template structure
// Content is set later in setup()
expect(rendered).toContain('class="block heading-1"');
expect(rendered).toContain('contenteditable="true"');
expect(rendered).toContain('data-block-id="test-h1"');
expect(rendered).toContain('data-block-type="heading-1"');
}
});
tap.test('getAllTypes should return all registered types', async () => {
const allTypes = BlockRegistry.getAllTypes();
expect(allTypes).toContain('divider');
expect(allTypes).toContain('paragraph');
expect(allTypes).toContain('heading-1');
expect(allTypes).toContain('heading-2');
expect(allTypes).toContain('heading-3');
expect(allTypes.length).toBeGreaterThanOrEqual(5);
});
export default tap.start();

View File

@@ -1,158 +0,0 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-wysiwyg-block.js';
tap.test('Selection highlighting should work consistently for all block types', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import various block types
editor.importBlocks([
{ id: 'para-1', type: 'paragraph', content: 'This is a paragraph' },
{ id: 'heading-1', type: 'heading-1', content: 'This is a heading' },
{ id: 'quote-1', type: 'quote', content: 'This is a quote' },
{ id: 'code-1', type: 'code', content: 'const x = 42;' },
{ id: 'list-1', type: 'list', content: 'Item 1\nItem 2' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Test paragraph highlighting
console.log('Testing paragraph highlighting...');
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const paraContainer = paraComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paraElement = paraContainer?.querySelector('.block.paragraph') as HTMLElement;
// Focus paragraph to select it
paraElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if paragraph has selected class
const paraHasSelected = paraElement.classList.contains('selected');
console.log('Paragraph has selected class:', paraHasSelected);
// Check computed styles
const paraStyle = window.getComputedStyle(paraElement);
console.log('Paragraph background:', paraStyle.background);
console.log('Paragraph box-shadow:', paraStyle.boxShadow);
// Test heading highlighting
console.log('\nTesting heading highlighting...');
const headingWrapper = editor.shadowRoot?.querySelector('[data-block-id="heading-1"]');
const headingComponent = headingWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const headingContainer = headingComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const headingElement = headingContainer?.querySelector('.block.heading-1') as HTMLElement;
// Focus heading to select it
headingElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if heading has selected class
const headingHasSelected = headingElement.classList.contains('selected');
console.log('Heading has selected class:', headingHasSelected);
// Check computed styles
const headingStyle = window.getComputedStyle(headingElement);
console.log('Heading background:', headingStyle.background);
console.log('Heading box-shadow:', headingStyle.boxShadow);
// Test quote highlighting
console.log('\nTesting quote highlighting...');
const quoteWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
const quoteComponent = quoteWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const quoteContainer = quoteComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
// Focus quote to select it
quoteElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if quote has selected class
const quoteHasSelected = quoteElement.classList.contains('selected');
console.log('Quote has selected class:', quoteHasSelected);
// Test code highlighting - code blocks use .code-editor instead of .block.code
console.log('\nTesting code highlighting...');
const codeWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
const codeComponent = codeWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const codeContainer = codeComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const codeElement = codeContainer?.querySelector('.code-editor') as HTMLElement;
const codeBlockContainer = codeContainer?.querySelector('.code-block-container') as HTMLElement;
// Focus code to select it
codeElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// For code blocks, the selection is on the container, not the editor
const codeHasSelected = codeBlockContainer?.classList.contains('selected');
console.log('Code container has selected class:', codeHasSelected);
// Focus back on paragraph and check if others are deselected
console.log('\nFocusing back on paragraph...');
paraElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check that only paragraph is selected
expect(paraElement.classList.contains('selected')).toBeTrue();
expect(headingElement.classList.contains('selected')).toBeFalse();
expect(quoteElement.classList.contains('selected')).toBeFalse();
// Code blocks use different selection structure
expect(codeBlockContainer?.classList.contains('selected') || false).toBeFalse();
console.log('Selection highlighting test complete');
});
tap.test('Selected class should toggle correctly when clicking between blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import two blocks
editor.importBlocks([
{ id: 'block-1', type: 'paragraph', content: 'First block' },
{ id: 'block-2', type: 'paragraph', content: 'Second block' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get both blocks
const block1Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
const block1Component = block1Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const block1Container = block1Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const block1Element = block1Container?.querySelector('.block.paragraph') as HTMLElement;
const block2Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
const block2Component = block2Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const block2Container = block2Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const block2Element = block2Container?.querySelector('.block.paragraph') as HTMLElement;
// Initially neither should be selected
expect(block1Element.classList.contains('selected')).toBeFalse();
expect(block2Element.classList.contains('selected')).toBeFalse();
// Click on first block
block1Element.click();
block1Element.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// First block should be selected
expect(block1Element.classList.contains('selected')).toBeTrue();
expect(block2Element.classList.contains('selected')).toBeFalse();
// Click on second block
block2Element.click();
block2Element.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Second block should be selected, first should not
expect(block1Element.classList.contains('selected')).toBeFalse();
expect(block2Element.classList.contains('selected')).toBeTrue();
console.log('Toggle test complete');
});
export default tap.start();

View File

@@ -1,62 +0,0 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-wysiwyg-block.js';
tap.test('Selection highlighting basic test', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import two blocks
editor.importBlocks([
{ id: 'para-1', type: 'paragraph', content: 'First paragraph' },
{ id: 'head-1', type: 'heading-1', content: 'First heading' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 500));
// Get paragraph element
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const paraBlock = paraComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
// Get heading element
const headWrapper = editor.shadowRoot?.querySelector('[data-block-id="head-1"]');
const headComponent = headWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const headBlock = headComponent?.shadowRoot?.querySelector('.block.heading-1') as HTMLElement;
console.log('Found elements:', {
paraBlock: !!paraBlock,
headBlock: !!headBlock
});
// Focus paragraph
paraBlock.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check classes
console.log('Paragraph classes:', paraBlock.className);
console.log('Heading classes:', headBlock.className);
// Check isSelected property
console.log('Paragraph component isSelected:', paraComponent.isSelected);
console.log('Heading component isSelected:', headComponent.isSelected);
// Focus heading
headBlock.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check classes again
console.log('\nAfter focusing heading:');
console.log('Paragraph classes:', paraBlock.className);
console.log('Heading classes:', headBlock.className);
console.log('Paragraph component isSelected:', paraComponent.isSelected);
console.log('Heading component isSelected:', headComponent.isSelected);
// Check that heading is selected
expect(headBlock.classList.contains('selected')).toBeTrue();
expect(paraBlock.classList.contains('selected')).toBeFalse();
});
export default tap.start();

View File

@@ -1,98 +0,0 @@
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/00group-input/dees-input-wysiwyg/dees-wysiwyg-block.js';
tap.test('should split paragraph content on Enter key', async () => {
// Create the wysiwyg editor
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a test paragraph
editor.importBlocks([{
id: 'test-para-1',
type: 'paragraph',
content: 'Hello World'
}]);
await editor.updateComplete;
// Wait for blocks to render
await new Promise(resolve => setTimeout(resolve, 100));
// Get the block wrapper and component
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-para-1"]');
expect(blockWrapper).toBeDefined();
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
expect(blockComponent).toBeDefined();
expect(blockComponent.block.type).toEqual('paragraph');
// Wait for block to render
await blockComponent.updateComplete;
// Test getSplitContent
console.log('Testing getSplitContent...');
const splitResult = blockComponent.getSplitContent();
console.log('Split result:', splitResult);
// Since we haven't set cursor position, it might return null or split at start
// This is just to test if the method is callable
expect(typeof blockComponent.getSplitContent).toEqual('function');
});
tap.test('should handle Enter key press in paragraph', async () => {
// Create the wysiwyg editor
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a test paragraph
editor.importBlocks([{
id: 'test-enter-1',
type: 'paragraph',
content: 'First part|Second part' // | marks where we'll simulate cursor
}]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check initial state
expect(editor.blocks.length).toEqual(1);
expect(editor.blocks[0].content).toEqual('First part|Second part');
// Get the block element
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-enter-1"]');
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const blockElement = blockComponent.shadowRoot?.querySelector('.block.paragraph') as HTMLDivElement;
expect(blockElement).toBeDefined();
// Set content without the | marker
blockElement.textContent = 'First partSecond part';
// Focus the block
blockElement.focus();
// Create and dispatch Enter key event
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
cancelable: true,
composed: true
});
// Dispatch the event
blockElement.dispatchEvent(enterEvent);
// Wait for processing
await new Promise(resolve => setTimeout(resolve, 200));
// Check if block was split (this might not work perfectly in test environment)
console.log('Blocks after Enter:', editor.blocks.length);
console.log('Block contents:', editor.blocks.map(b => b.content));
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@ecobridge.xyz/catalog',
version: '3.34.0',
version: '3.37.0',
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
}

View File

@@ -0,0 +1,24 @@
import { html } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
padding: 48px;
background: hsl(240 10% 4%);
min-height: 400px;
display: flex;
justify-content: center;
}
</style>
<div class="demo-container">
<eco-applauncher-batterymenu
open
.batteryLevel=${85}
.isCharging=${false}
.batterySaverEnabled=${false}
.timeRemaining=${'2h 30m remaining'}
@battery-saver-toggle=${(e: CustomEvent) => console.log('Battery saver:', e.detail)}
@settings-click=${() => console.log('Settings clicked')}
></eco-applauncher-batterymenu>
</div>
`;

View File

@@ -0,0 +1,394 @@
import {
customElement,
DeesElement,
type TemplateResult,
html,
property,
css,
cssManager,
} from '@design.estate/dees-element';
import { DeesIcon } from '@design.estate/dees-catalog';
import { demo } from './eco-applauncher-batterymenu.demo.js';
// Ensure dees-icon is registered
DeesIcon;
declare global {
interface HTMLElementTagNameMap {
'eco-applauncher-batterymenu': EcoApplauncherBatterymenu;
}
}
@customElement('eco-applauncher-batterymenu')
export class EcoApplauncherBatterymenu extends DeesElement {
public static demo = demo;
public static demoGroup = 'App Launcher';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
pointer-events: none;
}
:host([open]) {
pointer-events: auto;
}
.menu-container {
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 10%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 20%)')};
border-radius: 12px;
box-shadow: ${cssManager.bdTheme(
'0 8px 32px rgba(0, 0, 0, 0.15)',
'0 8px 32px rgba(0, 0, 0, 0.4)'
)};
min-width: 280px;
overflow: hidden;
opacity: 0;
transform: scale(0.95) translateY(-8px);
transition: all 0.2s ease-out;
pointer-events: none;
}
:host([open]) .menu-container {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.battery-display {
padding: 24px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.battery-visual {
display: flex;
align-items: center;
gap: 8px;
}
.battery-icon {
position: relative;
width: 80px;
height: 36px;
border: 2px solid ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 70%)')};
border-radius: 6px;
overflow: hidden;
}
.battery-icon::after {
content: '';
position: absolute;
right: -6px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 14px;
background: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 70%)')};
border-radius: 0 2px 2px 0;
}
.battery-fill {
height: 100%;
transition: width 0.3s ease, background 0.3s ease;
}
.battery-fill.good {
background: hsl(142 71% 45%);
}
.battery-fill.medium {
background: hsl(47 100% 50%);
}
.battery-fill.low {
background: hsl(0 72% 51%);
}
.battery-fill.charging {
background: linear-gradient(
90deg,
hsl(142 71% 45%) 0%,
hsl(142 71% 55%) 50%,
hsl(142 71% 45%) 100%
);
background-size: 200% 100%;
animation: charging-pulse 1.5s ease-in-out infinite;
}
@keyframes charging-pulse {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.charging-icon {
color: hsl(47 100% 50%);
}
.battery-percentage {
font-size: 32px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
}
.battery-status {
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
display: flex;
align-items: center;
gap: 6px;
}
.menu-divider {
height: 1px;
background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
}
.menu-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
transition: background 0.15s ease;
}
.menu-option:hover {
background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(240 5% 15%)')};
}
.option-label {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
}
.option-description {
font-size: 12px;
color: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 50%)')};
margin-top: 2px;
}
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
background: ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(240 5% 25%)')};
border-radius: 12px;
cursor: pointer;
transition: background 0.2s ease;
flex-shrink: 0;
}
.toggle-switch.active {
background: hsl(217 91% 60%);
}
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: ${cssManager.bdTheme('0 1px 3px rgba(0,0,0,0.2)', 'none')};
}
.toggle-switch.active::after {
transform: translateX(20px);
}
.menu-footer {
padding: 12px 16px;
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
}
.settings-link {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: hsl(217 91% 60%);
cursor: pointer;
transition: color 0.15s ease;
}
.settings-link:hover {
color: hsl(217 91% 50%);
}
`,
];
@property({ type: Boolean, reflect: true })
accessor open = false;
@property({ type: Number })
accessor batteryLevel = 100;
@property({ type: Boolean })
accessor isCharging = false;
@property({ type: Boolean })
accessor batterySaverEnabled = false;
@property({ type: String })
accessor timeRemaining: string | null = null;
private boundHandleClickOutside = this.handleClickOutside.bind(this);
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly INACTIVITY_TIMEOUT = 60000; // 1 minute
private lastActivityTime = 0;
public render(): TemplateResult {
const fillClass = this.getFillClass();
return html`
<div class="menu-container"
@click=${(e: MouseEvent) => e.stopPropagation()}
@mousedown=${this.resetInactivityTimer}
>
<div class="battery-display">
<div class="battery-visual">
<div class="battery-icon">
<div
class="battery-fill ${fillClass} ${this.isCharging ? 'charging' : ''}"
style="width: ${this.batteryLevel}%"
></div>
</div>
${this.isCharging ? html`
<dees-icon class="charging-icon" .icon=${'lucide:zap'} .iconSize=${24}></dees-icon>
` : ''}
</div>
<div class="battery-percentage">${this.batteryLevel}%</div>
<div class="battery-status">
${this.isCharging ? html`
<dees-icon .icon=${'lucide:plug'} .iconSize=${14}></dees-icon>
Charging
` : this.timeRemaining ? html`
<dees-icon .icon=${'lucide:clock'} .iconSize=${14}></dees-icon>
${this.timeRemaining}
` : html`
<dees-icon .icon=${'lucide:battery'} .iconSize=${14}></dees-icon>
On Battery
`}
</div>
</div>
<div class="menu-divider"></div>
<div class="menu-option" @click=${this.handleBatterySaverToggle}>
<div class="option-label">
<dees-icon .icon=${'lucide:leaf'} .iconSize=${18}></dees-icon>
<div>
<div>Battery Saver</div>
<div class="option-description">Extends battery life</div>
</div>
</div>
<div
class="toggle-switch ${this.batterySaverEnabled ? 'active' : ''}"
@click=${(e: Event) => e.stopPropagation()}
></div>
</div>
<div class="menu-footer">
<div class="settings-link" @click=${this.handleSettingsClick}>
<dees-icon .icon=${'lucide:settings'} .iconSize=${14}></dees-icon>
Power Settings...
</div>
</div>
</div>
`;
}
private getFillClass(): string {
if (this.batteryLevel > 50) return 'good';
if (this.batteryLevel > 20) return 'medium';
return 'low';
}
private handleBatterySaverToggle(): void {
this.batterySaverEnabled = !this.batterySaverEnabled;
this.dispatchEvent(new CustomEvent('battery-saver-toggle', {
detail: { enabled: this.batterySaverEnabled },
bubbles: true,
composed: true,
}));
}
private handleSettingsClick(): void {
this.dispatchEvent(new CustomEvent('settings-click', {
bubbles: true,
composed: true,
}));
}
private handleClickOutside(e: MouseEvent): void {
if (this.open && !this.contains(e.target as Node)) {
this.closeMenu();
}
}
private resetInactivityTimer(): void {
const now = Date.now();
// Throttle: only reset if 5+ seconds since last reset
if (now - this.lastActivityTime < 5000) {
return;
}
this.lastActivityTime = now;
this.clearInactivityTimer();
if (this.open) {
this.inactivityTimeout = setTimeout(() => {
this.closeMenu();
}, this.INACTIVITY_TIMEOUT);
}
}
private clearInactivityTimer(): void {
if (this.inactivityTimeout) {
clearTimeout(this.inactivityTimeout);
this.inactivityTimeout = null;
}
}
private closeMenu(): void {
this.open = false;
this.dispatchEvent(new CustomEvent('menu-close', {
bubbles: true,
composed: true,
}));
}
protected updated(changedProperties: Map<string, unknown>): void {
if (changedProperties.has('open')) {
if (this.open) {
this.resetInactivityTimer();
} else {
this.clearInactivityTimer();
}
}
}
async connectedCallback(): Promise<void> {
await super.connectedCallback();
setTimeout(() => {
document.addEventListener('click', this.boundHandleClickOutside);
}, 0);
}
async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleClickOutside);
this.clearInactivityTimer();
}
}

View File

@@ -0,0 +1 @@
export * from './eco-applauncher-batterymenu.js';

View File

@@ -0,0 +1,99 @@
import { html } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
padding: 24px;
background: hsl(240 10% 4%);
min-height: 400px;
display: flex;
flex-direction: column;
gap: 16px;
}
.output-area {
background: hsl(240 5% 12%);
border-radius: 8px;
padding: 16px;
min-height: 100px;
color: hsl(0 0% 95%);
font-family: monospace;
font-size: 16px;
white-space: pre-wrap;
word-break: break-all;
}
.output-label {
color: hsl(0 0% 60%);
font-size: 12px;
margin-bottom: 4px;
}
.keyboard-wrapper {
height: 260px;
}
.event-log {
background: hsl(240 5% 8%);
border-radius: 8px;
padding: 12px;
color: hsl(0 0% 70%);
font-family: monospace;
font-size: 12px;
max-height: 100px;
overflow-y: auto;
}
</style>
<div class="demo-container">
<div>
<div class="output-label">Typed text:</div>
<div class="output-area" id="typed-output">|</div>
</div>
<div>
<div class="output-label">Event log:</div>
<div class="event-log" id="event-log"></div>
</div>
<div class="keyboard-wrapper">
<eco-applauncher-keyboard
visible
@key-press=${(e: CustomEvent) => {
const output = document.getElementById('typed-output');
const log = document.getElementById('event-log');
if (output && log) {
const currentText = output.textContent?.replace('|', '') || '';
if (e.detail.type === 'special') {
if (e.detail.key === 'Backspace') {
output.textContent = currentText.slice(0, -1) + '|';
} else if (e.detail.key === 'Enter') {
output.textContent = currentText + '\n|';
}
} else {
output.textContent = currentText + e.detail.key + '|';
}
log.textContent = `key-press: ${JSON.stringify(e.detail)}\n` + log.textContent;
}
}}
@backspace=${() => {
const log = document.getElementById('event-log');
if (log) {
log.textContent = `backspace\n` + log.textContent;
}
}}
@enter=${() => {
const log = document.getElementById('event-log');
if (log) {
log.textContent = `enter\n` + log.textContent;
}
}}
@space=${() => {
const log = document.getElementById('event-log');
if (log) {
log.textContent = `space\n` + log.textContent;
}
}}
@arrow=${(e: CustomEvent) => {
const log = document.getElementById('event-log');
if (log) {
log.textContent = `arrow: ${e.detail.direction}\n` + log.textContent;
}
}}
></eco-applauncher-keyboard>
</div>
</div>
`;

View File

@@ -0,0 +1,670 @@
import {
customElement,
DeesElement,
type TemplateResult,
html,
property,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import { DeesIcon } from '@design.estate/dees-catalog';
import { demo } from './eco-applauncher-keyboard.demo.js';
// Ensure components are registered
DeesIcon;
declare global {
interface HTMLElementTagNameMap {
'eco-applauncher-keyboard': EcoApplauncherKeyboard;
}
}
export type TKeyboardLayout = 'qwerty' | 'numbers' | 'symbols';
export interface IKeyConfig {
key: string;
display?: string;
width?: number; // multiplier, default 1
type?: 'char' | 'special' | 'modifier' | 'space' | 'layout';
action?: string;
}
// Long-press alternatives map
const alternativesMap: Record<string, string[]> = {
'a': ['à', 'á', 'â', 'ä', 'æ', 'ã', 'å', 'ā'],
'c': ['ç', 'ć', 'č'],
'e': ['è', 'é', 'ê', 'ë', 'ē', 'ė', 'ę'],
'i': ['î', 'ï', 'í', 'ī', 'į', 'ì'],
'n': ['ñ', 'ń'],
'o': ['ô', 'ö', 'ò', 'ó', 'œ', 'ø', 'ō', 'õ'],
's': ['ß', 'ś', 'š'],
'u': ['û', 'ü', 'ù', 'ú', 'ū'],
'y': ['ÿ'],
'z': ['ž', 'ź', 'ż'],
// Numbers
'0': ['°', '⁰'],
'1': ['¹', '½', '⅓'],
'2': ['²', '⅔'],
'3': ['³', '¾'],
// Punctuation
'-': ['', '—', '•'],
'/': ['\\'],
'$': ['€', '£', '¥', '¢'],
'&': ['§'],
'"': ['"', '"', '«', '»'],
'.': ['…'],
'?': ['¿'],
'!': ['¡'],
"'": ['\u2018', '\u2019', '`'],
};
// Keyboard layouts
const qwertyLayout: IKeyConfig[][] = [
[
{ key: 'q' }, { key: 'w' }, { key: 'e' }, { key: 'r' }, { key: 't' },
{ key: 'y' }, { key: 'u' }, { key: 'i' }, { key: 'o' }, { key: 'p' },
],
[
{ key: 'a' }, { key: 's' }, { key: 'd' }, { key: 'f' }, { key: 'g' },
{ key: 'h' }, { key: 'j' }, { key: 'k' }, { key: 'l' },
],
[
{ key: 'shift', display: '⇧', width: 1.5, type: 'modifier' },
{ key: 'z' }, { key: 'x' }, { key: 'c' }, { key: 'v' },
{ key: 'b' }, { key: 'n' }, { key: 'm' },
{ key: 'backspace', display: '⌫', width: 1.5, type: 'special' },
],
[
{ key: '123', display: '123', width: 1.5, type: 'layout', action: 'numbers' },
{ key: 'globe', display: '🌐', type: 'special' },
{ key: 'space', display: '', width: 3, type: 'space' },
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
{ key: 'up', display: '↑', type: 'special', action: 'arrow-up' },
{ key: 'down', display: '↓', type: 'special', action: 'arrow-down' },
{ key: 'right', display: '→', type: 'special', action: 'arrow-right' },
{ key: 'enter', display: '↵', width: 1.5, type: 'special' },
],
];
const numbersLayout: IKeyConfig[][] = [
[
{ key: '1' }, { key: '2' }, { key: '3' }, { key: '4' }, { key: '5' },
{ key: '6' }, { key: '7' }, { key: '8' }, { key: '9' }, { key: '0' },
],
[
{ key: '-' }, { key: '/' }, { key: ':' }, { key: ';' }, { key: '(' },
{ key: ')' }, { key: '$' }, { key: '&' }, { key: '@' }, { key: '"' },
],
[
{ key: '#+=', display: '#+=' , width: 1.5, type: 'layout', action: 'symbols' },
{ key: '.' }, { key: ',' }, { key: '?' }, { key: '!' }, { key: "'" },
{ key: 'backspace', display: '⌫', width: 2.5, type: 'special' },
],
[
{ key: 'ABC', display: 'ABC', width: 1.5, type: 'layout', action: 'qwerty' },
{ key: 'globe', display: '🌐', type: 'special' },
{ key: 'space', display: '', width: 3, type: 'space' },
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
{ key: 'up', display: '↑', type: 'special', action: 'arrow-up' },
{ key: 'down', display: '↓', type: 'special', action: 'arrow-down' },
{ key: 'right', display: '→', type: 'special', action: 'arrow-right' },
{ key: 'enter', display: '↵', width: 1.5, type: 'special' },
],
];
const symbolsLayout: IKeyConfig[][] = [
[
{ key: '[' }, { key: ']' }, { key: '{' }, { key: '}' }, { key: '#' },
{ key: '%' }, { key: '^' }, { key: '*' }, { key: '+' }, { key: '=' },
],
[
{ key: '_' }, { key: '\\' }, { key: '|' }, { key: '~' }, { key: '<' },
{ key: '>' }, { key: '€' }, { key: '£' }, { key: '¥' }, { key: '•' },
],
[
{ key: '123', display: '123', width: 1.5, type: 'layout', action: 'numbers' },
{ key: '.' }, { key: ',' }, { key: '?' }, { key: '!' }, { key: "'" },
{ key: 'backspace', display: '⌫', width: 2.5, type: 'special' },
],
[
{ key: 'ABC', display: 'ABC', width: 1.5, type: 'layout', action: 'qwerty' },
{ key: 'globe', display: '🌐', type: 'special' },
{ key: 'space', display: '', width: 3, type: 'space' },
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
{ key: 'up', display: '↑', type: 'special', action: 'arrow-up' },
{ key: 'down', display: '↓', type: 'special', action: 'arrow-down' },
{ key: 'right', display: '→', type: 'special', action: 'arrow-right' },
{ key: 'enter', display: '↵', width: 1.5, type: 'special' },
],
];
const layouts: Record<TKeyboardLayout, IKeyConfig[][]> = {
qwerty: qwertyLayout,
numbers: numbersLayout,
symbols: symbolsLayout,
};
@customElement('eco-applauncher-keyboard')
export class EcoApplauncherKeyboard extends DeesElement {
public static demo = demo;
public static demoGroup = 'App Launcher';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
}
:host(:not([visible])) {
display: none;
}
.keyboard-container {
background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 6% 12%)')};
padding: 8px 4px;
height: 100%;
display: flex;
flex-direction: column;
gap: 6px;
box-sizing: border-box;
position: relative;
}
.keyboard-row {
display: flex;
justify-content: center;
gap: 4px;
flex: 1;
}
.keyboard-row.offset {
padding-left: 16px;
}
.key {
flex: 1;
max-width: 40px;
height: 100%;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 22%)')};
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')};
cursor: pointer;
user-select: none;
transition: background 0.1s ease, transform 0.05s ease;
box-shadow: 0 1px 3px ${cssManager.bdTheme('rgba(0, 0, 0, 0.15)', 'rgba(0, 0, 0, 0.4)')};
text-transform: none;
-webkit-tap-highlight-color: transparent;
}
.key:active,
.key.pressed {
transform: scale(0.95);
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;
}
.key.modifier {
background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')};
font-size: 16px;
}
.key.modifier.active {
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
color: white;
}
.key.layout {
background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')};
font-size: 14px;
font-weight: 600;
}
.key.space {
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 22%)')};
}
.key.wide-1-5 {
flex: 1.5;
max-width: 60px;
}
.key.wide-2 {
flex: 2;
max-width: 80px;
}
.key.wide-2-5 {
flex: 2.5;
max-width: 100px;
}
.key.wide-3 {
flex: 3;
max-width: 140px;
}
.key.wide-4 {
flex: 4;
max-width: 180px;
}
/* Alternatives popup */
.alternatives-popup {
position: absolute;
display: flex;
gap: 2px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 6% 18%)')};
border-radius: 10px;
padding: 6px;
box-shadow: 0 4px 20px ${cssManager.bdTheme('rgba(0, 0, 0, 0.25)', 'rgba(0, 0, 0, 0.6)')};
z-index: 10000;
pointer-events: none;
}
.alternative-key {
min-width: 36px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 20px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
transition: background 0.1s ease;
}
.alternative-key.selected {
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
color: white;
}
/* Key preview on press */
.key-preview {
position: absolute;
width: 48px;
height: 56px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 25%)')};
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')};
box-shadow: 0 4px 16px ${cssManager.bdTheme('rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)')};
z-index: 10000;
pointer-events: none;
}
`,
];
@property({ type: Boolean, reflect: true })
accessor visible = false;
@property({ type: String })
accessor layout: TKeyboardLayout = 'qwerty';
@state()
accessor shiftActive = false;
@state()
accessor capsLock = false;
@state()
accessor alternativesPopup: {
key: string;
alternatives: string[];
x: number;
y: number;
selectedIndex: number;
} | null = null;
@state()
accessor keyPreview: { key: string; x: number; y: number } | null = null;
private longPressTimer: ReturnType<typeof setTimeout> | null = null;
private longPressStartX = 0;
private currentLongPressKey: string | null = null;
public render(): TemplateResult {
const currentLayout = layouts[this.layout];
return html`
<div class="keyboard-container">
${currentLayout.map((row, rowIndex) => this.renderRow(row, rowIndex))}
${this.alternativesPopup ? this.renderAlternativesPopup() : ''}
${this.keyPreview ? this.renderKeyPreview() : ''}
</div>
`;
}
private renderRow(row: IKeyConfig[], rowIndex: number): TemplateResult {
const isSecondRow = rowIndex === 1 && this.layout === 'qwerty';
return html`
<div class="keyboard-row ${isSecondRow ? 'offset' : ''}">
${row.map((keyConfig) => this.renderKey(keyConfig))}
</div>
`;
}
private renderKey(config: IKeyConfig): TemplateResult {
const type = config.type || 'char';
const widthClass = config.width ? `wide-${String(config.width).replace('.', '-')}` : '';
const isShift = config.key === 'shift';
const isActive = isShift && (this.shiftActive || this.capsLock);
let displayValue = config.display ?? config.key;
if (type === 'char' && this.layout === 'qwerty') {
displayValue = (this.shiftActive || this.capsLock) ? displayValue.toUpperCase() : displayValue.toLowerCase();
}
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>
`;
}
private renderAlternativesPopup(): TemplateResult {
if (!this.alternativesPopup) return html``;
const { alternatives, x, y, selectedIndex } = this.alternativesPopup;
const popupWidth = alternatives.length * 40;
return html`
<div
class="alternatives-popup"
style="left: ${x - popupWidth / 2}px; top: ${y - 60}px;"
>
${alternatives.map((alt, index) => html`
<div class="alternative-key ${index === selectedIndex ? 'selected' : ''}">
${alt}
</div>
`)}
</div>
`;
}
private renderKeyPreview(): TemplateResult {
if (!this.keyPreview) return html``;
const { key, x, y } = this.keyPreview;
return html`
<div class="key-preview" style="left: ${x - 24}px; top: ${y - 70}px;">
${(this.shiftActive || this.capsLock) ? key.toUpperCase() : key}
</div>
`;
}
private handlePointerDown(e: PointerEvent, config: IKeyConfig): void {
e.preventDefault();
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
const type = config.type || 'char';
// Show key preview for character keys
if (type === 'char') {
const rect = target.getBoundingClientRect();
const containerRect = this.shadowRoot!.querySelector('.keyboard-container')!.getBoundingClientRect();
this.keyPreview = {
key: config.key,
x: rect.left + rect.width / 2 - containerRect.left,
y: rect.top - containerRect.top,
};
}
// Start long press timer for keys with alternatives
const keyLower = config.key.toLowerCase();
if (alternativesMap[keyLower] && type === 'char') {
this.longPressStartX = e.clientX;
this.currentLongPressKey = keyLower;
this.longPressTimer = setTimeout(() => {
const alternatives = alternativesMap[keyLower];
if (alternatives) {
const rect = target.getBoundingClientRect();
const containerRect = this.shadowRoot!.querySelector('.keyboard-container')!.getBoundingClientRect();
this.alternativesPopup = {
key: keyLower,
alternatives,
x: rect.left + rect.width / 2 - containerRect.left,
y: rect.top - containerRect.top,
selectedIndex: -1,
};
this.keyPreview = null;
}
}, 500);
}
}
private handlePointerMove(e: PointerEvent, config: IKeyConfig): void {
if (this.alternativesPopup) {
// Calculate which alternative is being hovered based on x position
const deltaX = e.clientX - this.longPressStartX;
const alternatives = this.alternativesPopup.alternatives;
const keyWidth = 40;
const totalWidth = alternatives.length * keyWidth;
const startX = -totalWidth / 2;
// Map deltaX to an index
const relativeX = deltaX - startX;
const index = Math.floor(relativeX / keyWidth);
const clampedIndex = Math.max(-1, Math.min(alternatives.length - 1, index));
if (clampedIndex !== this.alternativesPopup.selectedIndex) {
this.alternativesPopup = {
...this.alternativesPopup,
selectedIndex: clampedIndex,
};
}
}
}
private handlePointerUp(e: PointerEvent, config: IKeyConfig): void {
e.preventDefault();
e.stopPropagation();
this.clearLongPressTimer();
this.keyPreview = null;
const type = config.type || 'char';
// Handle alternatives selection
if (this.alternativesPopup) {
const { selectedIndex, alternatives, key } = this.alternativesPopup;
if (selectedIndex >= 0 && selectedIndex < alternatives.length) {
this.emitKeyPress(alternatives[selectedIndex]);
} else {
// No alternative selected, emit original key
this.emitKeyPress(key);
}
this.alternativesPopup = null;
this.handleShiftAfterKeyPress();
return;
}
// Handle different key types
switch (type) {
case 'char':
const char = (this.shiftActive || this.capsLock)
? config.key.toUpperCase()
: config.key.toLowerCase();
this.emitKeyPress(char);
this.handleShiftAfterKeyPress();
break;
case 'special':
this.handleSpecialKey(config);
break;
case 'modifier':
this.handleModifierKey(config);
break;
case 'space':
this.dispatchEvent(new CustomEvent('space', {
bubbles: true,
composed: true,
}));
this.dispatchEvent(new CustomEvent('key-press', {
detail: { key: ' ', type: 'space' },
bubbles: true,
composed: true,
}));
break;
case 'layout':
this.handleLayoutChange(config);
break;
}
}
private handlePointerLeave(e: PointerEvent, config: IKeyConfig): void {
if (!this.alternativesPopup) {
this.clearLongPressTimer();
this.keyPreview = null;
}
}
private clearLongPressTimer(): void {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
this.currentLongPressKey = null;
}
private emitKeyPress(key: string): void {
this.dispatchEvent(new CustomEvent('key-press', {
detail: { key, type: 'char' },
bubbles: true,
composed: true,
}));
}
private handleSpecialKey(config: IKeyConfig): void {
switch (config.key) {
case 'backspace':
this.dispatchEvent(new CustomEvent('backspace', {
bubbles: true,
composed: true,
}));
this.dispatchEvent(new CustomEvent('key-press', {
detail: { key: 'Backspace', type: 'special' },
bubbles: true,
composed: true,
}));
break;
case 'enter':
this.dispatchEvent(new CustomEvent('enter', {
bubbles: true,
composed: true,
}));
this.dispatchEvent(new CustomEvent('key-press', {
detail: { key: 'Enter', type: 'special' },
bubbles: true,
composed: true,
}));
break;
case 'left':
this.dispatchEvent(new CustomEvent('arrow', {
detail: { direction: 'left' },
bubbles: true,
composed: true,
}));
break;
case 'right':
this.dispatchEvent(new CustomEvent('arrow', {
detail: { direction: 'right' },
bubbles: true,
composed: true,
}));
break;
case 'up':
this.dispatchEvent(new CustomEvent('arrow', {
detail: { direction: 'up' },
bubbles: true,
composed: true,
}));
break;
case 'down':
this.dispatchEvent(new CustomEvent('arrow', {
detail: { direction: 'down' },
bubbles: true,
composed: true,
}));
break;
case 'globe':
// Could be used for language switching
this.dispatchEvent(new CustomEvent('globe-press', {
bubbles: true,
composed: true,
}));
break;
}
}
private handleModifierKey(config: IKeyConfig): void {
if (config.key === 'shift') {
if (this.capsLock) {
// If caps lock is on, turn it off
this.capsLock = false;
this.shiftActive = false;
} else if (this.shiftActive) {
// Double tap shift = caps lock
this.capsLock = true;
this.shiftActive = false;
} else {
// Single tap = shift
this.shiftActive = true;
}
}
}
private handleShiftAfterKeyPress(): void {
// Turn off shift after typing (unless caps lock is on)
if (this.shiftActive && !this.capsLock) {
this.shiftActive = false;
}
}
private handleLayoutChange(config: IKeyConfig): void {
const action = config.action as TKeyboardLayout;
if (action && layouts[action]) {
this.layout = action;
}
}
}

View File

@@ -0,0 +1 @@
export * from './eco-applauncher-keyboard.js';

View File

@@ -0,0 +1,20 @@
import { html } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
padding: 48px;
background: hsl(240 10% 4%);
min-height: 300px;
display: flex;
justify-content: center;
}
</style>
<div class="demo-container">
<eco-applauncher-powermenu
open
@power-action=${(e: CustomEvent) => console.log('Power action:', e.detail.action)}
@menu-close=${() => console.log('Menu closed')}
></eco-applauncher-powermenu>
</div>
`;

View File

@@ -0,0 +1,261 @@
import {
customElement,
DeesElement,
type TemplateResult,
html,
property,
css,
cssManager,
} from '@design.estate/dees-element';
import { DeesIcon } from '@design.estate/dees-catalog';
import { demo } from './eco-applauncher-powermenu.demo.js';
// Ensure dees-icon is registered
DeesIcon;
declare global {
interface HTMLElementTagNameMap {
'eco-applauncher-powermenu': EcoApplauncherPowermenu;
}
}
export type TPowerAction = 'lock' | 'lock-sleep' | 'reboot';
@customElement('eco-applauncher-powermenu')
export class EcoApplauncherPowermenu extends DeesElement {
public static demo = demo;
public static demoGroup = 'App Launcher';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
pointer-events: none;
}
:host([open]) {
pointer-events: auto;
}
.menu-container {
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 10%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 20%)')};
border-radius: 12px;
box-shadow: ${cssManager.bdTheme(
'0 8px 32px rgba(0, 0, 0, 0.15)',
'0 8px 32px rgba(0, 0, 0, 0.4)'
)};
min-width: 200px;
overflow: hidden;
opacity: 0;
transform: scale(0.95) translateY(-8px);
transition: all 0.2s ease-out;
pointer-events: none;
}
:host([open]) .menu-container {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.menu-header {
padding: 12px 16px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
}
.menu-options {
padding: 8px 0;
}
.menu-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background 0.15s ease;
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
}
.menu-option:hover {
background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(240 5% 15%)')};
}
.menu-option:active {
background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 18%)')};
}
.menu-option.danger {
color: ${cssManager.bdTheme('hsl(0 72% 45%)', 'hsl(0 72% 60%)')};
}
.menu-option.danger:hover {
background: ${cssManager.bdTheme('hsl(0 72% 97%)', 'hsl(0 50% 15%)')};
}
.option-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(240 5% 18%)')};
}
.menu-option.danger .option-icon {
background: ${cssManager.bdTheme('hsl(0 72% 94%)', 'hsl(0 50% 18%)')};
}
.option-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.option-label {
font-size: 14px;
font-weight: 500;
}
.option-description {
font-size: 11px;
color: ${cssManager.bdTheme('hsl(0 0% 55%)', 'hsl(0 0% 50%)')};
}
.menu-divider {
height: 1px;
background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
margin: 4px 0;
}
`,
];
@property({ type: Boolean, reflect: true })
accessor open = false;
private boundHandleClickOutside = this.handleClickOutside.bind(this);
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly INACTIVITY_TIMEOUT = 60000; // 1 minute
private lastActivityTime = 0;
public render(): TemplateResult {
return html`
<div class="menu-container"
@click=${(e: MouseEvent) => e.stopPropagation()}
@mousedown=${this.resetInactivityTimer}
>
<div class="menu-header">Power</div>
<div class="menu-options">
<div class="menu-option" @click=${() => this.handleAction('lock')}>
<div class="option-icon">
<dees-icon .icon=${'lucide:lock'} .iconSize=${16}></dees-icon>
</div>
<div class="option-text">
<span class="option-label">Lock</span>
<span class="option-description">Lock the screen</span>
</div>
</div>
<div class="menu-option" @click=${() => this.handleAction('lock-sleep')}>
<div class="option-icon">
<dees-icon .icon=${'lucide:moon'} .iconSize=${16}></dees-icon>
</div>
<div class="option-text">
<span class="option-label">Lock + Sleep</span>
<span class="option-description">Lock and turn off display</span>
</div>
</div>
<div class="menu-divider"></div>
<div class="menu-option danger" @click=${() => this.handleAction('reboot')}>
<div class="option-icon">
<dees-icon .icon=${'lucide:refreshCw'} .iconSize=${16}></dees-icon>
</div>
<div class="option-text">
<span class="option-label">Reboot</span>
<span class="option-description">Restart the system</span>
</div>
</div>
</div>
</div>
`;
}
private handleAction(action: TPowerAction): void {
this.dispatchEvent(new CustomEvent('power-action', {
detail: { action },
bubbles: true,
composed: true,
}));
this.closeMenu();
}
private handleClickOutside(e: MouseEvent): void {
if (this.open && !this.contains(e.target as Node)) {
this.closeMenu();
}
}
private resetInactivityTimer(): void {
const now = Date.now();
// Throttle: only reset if 5+ seconds since last reset
if (now - this.lastActivityTime < 5000) {
return;
}
this.lastActivityTime = now;
this.clearInactivityTimer();
if (this.open) {
this.inactivityTimeout = setTimeout(() => {
this.closeMenu();
}, this.INACTIVITY_TIMEOUT);
}
}
private clearInactivityTimer(): void {
if (this.inactivityTimeout) {
clearTimeout(this.inactivityTimeout);
this.inactivityTimeout = null;
}
}
private closeMenu(): void {
this.open = false;
this.dispatchEvent(new CustomEvent('menu-close', {
bubbles: true,
composed: true,
}));
}
protected updated(changedProperties: Map<string, unknown>): void {
if (changedProperties.has('open')) {
if (this.open) {
this.resetInactivityTimer();
} else {
this.clearInactivityTimer();
}
}
}
async connectedCallback(): Promise<void> {
await super.connectedCallback();
setTimeout(() => {
document.addEventListener('click', this.boundHandleClickOutside);
}, 0);
}
async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleClickOutside);
this.clearInactivityTimer();
}
}

View File

@@ -0,0 +1 @@
export * from './eco-applauncher-powermenu.js';

View File

@@ -0,0 +1,33 @@
import { html } from '@design.estate/dees-element';
import type { IAudioDevice } from './eco-applauncher-soundmenu.js';
const mockDevices: IAudioDevice[] = [
{ id: 'speakers', name: 'Built-in Speakers', type: 'speaker' },
{ id: 'headphones', name: 'AirPods Pro', type: 'bluetooth' },
{ id: 'hdmi', name: 'LG Monitor', type: 'hdmi' },
];
export const demo = () => html`
<style>
.demo-container {
padding: 48px;
background: hsl(240 10% 4%);
min-height: 400px;
display: flex;
justify-content: center;
}
</style>
<div class="demo-container">
<eco-applauncher-soundmenu
open
.volume=${70}
.muted=${false}
.outputDevices=${mockDevices}
.activeDeviceId=${'speakers'}
@volume-change=${(e: CustomEvent) => console.log('Volume:', e.detail)}
@mute-toggle=${(e: CustomEvent) => console.log('Mute:', e.detail)}
@device-select=${(e: CustomEvent) => console.log('Device:', e.detail)}
@settings-click=${() => console.log('Settings clicked')}
></eco-applauncher-soundmenu>
</div>
`;

View File

@@ -0,0 +1,493 @@
import {
customElement,
DeesElement,
type TemplateResult,
html,
property,
css,
cssManager,
} from '@design.estate/dees-element';
import { DeesIcon } from '@design.estate/dees-catalog';
import { demo } from './eco-applauncher-soundmenu.demo.js';
// Ensure dees-icon is registered
DeesIcon;
declare global {
interface HTMLElementTagNameMap {
'eco-applauncher-soundmenu': EcoApplauncherSoundmenu;
}
}
export interface IAudioDevice {
id: string;
name: string;
type: 'speaker' | 'headphones' | 'bluetooth' | 'hdmi';
active?: boolean;
}
@customElement('eco-applauncher-soundmenu')
export class EcoApplauncherSoundmenu extends DeesElement {
public static demo = demo;
public static demoGroup = 'App Launcher';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
pointer-events: none;
}
:host([open]) {
pointer-events: auto;
}
.menu-container {
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 10%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 20%)')};
border-radius: 12px;
box-shadow: ${cssManager.bdTheme(
'0 8px 32px rgba(0, 0, 0, 0.15)',
'0 8px 32px rgba(0, 0, 0, 0.4)'
)};
min-width: 280px;
overflow: hidden;
opacity: 0;
transform: scale(0.95) translateY(-8px);
transition: all 0.2s ease-out;
pointer-events: none;
}
:host([open]) .menu-container {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.menu-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
}
.menu-title {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
display: flex;
align-items: center;
gap: 10px;
}
.volume-section {
padding: 20px 16px;
}
.volume-slider-container {
display: flex;
align-items: center;
gap: 12px;
}
.volume-icon {
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 70%)')};
cursor: pointer;
transition: color 0.15s ease;
}
.volume-icon:hover {
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
}
.volume-icon.muted {
color: hsl(0 72% 51%);
}
.volume-slider {
flex: 1;
height: 6px;
background: ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(240 5% 20%)')};
border-radius: 3px;
position: relative;
cursor: pointer;
}
.volume-fill {
height: 100%;
background: hsl(217 91% 60%);
border-radius: 3px;
transition: width 0.1s ease;
}
.volume-fill.muted {
background: ${cssManager.bdTheme('hsl(0 0% 70%)', 'hsl(0 0% 40%)')};
}
.volume-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
cursor: grab;
}
.volume-thumb:active {
cursor: grabbing;
}
.volume-percentage {
min-width: 36px;
text-align: right;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
}
.menu-divider {
height: 1px;
background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
}
.section-title {
padding: 12px 16px 8px;
font-size: 12px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 50%)')};
text-transform: uppercase;
letter-spacing: 0.5px;
}
.device-list {
max-height: 160px;
overflow-y: auto;
}
.device-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
cursor: pointer;
transition: background 0.15s ease;
}
.device-item:hover {
background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(240 5% 15%)')};
}
.device-item.active {
background: ${cssManager.bdTheme('hsl(217 91% 95%)', 'hsl(217 91% 60% / 0.15)')};
}
.device-item.active:hover {
background: ${cssManager.bdTheme('hsl(217 91% 92%)', 'hsl(217 91% 60% / 0.25)')};
}
.device-icon {
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
}
.device-item.active .device-icon {
color: hsl(217 91% 60%);
}
.device-name {
flex: 1;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
}
.device-check {
color: hsl(217 91% 60%);
}
.menu-footer {
padding: 12px 16px;
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
}
.settings-link {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: hsl(217 91% 60%);
cursor: pointer;
transition: color 0.15s ease;
}
.settings-link:hover {
color: hsl(217 91% 50%);
}
`,
];
@property({ type: Boolean, reflect: true })
accessor open = false;
@property({ type: Number })
accessor volume = 50;
@property({ type: Boolean })
accessor muted = false;
@property({ type: Array })
accessor outputDevices: IAudioDevice[] = [];
@property({ type: String })
accessor activeDeviceId: string | null = null;
private boundHandleClickOutside = this.handleClickOutside.bind(this);
private isDragging = false;
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly INACTIVITY_TIMEOUT = 60000; // 1 minute
private lastActivityTime = 0;
public render(): TemplateResult {
const volumeIcon = this.getVolumeIcon();
return html`
<div class="menu-container"
@click=${(e: MouseEvent) => e.stopPropagation()}
@mousedown=${this.resetInactivityTimer}
>
<div class="menu-header">
<span class="menu-title">
<dees-icon .icon=${volumeIcon} .iconSize=${18}></dees-icon>
Sound
</span>
</div>
<div class="volume-section">
<div class="volume-slider-container">
<dees-icon
class="volume-icon ${this.muted ? 'muted' : ''}"
.icon=${this.muted ? 'lucide:volumeX' : 'lucide:volume2'}
.iconSize=${20}
@click=${this.handleMuteToggle}
></dees-icon>
<div
class="volume-slider"
@click=${this.handleSliderClick}
@mousedown=${this.handleSliderMouseDown}
>
<div
class="volume-fill ${this.muted ? 'muted' : ''}"
style="width: ${this.muted ? 0 : this.volume}%"
></div>
<div
class="volume-thumb"
style="left: ${this.muted ? 0 : this.volume}%"
></div>
</div>
<span class="volume-percentage">${this.muted ? 0 : this.volume}%</span>
</div>
</div>
${this.outputDevices.length > 0 ? html`
<div class="menu-divider"></div>
<div class="section-title">Output</div>
<div class="device-list">
${this.outputDevices.map((device) => this.renderDeviceItem(device))}
</div>
` : ''}
<div class="menu-footer">
<div class="settings-link" @click=${this.handleSettingsClick}>
<dees-icon .icon=${'lucide:settings'} .iconSize=${14}></dees-icon>
Sound Settings...
</div>
</div>
</div>
`;
}
private renderDeviceItem(device: IAudioDevice): TemplateResult {
const isActive = device.id === this.activeDeviceId;
const icon = this.getDeviceIcon(device.type);
return html`
<div
class="device-item ${isActive ? 'active' : ''}"
@click=${() => this.handleDeviceSelect(device)}
>
<dees-icon class="device-icon" .icon=${icon} .iconSize=${18}></dees-icon>
<span class="device-name">${device.name}</span>
${isActive ? html`
<dees-icon class="device-check" .icon=${'lucide:check'} .iconSize=${16}></dees-icon>
` : ''}
</div>
`;
}
private getVolumeIcon(): string {
if (this.muted || this.volume === 0) return 'lucide:volumeX';
if (this.volume < 33) return 'lucide:volume';
if (this.volume < 66) return 'lucide:volume1';
return 'lucide:volume2';
}
private getDeviceIcon(type: IAudioDevice['type']): string {
switch (type) {
case 'headphones':
return 'lucide:headphones';
case 'bluetooth':
return 'lucide:bluetooth';
case 'hdmi':
return 'lucide:monitor';
case 'speaker':
default:
return 'lucide:speaker';
}
}
private handleMuteToggle(): void {
this.muted = !this.muted;
this.dispatchEvent(new CustomEvent('mute-toggle', {
detail: { muted: this.muted },
bubbles: true,
composed: true,
}));
}
private handleSliderClick(e: MouseEvent): void {
const slider = e.currentTarget as HTMLElement;
const rect = slider.getBoundingClientRect();
const percentage = Math.round(((e.clientX - rect.left) / rect.width) * 100);
this.setVolume(Math.max(0, Math.min(100, percentage)));
}
private handleSliderMouseDown(e: MouseEvent): void {
this.isDragging = true;
const slider = e.currentTarget as HTMLElement;
let rafId: number | null = null;
let pendingPercentage: number | null = null;
const updateVolume = () => {
if (pendingPercentage !== null) {
this.setVolume(pendingPercentage);
pendingPercentage = null;
}
rafId = null;
};
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!this.isDragging) return;
const rect = slider.getBoundingClientRect();
pendingPercentage = Math.max(0, Math.min(100, Math.round(((moveEvent.clientX - rect.left) / rect.width) * 100)));
if (!rafId) {
rafId = requestAnimationFrame(updateVolume);
}
};
const handleMouseUp = () => {
this.isDragging = false;
if (rafId) {
cancelAnimationFrame(rafId);
}
if (pendingPercentage !== null) {
this.setVolume(pendingPercentage);
}
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
private setVolume(value: number): void {
this.volume = value;
if (this.muted && value > 0) {
this.muted = false;
}
this.dispatchEvent(new CustomEvent('volume-change', {
detail: { volume: this.volume },
bubbles: true,
composed: true,
}));
}
private handleDeviceSelect(device: IAudioDevice): void {
this.activeDeviceId = device.id;
this.dispatchEvent(new CustomEvent('device-select', {
detail: { device },
bubbles: true,
composed: true,
}));
}
private handleSettingsClick(): void {
this.dispatchEvent(new CustomEvent('settings-click', {
bubbles: true,
composed: true,
}));
}
private handleClickOutside(e: MouseEvent): void {
if (this.open && !this.contains(e.target as Node)) {
this.closeMenu();
}
}
private resetInactivityTimer(): void {
const now = Date.now();
// Throttle: only reset if 5+ seconds since last reset
if (now - this.lastActivityTime < 5000) {
return;
}
this.lastActivityTime = now;
this.clearInactivityTimer();
if (this.open) {
this.inactivityTimeout = setTimeout(() => {
this.closeMenu();
}, this.INACTIVITY_TIMEOUT);
}
}
private clearInactivityTimer(): void {
if (this.inactivityTimeout) {
clearTimeout(this.inactivityTimeout);
this.inactivityTimeout = null;
}
}
private closeMenu(): void {
this.open = false;
this.dispatchEvent(new CustomEvent('menu-close', {
bubbles: true,
composed: true,
}));
}
protected updated(changedProperties: Map<string, unknown>): void {
if (changedProperties.has('open')) {
if (this.open) {
this.resetInactivityTimer();
} else {
this.clearInactivityTimer();
}
}
}
async connectedCallback(): Promise<void> {
await super.connectedCallback();
setTimeout(() => {
document.addEventListener('click', this.boundHandleClickOutside);
}, 0);
}
async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleClickOutside);
this.clearInactivityTimer();
}
}

View File

@@ -0,0 +1 @@
export * from './eco-applauncher-soundmenu.js';

View File

@@ -0,0 +1,33 @@
import { html } from '@design.estate/dees-element';
import type { IWifiNetwork } from './eco-applauncher-wifimenu.js';
const mockNetworks: IWifiNetwork[] = [
{ ssid: 'HomeNetwork', signalStrength: 95, secured: true },
{ ssid: 'OfficeWiFi', signalStrength: 75, secured: true },
{ ssid: 'CoffeeShop_Guest', signalStrength: 60, secured: false },
{ ssid: 'Neighbor_5G', signalStrength: 40, secured: true },
{ ssid: 'WeakSignal', signalStrength: 15, secured: true },
];
export const demo = () => html`
<style>
.demo-container {
padding: 48px;
background: hsl(240 10% 4%);
min-height: 400px;
display: flex;
justify-content: center;
}
</style>
<div class="demo-container">
<eco-applauncher-wifimenu
open
.networks=${mockNetworks}
.connectedNetwork=${'HomeNetwork'}
.wifiEnabled=${true}
@wifi-toggle=${(e: CustomEvent) => console.log('WiFi toggle:', e.detail)}
@network-select=${(e: CustomEvent) => console.log('Network selected:', e.detail)}
@settings-click=${() => console.log('Settings clicked')}
></eco-applauncher-wifimenu>
</div>
`;

View File

@@ -0,0 +1,403 @@
import {
customElement,
DeesElement,
type TemplateResult,
html,
property,
css,
cssManager,
} from '@design.estate/dees-element';
import { DeesIcon } from '@design.estate/dees-catalog';
import { demo } from './eco-applauncher-wifimenu.demo.js';
// Ensure dees-icon is registered
DeesIcon;
declare global {
interface HTMLElementTagNameMap {
'eco-applauncher-wifimenu': EcoApplauncherWifimenu;
}
}
export interface IWifiNetwork {
ssid: string;
signalStrength: number; // 0-100
secured: boolean;
connected?: boolean;
}
@customElement('eco-applauncher-wifimenu')
export class EcoApplauncherWifimenu extends DeesElement {
public static demo = demo;
public static demoGroup = 'App Launcher';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
pointer-events: none;
}
:host([open]) {
pointer-events: auto;
}
.menu-container {
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 10%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 20%)')};
border-radius: 12px;
box-shadow: ${cssManager.bdTheme(
'0 8px 32px rgba(0, 0, 0, 0.15)',
'0 8px 32px rgba(0, 0, 0, 0.4)'
)};
min-width: 280px;
overflow: hidden;
opacity: 0;
transform: scale(0.95) translateY(-8px);
transition: all 0.2s ease-out;
pointer-events: none;
}
:host([open]) .menu-container {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.menu-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
}
.menu-title {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
display: flex;
align-items: center;
gap: 10px;
}
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
background: ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(240 5% 25%)')};
border-radius: 12px;
cursor: pointer;
transition: background 0.2s ease;
}
.toggle-switch.active {
background: hsl(217 91% 60%);
}
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: ${cssManager.bdTheme('0 1px 3px rgba(0,0,0,0.2)', 'none')};
}
.toggle-switch.active::after {
transform: translateX(20px);
}
.network-list {
max-height: 240px;
overflow-y: auto;
}
.network-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background 0.15s ease;
}
.network-item:hover {
background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(240 5% 15%)')};
}
.network-item.connected {
background: ${cssManager.bdTheme('hsl(217 91% 95%)', 'hsl(217 91% 60% / 0.15)')};
}
.network-item.connected:hover {
background: ${cssManager.bdTheme('hsl(217 91% 92%)', 'hsl(217 91% 60% / 0.25)')};
}
.signal-bars {
display: flex;
align-items: flex-end;
gap: 2px;
height: 16px;
width: 20px;
}
.signal-bar {
width: 4px;
background: ${cssManager.bdTheme('hsl(0 0% 80%)', 'hsl(0 0% 40%)')};
border-radius: 1px;
transition: background 0.2s ease;
}
.signal-bar.active {
background: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 90%)')};
}
.signal-bar:nth-child(1) { height: 4px; }
.signal-bar:nth-child(2) { height: 8px; }
.signal-bar:nth-child(3) { height: 12px; }
.signal-bar:nth-child(4) { height: 16px; }
.network-info {
flex: 1;
min-width: 0;
}
.network-name {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.network-status {
font-size: 12px;
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
margin-top: 2px;
}
.network-secured {
color: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 50%)')};
}
.menu-footer {
padding: 12px 16px;
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
}
.settings-link {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: hsl(217 91% 60%);
cursor: pointer;
transition: color 0.15s ease;
}
.settings-link:hover {
color: hsl(217 91% 50%);
}
.disabled-message {
padding: 32px 16px;
text-align: center;
color: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 50%)')};
font-size: 14px;
}
`,
];
@property({ type: Boolean, reflect: true })
accessor open = false;
@property({ type: Array })
accessor networks: IWifiNetwork[] = [];
@property({ type: String })
accessor connectedNetwork: string | null = null;
@property({ type: Boolean })
accessor wifiEnabled = true;
private boundHandleClickOutside = this.handleClickOutside.bind(this);
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly INACTIVITY_TIMEOUT = 60000; // 1 minute
private lastActivityTime = 0;
public render(): TemplateResult {
return html`
<div class="menu-container"
@click=${(e: MouseEvent) => e.stopPropagation()}
@mousedown=${this.resetInactivityTimer}
>
<div class="menu-header">
<span class="menu-title">
<dees-icon .icon=${'lucide:wifi'} .iconSize=${18}></dees-icon>
Wi-Fi
</span>
<div
class="toggle-switch ${this.wifiEnabled ? 'active' : ''}"
@click=${this.handleToggleWifi}
></div>
</div>
${this.wifiEnabled ? this.renderNetworkList() : this.renderDisabledMessage()}
<div class="menu-footer">
<div class="settings-link" @click=${this.handleSettingsClick}>
<dees-icon .icon=${'lucide:settings'} .iconSize=${14}></dees-icon>
Wi-Fi Settings...
</div>
</div>
</div>
`;
}
private renderNetworkList(): TemplateResult {
const sortedNetworks = [...this.networks].sort((a, b) => {
// Connected network first, then by signal strength
if (a.ssid === this.connectedNetwork) return -1;
if (b.ssid === this.connectedNetwork) return 1;
return b.signalStrength - a.signalStrength;
});
return html`
<div class="network-list">
${sortedNetworks.map((network) => this.renderNetworkItem(network))}
</div>
`;
}
private renderNetworkItem(network: IWifiNetwork): TemplateResult {
const isConnected = network.ssid === this.connectedNetwork;
const signalBars = this.getSignalBars(network.signalStrength);
return html`
<div
class="network-item ${isConnected ? 'connected' : ''}"
@click=${() => this.handleNetworkSelect(network)}
>
<div class="signal-bars">
${[1, 2, 3, 4].map((bar) => html`
<div class="signal-bar ${bar <= signalBars ? 'active' : ''}"></div>
`)}
</div>
<div class="network-info">
<div class="network-name">${network.ssid}</div>
${isConnected ? html`<div class="network-status">Connected</div>` : ''}
</div>
${network.secured ? html`
<dees-icon class="network-secured" .icon=${'lucide:lock'} .iconSize=${14}></dees-icon>
` : ''}
</div>
`;
}
private renderDisabledMessage(): TemplateResult {
return html`
<div class="disabled-message">
Wi-Fi is turned off
</div>
`;
}
private getSignalBars(strength: number): number {
if (strength >= 75) return 4;
if (strength >= 50) return 3;
if (strength >= 25) return 2;
return 1;
}
private handleToggleWifi(): void {
this.wifiEnabled = !this.wifiEnabled;
this.dispatchEvent(new CustomEvent('wifi-toggle', {
detail: { enabled: this.wifiEnabled },
bubbles: true,
composed: true,
}));
}
private handleNetworkSelect(network: IWifiNetwork): void {
this.dispatchEvent(new CustomEvent('network-select', {
detail: { network },
bubbles: true,
composed: true,
}));
}
private handleSettingsClick(): void {
this.dispatchEvent(new CustomEvent('settings-click', {
bubbles: true,
composed: true,
}));
}
private handleClickOutside(e: MouseEvent): void {
if (this.open && !this.contains(e.target as Node)) {
this.closeMenu();
}
}
private resetInactivityTimer(): void {
const now = Date.now();
// Throttle: only reset if 5+ seconds since last reset
if (now - this.lastActivityTime < 5000) {
return;
}
this.lastActivityTime = now;
this.clearInactivityTimer();
if (this.open) {
this.inactivityTimeout = setTimeout(() => {
this.closeMenu();
}, this.INACTIVITY_TIMEOUT);
}
}
private clearInactivityTimer(): void {
if (this.inactivityTimeout) {
clearTimeout(this.inactivityTimeout);
this.inactivityTimeout = null;
}
}
private closeMenu(): void {
this.open = false;
this.dispatchEvent(new CustomEvent('menu-close', {
bubbles: true,
composed: true,
}));
}
protected updated(changedProperties: Map<string, unknown>): void {
if (changedProperties.has('open')) {
if (this.open) {
this.resetInactivityTimer();
} else {
this.clearInactivityTimer();
}
}
}
async connectedCallback(): Promise<void> {
await super.connectedCallback();
// Delay to prevent immediate close when clicking to open
setTimeout(() => {
document.addEventListener('click', this.boundHandleClickOutside);
}, 0);
}
async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleClickOutside);
this.clearInactivityTimer();
}
}

View File

@@ -0,0 +1 @@
export * from './eco-applauncher-wifimenu.js';

View File

@@ -0,0 +1,133 @@
import { html } from '@design.estate/dees-element';
import type { IAppIcon, ILoginConfig, ILoginCredentials, TApplauncherMode } from './eco-applauncher.js';
import type { IWifiNetwork } from '../eco-applauncher-wifimenu/index.js';
import type { IAudioDevice } from '../eco-applauncher-soundmenu/index.js';
import '../../../views/eco-view-settings/eco-view-settings.js';
import '../../../views/eco-view-peripherals/eco-view-peripherals.js';
import '../../../views/eco-view-saasshare/eco-view-saasshare.js';
import '../../../views/eco-view-system/eco-view-system.js';
import type { EcoApplauncher } from './eco-applauncher.js';
const mockApps: IAppIcon[] = [
{ name: 'SaaS Share', icon: 'lucide:share2', view: html`<eco-view-saasshare></eco-view-saasshare>` },
{ name: 'System', icon: 'lucide:activity', view: html`<eco-view-system></eco-view-system>` },
{ name: 'Peripherals', icon: 'lucide:monitor', view: html`<eco-view-peripherals></eco-view-peripherals>` },
{ name: 'Settings', icon: 'lucide:settings', view: html`<eco-view-settings></eco-view-settings>` },
];
const mockNetworks: IWifiNetwork[] = [
{ ssid: 'HomeNetwork', signalStrength: 95, secured: true },
{ ssid: 'OfficeWiFi', signalStrength: 75, secured: true },
{ ssid: 'CoffeeShop_Guest', signalStrength: 60, secured: false },
{ ssid: 'Neighbor_5G', signalStrength: 40, secured: true },
{ ssid: 'WeakSignal', signalStrength: 15, secured: true },
];
const mockAudioDevices: IAudioDevice[] = [
{ id: 'speakers', name: 'Built-in Speakers', type: 'speaker' },
{ id: 'headphones', name: 'AirPods Pro', type: 'bluetooth' },
{ id: 'hdmi', name: 'LG Monitor', type: 'hdmi' },
];
const loginConfig: ILoginConfig = {
allowedMethods: ['pin', 'password', 'qr'],
pinLength: 4,
welcomeMessage: 'Welcome to EcoBridge',
};
const handleLoginAttempt = (e: CustomEvent) => {
const credentials = e.detail as ILoginCredentials;
const applauncher = e.target as EcoApplauncher;
console.log('Login attempt:', credentials);
// Demo validation: PIN "1234" or password "demo"
if (
(credentials.method === 'pin' && credentials.value === '1234') ||
(credentials.method === 'password' && credentials.value === 'demo')
) {
console.log('Login successful!');
applauncher.setLoginResult(true);
} else {
console.log('Login failed');
applauncher.setLoginResult(false, 'Invalid credentials. Try PIN: 1234 or Password: demo');
}
};
// Home mode demo
const demoHome = () => html`
<style>
.demo-container {
width: 100%;
height: 100%;
}
</style>
<div class="demo-container">
<eco-applauncher
.mode=${'home' as TApplauncherMode}
.loginConfig=${loginConfig}
.apps=${mockApps}
.batteryLevel=${85}
.networkStatus=${'online'}
.soundLevel=${70}
.networks=${mockNetworks}
.connectedNetwork=${'HomeNetwork'}
.wifiEnabled=${true}
.timeRemaining=${'2h 30m remaining'}
.outputDevices=${mockAudioDevices}
.activeDeviceId=${'speakers'}
.muted=${false}
.userName=${'John Doe'}
.notificationCount=${3}
@login-attempt=${handleLoginAttempt}
@login-success=${() => console.log('Login success event received')}
@login-failure=${(e: CustomEvent) => console.log('Login failure:', e.detail)}
@wifi-toggle=${(e: CustomEvent) => console.log('WiFi toggle:', e.detail)}
@network-select=${(e: CustomEvent) => console.log('Network selected:', e.detail)}
@wifi-settings-click=${() => console.log('WiFi settings clicked')}
@battery-saver-toggle=${(e: CustomEvent) => console.log('Battery saver:', e.detail)}
@battery-settings-click=${() => console.log('Battery settings clicked')}
@volume-change=${(e: CustomEvent) => console.log('Volume:', e.detail)}
@mute-toggle=${(e: CustomEvent) => console.log('Mute:', e.detail)}
@device-select=${(e: CustomEvent) => console.log('Device:', e.detail)}
@sound-settings-click=${() => console.log('Sound settings clicked')}
@search-click=${() => console.log('Search clicked')}
@notifications-click=${() => console.log('Notifications clicked')}
@user-click=${() => console.log('User clicked')}
></eco-applauncher>
</div>
`;
demoHome.demoTitle = 'Home Mode';
// Login mode demo
const demoLogin = () => html`
<style>
.demo-container {
width: 100%;
height: 100%;
}
</style>
<div class="demo-container">
<eco-applauncher
.mode=${'login' as TApplauncherMode}
.loginConfig=${loginConfig}
.apps=${mockApps}
.batteryLevel=${85}
.networkStatus=${'online'}
.soundLevel=${70}
.networks=${mockNetworks}
.connectedNetwork=${'HomeNetwork'}
.wifiEnabled=${true}
.outputDevices=${mockAudioDevices}
.activeDeviceId=${'speakers'}
.muted=${false}
@login-attempt=${handleLoginAttempt}
@login-success=${() => console.log('Login success event received')}
@login-failure=${(e: CustomEvent) => console.log('Login failure:', e.detail)}
></eco-applauncher>
</div>
`;
demoLogin.demoTitle = 'Login Mode';
// Export array of demo functions
export const demo = [demoHome, demoLogin];

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,7 @@
// App Launcher Components
export * from './eco-applauncher/index.js';
export * from './eco-applauncher-wifimenu/index.js';
export * from './eco-applauncher-batterymenu/index.js';
export * from './eco-applauncher-soundmenu/index.js';
export * from './eco-applauncher-keyboard/index.js';
export * from './eco-applauncher-powermenu/index.js';

View File

@@ -46,5 +46,5 @@ export function getZIndex(category: keyof typeof zIndexLayers, subcategory?: str
// Z-index assignments for components
export const componentZIndex = {
'dees-screensaver': zIndexLayers.overlay.screensaver,
'eco-screensaver': zIndexLayers.overlay.screensaver,
} as const;

View File

@@ -1 +0,0 @@
export * from './dees-screensaver.js';

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

@@ -0,0 +1,18 @@
import { html } from '@design.estate/dees-element';
import { EcoScreensaver } from './eco-screensaver.js';
export const demo = () => {
// Clean up any existing instance first
if (EcoScreensaver.instance) {
EcoScreensaver.instance.remove();
EcoScreensaver.instance = null;
}
return html`
<eco-screensaver .delay=${2000}></eco-screensaver>
<div style="padding: 24px; color: #888;">
Screensaver activates after 2 seconds of inactivity.<br>
Move mouse or press keys to reset timer.
</div>
`;
};

View File

@@ -9,10 +9,11 @@ import {
state,
} from '@design.estate/dees-element';
import { zIndexLayers } from '../00zindex.js';
import { demo } from './eco-screensaver.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-screensaver': DeesScreensaver;
'eco-screensaver': EcoScreensaver;
}
}
@@ -26,56 +27,36 @@ const colors = [
'hsl(142 71% 45%)', // green-500
];
@customElement('dees-screensaver')
export class DeesScreensaver extends DeesElement {
public static demo = () => {
// Clean up any existing instance first
if (DeesScreensaver.instance) {
DeesScreensaver.instance.remove();
DeesScreensaver.instance = null;
}
// Create screensaver element immediately but inactive
const screensaver = document.createElement('dees-screensaver') as DeesScreensaver;
document.body.appendChild(screensaver);
DeesScreensaver.instance = screensaver;
// Activate after 2 seconds to show the animation
setTimeout(() => {
if (DeesScreensaver.instance === screensaver) {
screensaver.active = true;
}
}, 2000);
return html`<div style="padding: 24px; color: #888;">Screensaver will activate in 2 seconds...</div>`;
};
@customElement('eco-screensaver')
export class EcoScreensaver extends DeesElement {
public static demo = demo;
// Instance management
private static instance: DeesScreensaver | null = null;
public static instance: EcoScreensaver | null = null;
public static async show(): Promise<DeesScreensaver> {
if (DeesScreensaver.instance) {
DeesScreensaver.instance.active = true;
return DeesScreensaver.instance;
public static async show(): Promise<EcoScreensaver> {
if (EcoScreensaver.instance) {
EcoScreensaver.instance.active = true;
return EcoScreensaver.instance;
}
const screensaver = new DeesScreensaver();
const screensaver = new EcoScreensaver();
screensaver.active = true;
document.body.appendChild(screensaver);
DeesScreensaver.instance = screensaver;
EcoScreensaver.instance = screensaver;
return screensaver;
}
public static hide(): void {
if (DeesScreensaver.instance) {
DeesScreensaver.instance.active = false;
if (EcoScreensaver.instance) {
EcoScreensaver.instance.active = false;
}
}
public static destroy(): void {
if (DeesScreensaver.instance) {
DeesScreensaver.instance.remove();
DeesScreensaver.instance = null;
if (EcoScreensaver.instance) {
EcoScreensaver.instance.remove();
EcoScreensaver.instance = null;
}
}
@@ -201,12 +182,39 @@ export class DeesScreensaver extends DeesElement {
font-size: 14px;
}
}
.hint {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
padding: 12px 24px;
background: hsl(240 6% 15%);
border: 1px solid hsl(240 5% 26%);
border-radius: 8px;
color: hsl(0 0% 90%);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
font-weight: 500;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease, transform 0.3s ease;
z-index: 10;
}
.hint.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
`,
];
@property({ type: Boolean, reflect: true })
accessor active = false;
@property({ type: Number })
accessor delay = 0; // milliseconds before activation (0 = no delay)
@state()
accessor currentTime = '';
@@ -230,10 +238,18 @@ export class DeesScreensaver extends DeesElement {
private timeContainerEl: HTMLElement | null = null;
private vignetteEl: HTMLElement | null = null;
private contentEl: HTMLElement | null = null;
private delayTimeoutId: ReturnType<typeof setTimeout> | null = null;
private boundResetDelayTimer: () => void;
private boundShowHint: () => void;
private hintEl: HTMLElement | null = null;
private hintTimeoutId: ReturnType<typeof setTimeout> | null = null;
private hintVisible = false;
constructor() {
super();
this.updateTime();
this.boundResetDelayTimer = this.resetDelayTimer.bind(this);
this.boundShowHint = this.showHint.bind(this);
}
public render(): TemplateResult {
@@ -246,6 +262,7 @@ export class DeesScreensaver extends DeesElement {
<span class="date" style="color: ${this.currentColor};">${this.currentDate}</span>
</div>
</div>
<div class="hint">Click to exit screensaver</div>
`;
}
@@ -253,18 +270,28 @@ export class DeesScreensaver extends DeesElement {
this.timeContainerEl = this.shadowRoot?.querySelector('.time-container') as HTMLElement;
this.vignetteEl = this.shadowRoot?.querySelector('.vignette') as HTMLElement;
this.contentEl = this.shadowRoot?.querySelector('.screensaver-content') as HTMLElement;
this.hintEl = this.shadowRoot?.querySelector('.hint') as HTMLElement;
}
async connectedCallback(): Promise<void> {
await super.connectedCallback();
this.startAnimation();
this.startTimeUpdate();
// If delay is set, start the delay timer and listen for activity
if (this.delay > 0 && !this.active) {
this.startDelayTimer();
this.addActivityListeners();
} else if (this.active) {
this.startAnimation();
this.startTimeUpdate();
}
}
async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
this.stopAnimation();
this.stopTimeUpdate();
this.stopDelayTimer();
this.removeActivityListeners();
}
updated(changedProperties: Map<string, unknown>): void {
@@ -276,9 +303,15 @@ export class DeesScreensaver extends DeesElement {
this.contentEl.style.maskImage = '';
this.contentEl.style.webkitMaskImage = '';
}
// Hide hint when freshly activated
this.hideHint();
// Listen for mouse movement to show hint
window.addEventListener('mousemove', this.boundShowHint);
this.startAnimation();
this.startTimeUpdate();
} else {
window.removeEventListener('mousemove', this.boundShowHint);
this.hideHint();
this.stopAnimation();
this.stopTimeUpdate();
}
@@ -393,6 +426,71 @@ export class DeesScreensaver extends DeesElement {
}
}
private startDelayTimer(): void {
this.stopDelayTimer();
this.delayTimeoutId = setTimeout(() => {
this.removeActivityListeners();
this.active = true;
}, this.delay);
}
private stopDelayTimer(): void {
if (this.delayTimeoutId) {
clearTimeout(this.delayTimeoutId);
this.delayTimeoutId = null;
}
}
private resetDelayTimer(): void {
if (this.delay > 0 && !this.active) {
this.startDelayTimer();
}
}
private addActivityListeners(): void {
window.addEventListener('mousemove', this.boundResetDelayTimer);
window.addEventListener('keydown', this.boundResetDelayTimer);
window.addEventListener('click', this.boundResetDelayTimer);
window.addEventListener('touchstart', this.boundResetDelayTimer);
window.addEventListener('scroll', this.boundResetDelayTimer);
}
private removeActivityListeners(): void {
window.removeEventListener('mousemove', this.boundResetDelayTimer);
window.removeEventListener('keydown', this.boundResetDelayTimer);
window.removeEventListener('click', this.boundResetDelayTimer);
window.removeEventListener('touchstart', this.boundResetDelayTimer);
window.removeEventListener('scroll', this.boundResetDelayTimer);
}
private showHint(): void {
if (!this.active || this.hintVisible) return;
this.hintVisible = true;
if (this.hintEl) {
this.hintEl.classList.add('visible');
}
// Auto-hide after 3 seconds
if (this.hintTimeoutId) {
clearTimeout(this.hintTimeoutId);
}
this.hintTimeoutId = setTimeout(() => {
this.hideHint();
}, 3000);
}
private hideHint(): void {
this.hintVisible = false;
if (this.hintEl) {
this.hintEl.classList.remove('visible');
}
if (this.hintTimeoutId) {
clearTimeout(this.hintTimeoutId);
this.hintTimeoutId = null;
}
}
private handleClick(event: MouseEvent | TouchEvent): void {
// Get click/touch position
let x: number, y: number;
@@ -434,7 +532,7 @@ export class DeesScreensaver extends DeesElement {
} else {
// Animation complete - remove screensaver
this.active = false;
DeesScreensaver.destroy();
EcoScreensaver.destroy();
}
};

View File

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

View File

@@ -1,5 +1,9 @@
export * from './00zindex.js';
export * from './00theme.js';
// Component Groups
export * from './00group-applauncher/index.js';
// Standalone Components
export * from './dees-screensaver/index.js';
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';

View File

@@ -0,0 +1,34 @@
import { html } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
width: 100%;
height: 100%;
min-height: 600px;
background: hsl(240 10% 4%);
border-radius: 12px;
overflow: hidden;
}
</style>
<div class="demo-container">
<eco-view-browser
.bookmarks=${[
{ id: 'bm-1', title: 'Google', url: 'https://www.google.com', createdAt: new Date() },
{ id: 'bm-2', title: 'GitHub', url: 'https://github.com', createdAt: new Date() },
{ id: 'bm-3', title: 'Stack Overflow', url: 'https://stackoverflow.com', createdAt: new Date() },
{ id: 'bm-4', title: 'MDN Web Docs', url: 'https://developer.mozilla.org', createdAt: new Date() },
]}
.showBookmarksBar=${true}
@navigate=${(e: CustomEvent) => console.log('Navigate:', e.detail)}
@bookmark-add=${(e: CustomEvent) => console.log('Bookmark add:', e.detail)}
@bookmark-remove=${(e: CustomEvent) => console.log('Bookmark remove:', e.detail)}
@browser-back=${() => console.log('Browser back')}
@browser-forward=${() => console.log('Browser forward')}
@browser-refresh=${() => console.log('Browser refresh')}
@browser-stop=${() => console.log('Browser stop')}
@new-tab=${() => console.log('New tab')}
@open-devtools=${() => console.log('Open devtools')}
></eco-view-browser>
</div>
`;

View File

@@ -0,0 +1,959 @@
import {
customElement,
DeesElement,
type TemplateResult,
html,
property,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import { DeesIcon } from '@design.estate/dees-catalog';
import { demo } from './eco-view-browser.demo.js';
// Ensure components are registered
DeesIcon;
declare global {
interface HTMLElementTagNameMap {
'eco-view-browser': EcoViewBrowser;
}
}
// Types
export interface IBookmark {
id: string;
title: string;
url: string;
favicon?: string;
createdAt: Date;
}
export interface IBrowserState {
url: string;
title: string;
canGoBack: boolean;
canGoForward: boolean;
isLoading: boolean;
}
export type TBrowserPanel = 'browser' | 'bookmarks';
@customElement('eco-view-browser')
export class EcoViewBrowser extends DeesElement {
public static demo = demo;
public static demoGroup = 'Views';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 8%)')};
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.browser-container {
display: flex;
flex-direction: column;
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: ${cssManager.bdTheme('#f5f5f7', 'hsl(240 6% 10%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 15%)')};
}
.nav-buttons {
display: flex;
gap: 4px;
}
.nav-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 70%)')};
cursor: pointer;
transition: all 0.15s ease;
}
.nav-button:hover:not(:disabled) {
background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 18%)')};
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
}
.nav-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nav-button.loading dees-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.url-container {
flex: 1;
display: flex;
align-items: center;
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 15%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(240 5% 25%)')};
border-radius: 8px;
padding: 0 12px;
height: 36px;
transition: border-color 0.15s ease;
}
.url-container:focus-within {
border-color: hsl(217 91% 60%);
}
.url-icon {
margin-right: 8px;
opacity: 0.5;
}
.url-input {
flex: 1;
border: none;
background: transparent;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')};
outline: none;
}
.url-input::placeholder {
color: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 50%)')};
}
.bookmark-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
cursor: pointer;
transition: all 0.15s ease;
}
.bookmark-button:hover {
background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 18%)')};
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
}
.bookmark-button.bookmarked {
color: hsl(45 93% 47%);
}
.bookmark-button.bookmarked:hover {
color: hsl(45 93% 40%);
}
.menu-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
cursor: pointer;
transition: all 0.15s ease;
}
.menu-button:hover {
background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 18%)')};
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
}
.webview-container {
flex: 1;
position: relative;
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 5%)')};
overflow: hidden;
}
.webview-wrapper {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
webview {
width: 100%;
height: 100%;
border: none;
display: flex;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 50%)')};
gap: 16px;
}
.placeholder dees-icon {
opacity: 0.3;
}
.placeholder-text {
font-size: 14px;
}
.placeholder-hint {
font-size: 12px;
opacity: 0.7;
}
.bookmarks-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
background: ${cssManager.bdTheme('#fafafa', 'hsl(240 6% 9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 13%)')};
overflow-x: auto;
}
.bookmarks-bar::-webkit-scrollbar {
height: 4px;
}
.bookmarks-bar::-webkit-scrollbar-track {
background: transparent;
}
.bookmarks-bar::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('hsl(0 0% 80%)', 'hsl(240 5% 25%)')};
border-radius: 2px;
}
.bookmark-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 15%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 20%)')};
border-radius: 6px;
font-size: 12px;
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.bookmark-chip:hover {
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(240 5% 18%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 80%)', 'hsl(240 5% 25%)')};
}
.bookmark-chip .favicon {
width: 14px;
height: 14px;
border-radius: 2px;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 15%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 25%)')};
border-radius: 8px;
box-shadow: 0 4px 16px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
min-width: 180px;
z-index: 100;
overflow: hidden;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
cursor: pointer;
transition: background 0.1s ease;
}
.dropdown-item:hover {
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(240 5% 20%)')};
}
.dropdown-item.destructive {
color: hsl(0 72% 50%);
}
.dropdown-item.destructive:hover {
background: hsl(0 72% 95%);
}
.dropdown-divider {
height: 1px;
background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 20%)')};
margin: 4px 0;
}
.menu-container {
position: relative;
}
/* Bookmarks panel styles */
.bookmarks-panel {
padding: 32px 48px;
overflow-y: auto;
height: 100%;
}
.panel-header {
margin-bottom: 24px;
}
.panel-title {
font-size: 28px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
margin: 0 0 8px 0;
}
.panel-description {
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
margin: 0;
}
.bookmarks-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.bookmark-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 12%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 18%)')};
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.bookmark-item:hover {
border-color: hsl(217 91% 60%);
transform: translateX(2px);
}
.bookmark-item .favicon {
width: 20px;
height: 20px;
border-radius: 4px;
flex-shrink: 0;
}
.bookmark-info {
flex: 1;
min-width: 0;
}
.bookmark-title {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-url {
font-size: 12px;
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-delete {
opacity: 0;
transition: opacity 0.15s ease;
}
.bookmark-item:hover .bookmark-delete {
opacity: 1;
}
.bookmark-delete-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
cursor: pointer;
}
.bookmark-delete-btn:hover {
background: hsl(0 72% 95%);
color: hsl(0 72% 50%);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
}
.empty-state dees-icon {
margin-bottom: 16px;
opacity: 0.4;
}
.empty-state p {
font-size: 14px;
margin: 0;
}
.loading-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg,
hsl(217 91% 60%) 0%,
hsl(217 91% 70%) 50%,
hsl(217 91% 60%) 100%
);
background-size: 200% 100%;
animation: loading 1.5s linear infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`,
];
@property({ type: Array })
accessor bookmarks: IBookmark[] = [];
@property({ type: Boolean })
accessor showBookmarksBar = true;
@property({ type: String })
accessor defaultUrl = '';
@state()
accessor activePanel: TBrowserPanel = 'browser';
@state()
accessor currentUrl = '';
@state()
accessor inputUrl = '';
@state()
accessor pageTitle = '';
@state()
accessor canGoBack = false;
@state()
accessor canGoForward = false;
@state()
accessor isLoading = false;
@state()
accessor showMenu = false;
private isElectron = false;
private webviewEventsBound = false;
async connectedCallback(): Promise<void> {
await super.connectedCallback();
// Check if we're in Electron - webview tag only works in Electron
// Since nodeIntegration is disabled, we check for the existence of electronAPI
// or if we're in a file:// protocol (Electron loads from file://)
this.isElectron = typeof window !== 'undefined' && (
typeof (window as any).electronAPI !== 'undefined' ||
window.location.protocol === 'file:' ||
navigator.userAgent.includes('Electron')
);
if (this.defaultUrl) {
this.inputUrl = this.defaultUrl;
}
}
protected updated(changedProperties: Map<string, any>): void {
super.updated(changedProperties);
// Bind webview events after it's rendered
this.bindWebviewEvents();
}
private bindWebviewEvents(): void {
if (!this.isElectron || this.webviewEventsBound) return;
const webview = this.shadowRoot?.querySelector('webview') as any;
if (!webview) return;
this.webviewEventsBound = true;
webview.addEventListener('did-start-loading', () => {
this.isLoading = true;
});
webview.addEventListener('did-stop-loading', () => {
this.isLoading = false;
this.updateNavigationState();
});
webview.addEventListener('did-navigate', (e: any) => {
this.currentUrl = e.url || this.currentUrl;
this.inputUrl = this.currentUrl;
this.updateNavigationState();
});
webview.addEventListener('did-navigate-in-page', (e: any) => {
if (e.isMainFrame) {
this.currentUrl = e.url || this.currentUrl;
this.inputUrl = this.currentUrl;
}
});
webview.addEventListener('page-title-updated', (e: any) => {
this.pageTitle = e.title || '';
});
webview.addEventListener('page-favicon-updated', (e: any) => {
// Could store favicon for bookmarks
});
webview.addEventListener('dom-ready', () => {
this.updateNavigationState();
});
console.log('Webview events bound');
}
public render(): TemplateResult {
return html`
<div class="browser-container">
${this.renderToolbar()}
${this.showBookmarksBar && this.bookmarks.length > 0 ? this.renderBookmarksBar() : ''}
${this.activePanel === 'browser' ? this.renderBrowserContent() : this.renderBookmarksPanel()}
</div>
`;
}
private renderToolbar(): TemplateResult {
return html`
<div class="toolbar">
<div class="nav-buttons">
<button
class="nav-button"
title="Back"
?disabled=${!this.canGoBack}
@click=${this.handleBack}
>
<dees-icon .icon=${'lucide:arrowLeft'} .iconSize=${18}></dees-icon>
</button>
<button
class="nav-button"
title="Forward"
?disabled=${!this.canGoForward}
@click=${this.handleForward}
>
<dees-icon .icon=${'lucide:arrowRight'} .iconSize=${18}></dees-icon>
</button>
<button
class="nav-button ${this.isLoading ? 'loading' : ''}"
title=${this.isLoading ? 'Stop' : 'Refresh'}
@click=${this.isLoading ? this.handleStop : this.handleRefresh}
>
<dees-icon
.icon=${this.isLoading ? 'lucide:loader' : 'lucide:refreshCw'}
.iconSize=${18}
></dees-icon>
</button>
</div>
<div class="url-container">
<dees-icon class="url-icon" .icon=${'lucide:globe'} .iconSize=${16}></dees-icon>
<input
class="url-input"
type="text"
placeholder="Enter URL or search..."
.value=${this.inputUrl}
@input=${(e: InputEvent) => this.inputUrl = (e.target as HTMLInputElement).value}
@keydown=${this.handleUrlKeydown}
/>
</div>
<button
class="bookmark-button ${this.isCurrentPageBookmarked() ? 'bookmarked' : ''}"
title=${this.isCurrentPageBookmarked() ? 'Remove bookmark' : 'Add bookmark'}
@click=${this.toggleBookmark}
?disabled=${!this.currentUrl}
>
<dees-icon
.icon=${this.isCurrentPageBookmarked() ? 'lucide:star' : 'lucide:star'}
.iconSize=${18}
></dees-icon>
</button>
<div class="menu-container">
<button
class="menu-button"
title="Menu"
@click=${() => this.showMenu = !this.showMenu}
>
<dees-icon .icon=${'lucide:moreVertical'} .iconSize=${18}></dees-icon>
</button>
${this.showMenu ? this.renderMenu() : ''}
</div>
</div>
`;
}
private renderMenu(): TemplateResult {
return html`
<div class="dropdown-menu" @mouseleave=${() => this.showMenu = false}>
<div class="dropdown-item" @click=${() => { this.activePanel = 'bookmarks'; this.showMenu = false; }}>
<dees-icon .icon=${'lucide:bookmark'} .iconSize=${18}></dees-icon>
Bookmarks
</div>
<div class="dropdown-item" @click=${this.handleNewTab}>
<dees-icon .icon=${'lucide:plus'} .iconSize=${18}></dees-icon>
New Tab
</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item" @click=${this.handleOpenDevTools}>
<dees-icon .icon=${'lucide:code'} .iconSize=${18}></dees-icon>
Developer Tools
</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item" @click=${this.handleCopyUrl}>
<dees-icon .icon=${'lucide:copy'} .iconSize=${18}></dees-icon>
Copy URL
</div>
</div>
`;
}
private renderBookmarksBar(): TemplateResult {
return html`
<div class="bookmarks-bar">
${this.bookmarks.slice(0, 10).map(bookmark => html`
<div
class="bookmark-chip"
title=${bookmark.url}
@click=${() => this.navigate(bookmark.url)}
>
${bookmark.favicon
? html`<img class="favicon" src=${bookmark.favicon} alt="" />`
: html`<dees-icon .icon=${'lucide:globe'} .iconSize=${14}></dees-icon>`
}
${bookmark.title || new URL(bookmark.url).hostname}
</div>
`)}
</div>
`;
}
private renderBrowserContent(): TemplateResult {
return html`
<div class="webview-container">
${this.isLoading ? html`<div class="loading-bar"></div>` : ''}
<div class="webview-wrapper">
${this.currentUrl ? this.renderWebview() : this.renderPlaceholder()}
</div>
</div>
`;
}
private renderWebview(): TemplateResult {
// In Electron, we use webview tag. In browser, we show a notice.
if (this.isElectron) {
// Reset event binding flag when URL changes so events get rebound
// Note: webview events are bound in updated() lifecycle
return html`
<webview
src=${this.currentUrl}
style="width: 100%; height: 100%;"
allowpopups
></webview>
`;
} else {
// Fallback for non-Electron environment (demo/testing)
return html`
<div class="placeholder">
<dees-icon .icon=${'lucide:monitor'} .iconSize=${48}></dees-icon>
<span class="placeholder-text">Webview requires Electron environment</span>
<span class="placeholder-hint">URL: ${this.currentUrl}</span>
</div>
`;
}
}
private renderPlaceholder(): TemplateResult {
return html`
<div class="placeholder">
<dees-icon .icon=${'lucide:globe'} .iconSize=${64}></dees-icon>
<span class="placeholder-text">Enter a URL to start browsing</span>
<span class="placeholder-hint">or select a bookmark</span>
</div>
`;
}
private renderBookmarksPanel(): TemplateResult {
return html`
<div class="bookmarks-panel">
<div class="panel-header">
<h2 class="panel-title">Bookmarks</h2>
<p class="panel-description">Your saved websites</p>
</div>
${this.bookmarks.length > 0
? html`
<div class="bookmarks-list">
${this.bookmarks.map(bookmark => html`
<div class="bookmark-item" @click=${() => this.handleBookmarkClick(bookmark)}>
${bookmark.favicon
? html`<img class="favicon" src=${bookmark.favicon} alt="" />`
: html`<dees-icon .icon=${'lucide:globe'} .iconSize=${20}></dees-icon>`
}
<div class="bookmark-info">
<div class="bookmark-title">${bookmark.title || bookmark.url}</div>
<div class="bookmark-url">${bookmark.url}</div>
</div>
<div class="bookmark-delete">
<button
class="bookmark-delete-btn"
title="Delete bookmark"
@click=${(e: Event) => { e.stopPropagation(); this.removeBookmark(bookmark.id); }}
>
<dees-icon .icon=${'lucide:trash2'} .iconSize=${16}></dees-icon>
</button>
</div>
</div>
`)}
</div>
`
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:bookmark'} .iconSize=${48}></dees-icon>
<p>No bookmarks yet. Star a page to save it here.</p>
</div>
`
}
</div>
`;
}
// Navigation methods
private navigate(url: string): void {
if (!url) return;
// Add protocol if missing
if (!/^https?:\/\//i.test(url)) {
// Check if it looks like a domain
if (/^[a-zA-Z0-9][a-zA-Z0-9-]*\.[a-zA-Z]{2,}/.test(url)) {
url = 'https://' + url;
} else {
// Treat as search query
url = `https://www.google.com/search?q=${encodeURIComponent(url)}`;
}
}
this.currentUrl = url;
this.inputUrl = url;
this.isLoading = true;
this.dispatchEvent(new CustomEvent('navigate', {
detail: { url },
bubbles: true,
composed: true,
}));
}
private handleUrlKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter') {
this.navigate(this.inputUrl);
}
}
private get webview(): any {
return this.shadowRoot?.querySelector('webview');
}
private handleBack(): void {
const wv = this.webview;
if (wv && this.isElectron) {
wv.goBack();
}
this.dispatchEvent(new CustomEvent('browser-back', { bubbles: true, composed: true }));
}
private handleForward(): void {
const wv = this.webview;
if (wv && this.isElectron) {
wv.goForward();
}
this.dispatchEvent(new CustomEvent('browser-forward', { bubbles: true, composed: true }));
}
private handleRefresh(): void {
const wv = this.webview;
if (wv && this.isElectron) {
wv.reload();
}
this.dispatchEvent(new CustomEvent('browser-refresh', { bubbles: true, composed: true }));
}
private handleStop(): void {
const wv = this.webview;
if (wv && this.isElectron) {
wv.stop();
}
this.isLoading = false;
this.dispatchEvent(new CustomEvent('browser-stop', { bubbles: true, composed: true }));
}
private handleFaviconUpdate(e: any): void {
// Could update current page favicon for bookmarks
}
private updateNavigationState(): void {
if (this.webview && this.isElectron) {
this.canGoBack = (this.webview as any).canGoBack?.() || false;
this.canGoForward = (this.webview as any).canGoForward?.() || false;
}
}
// Bookmark methods
private isCurrentPageBookmarked(): boolean {
return this.bookmarks.some(b => b.url === this.currentUrl);
}
private toggleBookmark(): void {
if (!this.currentUrl) return;
if (this.isCurrentPageBookmarked()) {
const bookmark = this.bookmarks.find(b => b.url === this.currentUrl);
if (bookmark) {
this.removeBookmark(bookmark.id);
}
} else {
this.addBookmark();
}
}
private addBookmark(): void {
const bookmark: IBookmark = {
id: crypto.randomUUID(),
title: this.pageTitle || this.currentUrl,
url: this.currentUrl,
createdAt: new Date(),
};
this.dispatchEvent(new CustomEvent('bookmark-add', {
detail: { bookmark },
bubbles: true,
composed: true,
}));
}
private removeBookmark(id: string): void {
this.dispatchEvent(new CustomEvent('bookmark-remove', {
detail: { id },
bubbles: true,
composed: true,
}));
}
private handleBookmarkClick(bookmark: IBookmark): void {
this.navigate(bookmark.url);
this.activePanel = 'browser';
}
// Menu actions
private handleNewTab(): void {
this.showMenu = false;
this.dispatchEvent(new CustomEvent('new-tab', { bubbles: true, composed: true }));
}
private handleOpenDevTools(): void {
this.showMenu = false;
if (this.webview && this.isElectron) {
(this.webview as any).openDevTools();
}
this.dispatchEvent(new CustomEvent('open-devtools', { bubbles: true, composed: true }));
}
private handleCopyUrl(): void {
this.showMenu = false;
if (this.currentUrl) {
navigator.clipboard.writeText(this.currentUrl);
}
}
// Public API
public goTo(url: string): void {
this.navigate(url);
}
public setBookmarks(bookmarks: IBookmark[]): void {
this.bookmarks = bookmarks;
}
public getState(): IBrowserState {
return {
url: this.currentUrl,
title: this.pageTitle,
canGoBack: this.canGoBack,
canGoForward: this.canGoForward,
isLoading: this.isLoading,
};
}
}

View File

@@ -0,0 +1 @@
export * from './eco-view-browser.js';

View File

@@ -0,0 +1,31 @@
import { html } from '@design.estate/dees-element';
import type { IAppIcon } from './eco-view-home.js';
const mockApps: IAppIcon[] = [
{ name: 'SaaS Share', icon: 'lucide:share2' },
{ name: 'System', icon: 'lucide:activity' },
{ name: 'Peripherals', icon: 'lucide:monitor' },
{ name: 'Settings', icon: 'lucide:settings' },
{ name: 'Files', icon: 'lucide:folder' },
{ name: 'Terminal', icon: 'lucide:terminal' },
{ name: 'Browser', icon: 'lucide:globe' },
{ name: 'Camera', icon: 'lucide:camera' },
];
export const demo = () => html`
<style>
.demo-container {
width: 100%;
height: 100%;
background: hsl(240 10% 4%);
border-radius: 12px;
overflow: hidden;
}
</style>
<div class="demo-container">
<eco-view-home
.apps=${mockApps}
@app-click=${(e: CustomEvent) => console.log('App clicked:', e.detail.app)}
></eco-view-home>
</div>
`;

View File

@@ -0,0 +1,157 @@
import {
customElement,
DeesElement,
type TemplateResult,
html,
property,
css,
cssManager,
} from '@design.estate/dees-element';
import { DeesIcon } from '@design.estate/dees-catalog';
// Ensure icon component is registered
DeesIcon;
declare global {
interface HTMLElementTagNameMap {
'eco-view-home': EcoViewHome;
}
}
export interface IAppIcon {
name: string;
icon: string;
action?: () => void;
view?: TemplateResult;
}
@customElement('eco-view-home')
export class EcoViewHome extends DeesElement {
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
overflow-y: auto;
}
.apps-area {
padding: 48px;
min-height: 100%;
box-sizing: border-box;
}
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 32px;
width: 100%;
}
.app-icon {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 16px;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.app-icon:hover {
background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 5% 12%)')};
}
.app-icon:active {
transform: scale(0.95);
background: ${cssManager.bdTheme('hsl(220 15% 88%)', 'hsl(240 5% 16%)')};
}
.app-icon-circle {
width: 64px;
height: 64px;
border-radius: 16px;
background: ${cssManager.bdTheme('hsl(220 15% 90%)', 'hsl(240 5% 15%)')};
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 80%)')};
}
.app-icon-circle dees-icon {
--dees-icon-size: 28px;
}
.app-icon-name {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 25%)', 'hsl(0 0% 85%)')};
text-align: center;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 600px) {
.apps-area {
padding: 24px;
}
.apps-grid {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 16px;
}
.app-icon-circle {
width: 56px;
height: 56px;
font-size: 24px;
}
.app-icon-name {
font-size: 12px;
}
}
`,
];
@property({ type: Array })
accessor apps: IAppIcon[] = [];
public render(): TemplateResult {
return html`
<div class="apps-area">
<div class="apps-grid">
${this.apps.map((app) => this.renderAppIcon(app))}
</div>
</div>
`;
}
private renderAppIcon(app: IAppIcon): TemplateResult {
return html`
<div class="app-icon" @click=${() => this.handleAppClick(app)}>
<div class="app-icon-circle">
<dees-icon .icon=${app.icon} .iconSize=${28}></dees-icon>
</div>
<span class="app-icon-name">${app.name}</span>
</div>
`;
}
private handleAppClick(app: IAppIcon): void {
this.dispatchEvent(
new CustomEvent('app-click', {
detail: { app },
bubbles: true,
composed: true,
})
);
}
}

Some files were not shown because too many files have changed in this diff Show More