Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c0a4b3e6e | |||
| 0a11c3e65e | |||
| 72900086cd | |||
| c55cd25a88 | |||
| fafa98bc19 | |||
| 942896a3ef | |||
| 79f41a6001 | |||
| dcc3e18474 | |||
| 2dbc9e35c6 | |||
| 9d07d4ca88 |
BIN
.playwright-mcp/after-body-click.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
.playwright-mcp/after-pointer-fix.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
.playwright-mcp/applauncher-battery-menu-fixed.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
.playwright-mcp/applauncher-battery-menu-open.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
.playwright-mcp/applauncher-initial.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
.playwright-mcp/applauncher-keyboard-bright.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
.playwright-mcp/applauncher-keyboard-open.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
.playwright-mcp/applauncher-keyboard-toggle.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
.playwright-mcp/applauncher-keyboard-zindex.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
.playwright-mcp/applauncher-menus.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.playwright-mcp/applauncher-peripherals-view.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
.playwright-mcp/applauncher-topbar-bright.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
.playwright-mcp/applauncher-topbar-dark.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
.playwright-mcp/applauncher-wifi-menu-open.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
.playwright-mcp/applauncher-with-keyboard-toggle.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
.playwright-mcp/battery-menu-open.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.playwright-mcp/battery-saver-toggled.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.playwright-mcp/eco-applauncher-bright.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
.playwright-mcp/eco-applauncher-dark.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
.playwright-mcp/eco-batterymenu-bright.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
.playwright-mcp/eco-batterymenu-dark.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
.playwright-mcp/eco-soundmenu-dark.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
.playwright-mcp/eco-wifimenu-bright.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
.playwright-mcp/eco-wifimenu-dark.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
.playwright-mcp/menu-test.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.playwright-mcp/menus-after-fix.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
.playwright-mcp/saasshare-apps-list.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
.playwright-mcp/saasshare-devices.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
.playwright-mcp/saasshare-view.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
.playwright-mcp/system-view-working.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
23
changelog.md
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2026-01-06 - 3.34.0 - feat(dees-screensaver)
|
||||||
improve screensaver activation, visuals, and dismissal animations
|
improve screensaver activation, visuals, and dismissal animations
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,28 @@
|
|||||||
import * as deesWccTools from '@design.estate/dees-wcctools';
|
import * as deesWccTools from '@design.estate/dees-wcctools';
|
||||||
import * as deesDomTools from '@design.estate/dees-domtools';
|
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 elements from '../ts_web/elements/index.js';
|
||||||
import * as pages from '../ts_web/pages/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();
|
deesDomTools.elementBasic.setup();
|
||||||
|
|||||||
@@ -3,37 +3,23 @@
|
|||||||
"projectType": "wcc",
|
"projectType": "wcc",
|
||||||
"module": {
|
"module": {
|
||||||
"githost": "code.foss.global",
|
"githost": "code.foss.global",
|
||||||
"gitscope": "design.estate",
|
"gitscope": "ecobridge.xyz",
|
||||||
"gitrepo": "dees-catalog",
|
"gitrepo": "catalog",
|
||||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
"description": "A web component catalog for building ecobridge application interfaces with specialized components for the ecobridge ecosystem.",
|
||||||
"npmPackagename": "@design.estate/dees-catalog",
|
"npmPackagename": "@ecobridge.xyz/catalog",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"projectDomain": "design.estate",
|
"projectDomain": "ecobridge.xyz",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"Web Components",
|
"Web Components",
|
||||||
"User Interface",
|
"User Interface",
|
||||||
"UI Library",
|
"UI Library",
|
||||||
"Component Library",
|
"Component Library",
|
||||||
"JavaScript",
|
|
||||||
"TypeScript",
|
"TypeScript",
|
||||||
"Dynamic Components",
|
"Ecobridge",
|
||||||
"Modular Architecture",
|
"App Launcher",
|
||||||
"Reusable Components",
|
"Desktop Interface",
|
||||||
"Web Development",
|
|
||||||
"Application UI",
|
|
||||||
"Custom Elements",
|
"Custom Elements",
|
||||||
"Shadow DOM",
|
"LitElement"
|
||||||
"UI Elements",
|
|
||||||
"Dashboard Interfaces",
|
|
||||||
"Form Handling",
|
|
||||||
"Data Display",
|
|
||||||
"Visualization",
|
|
||||||
"Charting",
|
|
||||||
"Interactive Components",
|
|
||||||
"Responsive Design",
|
|
||||||
"Web Applications",
|
|
||||||
"Modern Web",
|
|
||||||
"Frontend Development"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"release": {
|
"release": {
|
||||||
@@ -45,9 +31,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@git.zone/tsdoc": {
|
"@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": {
|
"@ship.zone/szci": {
|
||||||
"npmGlobalTools": []
|
"npmGlobalTools": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ecobridge.xyz/catalog",
|
"name": "@ecobridge.xyz/catalog",
|
||||||
"version": "3.34.0",
|
"version": "3.34.3",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
"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",
|
"main": "dist_ts_web/index.js",
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
35
test/test.eco-catalog.chromium.ts
Normal 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();
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@ecobridge.xyz/catalog',
|
name: '@ecobridge.xyz/catalog',
|
||||||
version: '3.34.0',
|
version: '3.34.3',
|
||||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-applauncher-batterymenu.js';
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
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.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' : ''}"
|
||||||
|
@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)}
|
||||||
|
>
|
||||||
|
${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();
|
||||||
|
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();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-applauncher-keyboard.js';
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-applauncher-powermenu.js';
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-applauncher-soundmenu.js';
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-applauncher-wifimenu.js';
|
||||||
@@ -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];
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-applauncher.js';
|
||||||
7
ts_web/elements/00group-applauncher/index.ts
Normal 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';
|
||||||
@@ -46,5 +46,5 @@ export function getZIndex(category: keyof typeof zIndexLayers, subcategory?: str
|
|||||||
|
|
||||||
// Z-index assignments for components
|
// Z-index assignments for components
|
||||||
export const componentZIndex = {
|
export const componentZIndex = {
|
||||||
'dees-screensaver': zIndexLayers.overlay.screensaver,
|
'eco-screensaver': zIndexLayers.overlay.screensaver,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './dees-screensaver.js';
|
|
||||||
18
ts_web/elements/eco-screensaver/eco-screensaver.demo.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
};
|
||||||
@@ -9,10 +9,11 @@ import {
|
|||||||
state,
|
state,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import { zIndexLayers } from '../00zindex.js';
|
import { zIndexLayers } from '../00zindex.js';
|
||||||
|
import { demo } from './eco-screensaver.demo.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'dees-screensaver': DeesScreensaver;
|
'eco-screensaver': EcoScreensaver;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,56 +27,36 @@ const colors = [
|
|||||||
'hsl(142 71% 45%)', // green-500
|
'hsl(142 71% 45%)', // green-500
|
||||||
];
|
];
|
||||||
|
|
||||||
@customElement('dees-screensaver')
|
@customElement('eco-screensaver')
|
||||||
export class DeesScreensaver extends DeesElement {
|
export class EcoScreensaver extends DeesElement {
|
||||||
public static demo = () => {
|
public static demo = 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>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Instance management
|
// Instance management
|
||||||
private static instance: DeesScreensaver | null = null;
|
public static instance: EcoScreensaver | null = null;
|
||||||
|
|
||||||
public static async show(): Promise<DeesScreensaver> {
|
public static async show(): Promise<EcoScreensaver> {
|
||||||
if (DeesScreensaver.instance) {
|
if (EcoScreensaver.instance) {
|
||||||
DeesScreensaver.instance.active = true;
|
EcoScreensaver.instance.active = true;
|
||||||
return DeesScreensaver.instance;
|
return EcoScreensaver.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
const screensaver = new DeesScreensaver();
|
const screensaver = new EcoScreensaver();
|
||||||
screensaver.active = true;
|
screensaver.active = true;
|
||||||
document.body.appendChild(screensaver);
|
document.body.appendChild(screensaver);
|
||||||
DeesScreensaver.instance = screensaver;
|
EcoScreensaver.instance = screensaver;
|
||||||
return screensaver;
|
return screensaver;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static hide(): void {
|
public static hide(): void {
|
||||||
if (DeesScreensaver.instance) {
|
if (EcoScreensaver.instance) {
|
||||||
DeesScreensaver.instance.active = false;
|
EcoScreensaver.instance.active = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static destroy(): void {
|
public static destroy(): void {
|
||||||
if (DeesScreensaver.instance) {
|
if (EcoScreensaver.instance) {
|
||||||
DeesScreensaver.instance.remove();
|
EcoScreensaver.instance.remove();
|
||||||
DeesScreensaver.instance = null;
|
EcoScreensaver.instance = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,12 +182,39 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
font-size: 14px;
|
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 })
|
@property({ type: Boolean, reflect: true })
|
||||||
accessor active = false;
|
accessor active = false;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
accessor delay = 0; // milliseconds before activation (0 = no delay)
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor currentTime = '';
|
accessor currentTime = '';
|
||||||
|
|
||||||
@@ -230,10 +238,18 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
private timeContainerEl: HTMLElement | null = null;
|
private timeContainerEl: HTMLElement | null = null;
|
||||||
private vignetteEl: HTMLElement | null = null;
|
private vignetteEl: HTMLElement | null = null;
|
||||||
private contentEl: 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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.updateTime();
|
this.updateTime();
|
||||||
|
this.boundResetDelayTimer = this.resetDelayTimer.bind(this);
|
||||||
|
this.boundShowHint = this.showHint.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
@@ -246,6 +262,7 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
<span class="date" style="color: ${this.currentColor};">${this.currentDate}</span>
|
<span class="date" style="color: ${this.currentColor};">${this.currentDate}</span>
|
||||||
</div>
|
</div>
|
||||||
</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.timeContainerEl = this.shadowRoot?.querySelector('.time-container') as HTMLElement;
|
||||||
this.vignetteEl = this.shadowRoot?.querySelector('.vignette') as HTMLElement;
|
this.vignetteEl = this.shadowRoot?.querySelector('.vignette') as HTMLElement;
|
||||||
this.contentEl = this.shadowRoot?.querySelector('.screensaver-content') as HTMLElement;
|
this.contentEl = this.shadowRoot?.querySelector('.screensaver-content') as HTMLElement;
|
||||||
|
this.hintEl = this.shadowRoot?.querySelector('.hint') as HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback(): Promise<void> {
|
async connectedCallback(): Promise<void> {
|
||||||
await super.connectedCallback();
|
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> {
|
async disconnectedCallback(): Promise<void> {
|
||||||
await super.disconnectedCallback();
|
await super.disconnectedCallback();
|
||||||
this.stopAnimation();
|
this.stopAnimation();
|
||||||
this.stopTimeUpdate();
|
this.stopTimeUpdate();
|
||||||
|
this.stopDelayTimer();
|
||||||
|
this.removeActivityListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changedProperties: Map<string, unknown>): void {
|
updated(changedProperties: Map<string, unknown>): void {
|
||||||
@@ -276,9 +303,15 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
this.contentEl.style.maskImage = '';
|
this.contentEl.style.maskImage = '';
|
||||||
this.contentEl.style.webkitMaskImage = '';
|
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.startAnimation();
|
||||||
this.startTimeUpdate();
|
this.startTimeUpdate();
|
||||||
} else {
|
} else {
|
||||||
|
window.removeEventListener('mousemove', this.boundShowHint);
|
||||||
|
this.hideHint();
|
||||||
this.stopAnimation();
|
this.stopAnimation();
|
||||||
this.stopTimeUpdate();
|
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 {
|
private handleClick(event: MouseEvent | TouchEvent): void {
|
||||||
// Get click/touch position
|
// Get click/touch position
|
||||||
let x: number, y: number;
|
let x: number, y: number;
|
||||||
@@ -434,7 +532,7 @@ export class DeesScreensaver extends DeesElement {
|
|||||||
} else {
|
} else {
|
||||||
// Animation complete - remove screensaver
|
// Animation complete - remove screensaver
|
||||||
this.active = false;
|
this.active = false;
|
||||||
DeesScreensaver.destroy();
|
EcoScreensaver.destroy();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
1
ts_web/elements/eco-screensaver/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-screensaver.js';
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
export * from './00zindex.js';
|
export * from './00zindex.js';
|
||||||
export * from './00theme.js';
|
export * from './00theme.js';
|
||||||
|
|
||||||
|
// Component Groups
|
||||||
|
export * from './00group-applauncher/index.js';
|
||||||
|
|
||||||
// Standalone Components
|
// Standalone Components
|
||||||
export * from './dees-screensaver/index.js';
|
export * from './eco-screensaver/index.js';
|
||||||
|
|||||||
31
ts_web/views/eco-view-home/eco-view-home.demo.ts
Normal 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>
|
||||||
|
`;
|
||||||
157
ts_web/views/eco-view-home/eco-view-home.ts
Normal 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts_web/views/eco-view-home/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-view-home.js';
|
||||||
48
ts_web/views/eco-view-login/eco-view-login.demo.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
import type { ILoginConfig, ILoginCredentials } from './eco-view-login.js';
|
||||||
|
|
||||||
|
const handleLoginAttempt = (e: CustomEvent<ILoginCredentials>) => {
|
||||||
|
const { method, value } = e.detail;
|
||||||
|
console.log(`Login attempt via ${method}:`, value);
|
||||||
|
|
||||||
|
// Demo: Show success for PIN "1234" or password "demo"
|
||||||
|
const loginView = e.target as HTMLElement & { showErrorMessage: (msg: string) => void; clearInput: () => void };
|
||||||
|
|
||||||
|
if ((method === 'pin' && value === '1234') || (method === 'password' && value === 'demo')) {
|
||||||
|
console.log('Login successful!');
|
||||||
|
alert('Login successful! (Demo)');
|
||||||
|
loginView.clearInput();
|
||||||
|
} else {
|
||||||
|
loginView.showErrorMessage('Invalid credentials. Try PIN: 1234 or Password: demo');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pinOnlyConfig: ILoginConfig = {
|
||||||
|
allowedMethods: ['pin'],
|
||||||
|
pinLength: 4,
|
||||||
|
welcomeMessage: 'Enter PIN',
|
||||||
|
};
|
||||||
|
|
||||||
|
const allMethodsConfig: ILoginConfig = {
|
||||||
|
allowedMethods: ['pin', 'password', 'qr'],
|
||||||
|
pinLength: 6,
|
||||||
|
welcomeMessage: 'Sign In',
|
||||||
|
};
|
||||||
|
|
||||||
|
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-login
|
||||||
|
.config=${allMethodsConfig}
|
||||||
|
@login-attempt=${handleLoginAttempt}
|
||||||
|
></eco-view-login>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
749
ts_web/views/eco-view-login/eco-view-login.ts
Normal file
@@ -0,0 +1,749 @@
|
|||||||
|
import {
|
||||||
|
customElement,
|
||||||
|
DeesElement,
|
||||||
|
type TemplateResult,
|
||||||
|
html,
|
||||||
|
property,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
state,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import { DeesIcon } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
// Ensure icon component is registered
|
||||||
|
DeesIcon;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'eco-view-login': EcoViewLogin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TAuthMethod = 'pin' | 'password' | 'qr';
|
||||||
|
|
||||||
|
export interface ILoginConfig {
|
||||||
|
allowedMethods: TAuthMethod[];
|
||||||
|
pinLength?: number;
|
||||||
|
qrCodeData?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
welcomeMessage?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILoginCredentials {
|
||||||
|
method: TAuthMethod;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('eco-view-login')
|
||||||
|
export class EcoViewLogin extends DeesElement {
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left Panel - Branding & Method Selection */
|
||||||
|
.left-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 64px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 96%)', 'hsl(240 6% 10%)')};
|
||||||
|
border-right: 1px solid ${cssManager.bdTheme('hsl(220 15% 90%)', 'hsl(240 5% 16%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding {
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')};
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 45%)', 'hsl(0 0% 55%)')};
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-selector-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')};
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: ${cssManager.bdTheme('white', 'hsl(240 5% 14%)')};
|
||||||
|
border: 2px solid ${cssManager.bdTheme('hsl(220 15% 90%)', 'hsl(240 5% 20%)')};
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option:hover {
|
||||||
|
border-color: ${cssManager.bdTheme('hsl(220 15% 80%)', 'hsl(240 5% 28%)')};
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 98%)', 'hsl(240 5% 16%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option.active {
|
||||||
|
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 97%)', 'hsl(217 91% 15%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 94%)', 'hsl(240 5% 20%)')};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 70%)')};
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option.active .method-icon {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-description {
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 55%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-check {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid ${cssManager.bdTheme('hsl(220 15% 85%)', 'hsl(240 5% 25%)')};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option.active .method-check {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
||||||
|
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Panel - Auth Input */
|
||||||
|
.right-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 64px;
|
||||||
|
background: ${cssManager.bdTheme('white', 'hsl(240 6% 6%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error message */
|
||||||
|
.error-message {
|
||||||
|
color: hsl(0 72% 51%);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: hsla(0, 72%, 51%, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PIN Input */
|
||||||
|
.pin-display {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-dot {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 85%)', 'hsl(240 5% 20%)')};
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-dot.filled {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-dot.error {
|
||||||
|
background: hsl(0 72% 51%);
|
||||||
|
animation: shake 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-4px); }
|
||||||
|
75% { transform: translateX(4px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.numpad {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.numpad-button {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 95%)', 'hsl(240 5% 14%)')};
|
||||||
|
border: none;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 85%)')};
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.numpad-button:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 90%)', 'hsl(240 5% 20%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.numpad-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 85%)', 'hsl(240 5% 24%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.numpad-button.action {
|
||||||
|
background: transparent;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.numpad-button.action:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 95%)', 'hsl(240 5% 14%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.numpad-button.submit {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.numpad-button.submit:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 55%)', 'hsl(217 91% 45%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password Input */
|
||||||
|
.password-form {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 18px 52px 18px 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 96%)', 'hsl(240 5% 12%)')};
|
||||||
|
border: 2px solid ${cssManager.bdTheme('hsl(220 15% 88%)', 'hsl(240 5% 20%)')};
|
||||||
|
border-radius: 12px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input:focus {
|
||||||
|
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input.error {
|
||||||
|
border-color: hsl(0 72% 51%);
|
||||||
|
animation: shake 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 55%)')};
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(220 15% 90%)', 'hsl(240 5% 18%)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 55%)', 'hsl(217 91% 45%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QR Code */
|
||||||
|
.qr-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code {
|
||||||
|
width: 220px;
|
||||||
|
height: 220px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
hsl(0 0% 92%),
|
||||||
|
hsl(0 0% 92%) 10px,
|
||||||
|
hsl(0 0% 88%) 10px,
|
||||||
|
hsl(0 0% 88%) 20px
|
||||||
|
);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: hsl(0 0% 50%);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-instruction {
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 55%)')};
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.login-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
padding: 32px;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid ${cssManager.bdTheme('hsl(220 15% 90%)', 'hsl(240 5% 16%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-selector {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-option {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-check {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
@property({ type: Object })
|
||||||
|
accessor config: ILoginConfig = {
|
||||||
|
allowedMethods: ['pin', 'password', 'qr'],
|
||||||
|
pinLength: 4,
|
||||||
|
welcomeMessage: 'Welcome',
|
||||||
|
subtitle: 'Sign in to continue',
|
||||||
|
};
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor selectedMethod: TAuthMethod = 'pin';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor pinValue = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor passwordValue = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor showPassword = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor error = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor showError = false;
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const effectivePinLength = this.config.pinLength || 4;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="left-panel">
|
||||||
|
<div class="branding">
|
||||||
|
${this.config.logoUrl
|
||||||
|
? html`<div class="logo"><img src=${this.config.logoUrl} alt="Logo" /></div>`
|
||||||
|
: html`<div class="logo"><dees-icon .icon=${'lucide:shield'} .iconSize=${32}></dees-icon></div>`
|
||||||
|
}
|
||||||
|
<h1 class="welcome-message">${this.config.welcomeMessage || 'Welcome'}</h1>
|
||||||
|
<p class="subtitle">${this.config.subtitle || 'Sign in to continue'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.config.allowedMethods.length > 1 ? this.renderMethodSelector() : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-panel">
|
||||||
|
<div class="auth-content">
|
||||||
|
<h2 class="auth-title">${this.getAuthTitle()}</h2>
|
||||||
|
${this.showError ? html`<div class="error-message">${this.error}</div>` : ''}
|
||||||
|
${this.selectedMethod === 'pin' ? this.renderPinInput(effectivePinLength) : ''}
|
||||||
|
${this.selectedMethod === 'password' ? this.renderPasswordInput() : ''}
|
||||||
|
${this.selectedMethod === 'qr' ? this.renderQrCode() : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAuthTitle(): string {
|
||||||
|
switch (this.selectedMethod) {
|
||||||
|
case 'pin':
|
||||||
|
return 'Enter your PIN';
|
||||||
|
case 'password':
|
||||||
|
return 'Enter your password';
|
||||||
|
case 'qr':
|
||||||
|
return 'Scan to sign in';
|
||||||
|
default:
|
||||||
|
return 'Sign in';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMethodSelector(): TemplateResult {
|
||||||
|
const methods: Array<{ id: TAuthMethod; icon: string; name: string; description: string }> = [
|
||||||
|
{ id: 'pin', icon: 'lucide:keySquare', name: 'PIN Code', description: 'Quick numeric access' },
|
||||||
|
{ id: 'password', icon: 'lucide:key', name: 'Password', description: 'Traditional password' },
|
||||||
|
{ id: 'qr', icon: 'lucide:qrCode', name: 'QR Code', description: 'Scan with mobile app' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const availableMethods = methods.filter((m) => this.config.allowedMethods.includes(m.id));
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="method-selector">
|
||||||
|
<span class="method-selector-label">Sign in method</span>
|
||||||
|
${availableMethods.map((method) => html`
|
||||||
|
<div
|
||||||
|
class="method-option ${this.selectedMethod === method.id ? 'active' : ''}"
|
||||||
|
@click=${() => this.selectMethod(method.id)}
|
||||||
|
>
|
||||||
|
<div class="method-icon">
|
||||||
|
<dees-icon .icon=${method.icon} .iconSize=${22}></dees-icon>
|
||||||
|
</div>
|
||||||
|
<div class="method-info">
|
||||||
|
<div class="method-name">${method.name}</div>
|
||||||
|
<div class="method-description">${method.description}</div>
|
||||||
|
</div>
|
||||||
|
<div class="method-check">
|
||||||
|
${this.selectedMethod === method.id
|
||||||
|
? html`<dees-icon .icon=${'lucide:check'} .iconSize=${14}></dees-icon>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPinInput(length: number): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="pin-display">
|
||||||
|
${Array.from({ length }, (_, i) => html`
|
||||||
|
<div class="pin-dot ${i < this.pinValue.length ? 'filled' : ''} ${this.showError ? 'error' : ''}"></div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="numpad">
|
||||||
|
${[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => html`
|
||||||
|
<button class="numpad-button" @click=${() => this.handlePinInput(String(num))}>${num}</button>
|
||||||
|
`)}
|
||||||
|
<button class="numpad-button action" @click=${this.handleBackspace}>
|
||||||
|
<dees-icon .icon=${'lucide:delete'} .iconSize=${24}></dees-icon>
|
||||||
|
</button>
|
||||||
|
<button class="numpad-button" @click=${() => this.handlePinInput('0')}>0</button>
|
||||||
|
<button class="numpad-button action submit" @click=${this.handlePinSubmit}>
|
||||||
|
<dees-icon .icon=${'lucide:arrowRight'} .iconSize=${24}></dees-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPasswordInput(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="password-form">
|
||||||
|
<div class="password-input-wrapper">
|
||||||
|
<input
|
||||||
|
class="password-input ${this.showError ? 'error' : ''}"
|
||||||
|
type=${this.showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
.value=${this.passwordValue}
|
||||||
|
@input=${this.handlePasswordInput}
|
||||||
|
@keydown=${this.handlePasswordKeydown}
|
||||||
|
/>
|
||||||
|
<button class="password-toggle" @click=${this.togglePasswordVisibility}>
|
||||||
|
<dees-icon
|
||||||
|
.icon=${this.showPassword ? 'lucide:eyeOff' : 'lucide:eye'}
|
||||||
|
.iconSize=${20}
|
||||||
|
></dees-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="submit-button" @click=${this.handlePasswordSubmit}>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderQrCode(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="qr-container">
|
||||||
|
<div class="qr-code">
|
||||||
|
${this.config.qrCodeData
|
||||||
|
? html`<img src=${this.config.qrCodeData} alt="Login QR Code" />`
|
||||||
|
: html`<div class="qr-placeholder">QR Code</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="qr-instruction">
|
||||||
|
Open your authenticator app and scan this code to sign in securely without typing a password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectMethod(method: TAuthMethod): void {
|
||||||
|
this.selectedMethod = method;
|
||||||
|
this.clearError();
|
||||||
|
this.pinValue = '';
|
||||||
|
this.passwordValue = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePinInput(digit: string): void {
|
||||||
|
this.clearError();
|
||||||
|
const maxLength = this.config.pinLength || 4;
|
||||||
|
if (this.pinValue.length < maxLength) {
|
||||||
|
this.pinValue += digit;
|
||||||
|
this.dispatchKeyPress(digit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleBackspace(): void {
|
||||||
|
this.clearError();
|
||||||
|
if (this.pinValue.length > 0) {
|
||||||
|
this.pinValue = this.pinValue.slice(0, -1);
|
||||||
|
this.dispatchEvent(new CustomEvent('backspace', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePinSubmit(): void {
|
||||||
|
if (this.pinValue.length === 0) {
|
||||||
|
this.showErrorMessage('Please enter your PIN');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatchLoginAttempt('pin', this.pinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePasswordInput(e: InputEvent): void {
|
||||||
|
this.clearError();
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
this.passwordValue = input.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePasswordKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
this.handlePasswordSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePasswordSubmit(): void {
|
||||||
|
if (this.passwordValue.length === 0) {
|
||||||
|
this.showErrorMessage('Please enter your password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatchLoginAttempt('password', this.passwordValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private togglePasswordVisibility(): void {
|
||||||
|
this.showPassword = !this.showPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchKeyPress(key: string): void {
|
||||||
|
this.dispatchEvent(new CustomEvent('key-press', {
|
||||||
|
detail: { key },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchLoginAttempt(method: TAuthMethod, value: string): void {
|
||||||
|
this.dispatchEvent(new CustomEvent('login-attempt', {
|
||||||
|
detail: { method, value } as ILoginCredentials,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public showErrorMessage(message: string): void {
|
||||||
|
this.error = message;
|
||||||
|
this.showError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearError(): void {
|
||||||
|
this.error = '';
|
||||||
|
this.showError = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearInput(): void {
|
||||||
|
this.pinValue = '';
|
||||||
|
this.passwordValue = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts_web/views/eco-view-login/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-view-login.js';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
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-peripherals
|
||||||
|
.activeCategory=${'all'}
|
||||||
|
@device-select=${(e: CustomEvent) => console.log('Device selected:', e.detail)}
|
||||||
|
@scan-start=${() => console.log('Scanning started')}
|
||||||
|
@scan-complete=${() => console.log('Scanning complete')}
|
||||||
|
></eco-view-peripherals>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
840
ts_web/views/eco-view-peripherals/eco-view-peripherals.ts
Normal file
@@ -0,0 +1,840 @@
|
|||||||
|
import {
|
||||||
|
customElement,
|
||||||
|
DeesElement,
|
||||||
|
type TemplateResult,
|
||||||
|
html,
|
||||||
|
property,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
state,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import { DeesAppuiSecondarymenu, DeesIcon } from '@design.estate/dees-catalog';
|
||||||
|
import type { ISecondaryMenuGroup, ISecondaryMenuItem } from '../../elements/interfaces/secondarymenu.js';
|
||||||
|
import { demo } from './eco-view-peripherals.demo.js';
|
||||||
|
|
||||||
|
// Ensure components are registered
|
||||||
|
DeesAppuiSecondarymenu;
|
||||||
|
DeesIcon;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'eco-view-peripherals': EcoViewPeripherals;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TPeripheralCategory =
|
||||||
|
| 'all'
|
||||||
|
| 'printers'
|
||||||
|
| 'scanners'
|
||||||
|
| 'speakers'
|
||||||
|
| 'storage'
|
||||||
|
| 'power'
|
||||||
|
| 'cameras'
|
||||||
|
| 'streaming'
|
||||||
|
| 'usb';
|
||||||
|
|
||||||
|
export type TConnectionType = 'network' | 'usb' | 'bluetooth';
|
||||||
|
|
||||||
|
export interface IPeripheralDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: TPeripheralCategory;
|
||||||
|
connectionType: TConnectionType;
|
||||||
|
status: 'online' | 'offline' | 'busy' | 'error';
|
||||||
|
ip?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('eco-view-peripherals')
|
||||||
|
export class EcoViewPeripherals 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('#f5f5f7', 'hsl(240 6% 10%)')};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peripherals-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
dees-appui-secondarymenu {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 8%)')};
|
||||||
|
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 15%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 32px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: hsl(217 91% 60%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button:hover {
|
||||||
|
background: hsl(217 91% 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button.scanning {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(240 5% 25%)')};
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button.scanning dees-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section {
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 12%)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 18%)')};
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(240 5% 15%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 50%)')};
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 50%)')};
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(240 5% 20%)')};
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-item:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(240 5% 14%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-icon-wrapper {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(240 5% 18%)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 70%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-icon-wrapper.online {
|
||||||
|
background: ${cssManager.bdTheme('hsl(142 71% 93%)', 'hsl(142 71% 45% / 0.15)')};
|
||||||
|
color: hsl(142 71% 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-icon-wrapper.busy {
|
||||||
|
background: ${cssManager.bdTheme('hsl(47 100% 93%)', 'hsl(47 100% 50% / 0.15)')};
|
||||||
|
color: hsl(47 100% 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-icon-wrapper.error {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 72% 93%)', 'hsl(0 72% 51% / 0.15)')};
|
||||||
|
color: hsl(0 72% 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(217 91% 60%);
|
||||||
|
background: ${cssManager.bdTheme('hsl(217 91% 95%)', 'hsl(217 91% 60% / 0.15)')};
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-details {
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 55%)')};
|
||||||
|
margin-top: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.online {
|
||||||
|
background: hsl(142 71% 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.offline {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 70%)', 'hsl(0 0% 40%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.busy {
|
||||||
|
background: hsl(47 100% 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.error {
|
||||||
|
background: hsl(0 72% 51%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(240 5% 25%)')};
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(240 5% 18%)')};
|
||||||
|
border-color: ${cssManager.bdTheme('hsl(0 0% 75%)', 'hsl(240 5% 35%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.primary {
|
||||||
|
background: hsl(217 91% 60%);
|
||||||
|
border-color: hsl(217 91% 60%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.primary:hover {
|
||||||
|
background: hsl(217 91% 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 48px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 75%)', 'hsl(0 0% 35%)')};
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 55%)', 'hsl(0 0% 50%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-type {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(240 5% 20%)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor activeCategory: TPeripheralCategory = 'all';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor isScanning = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor devices: IPeripheralDevice[] = [
|
||||||
|
// Mock printers
|
||||||
|
{
|
||||||
|
id: 'printer-1',
|
||||||
|
name: 'HP LaserJet Pro',
|
||||||
|
type: 'printers',
|
||||||
|
connectionType: 'network',
|
||||||
|
status: 'online',
|
||||||
|
ip: '192.168.1.50',
|
||||||
|
manufacturer: 'HP',
|
||||||
|
model: 'LaserJet Pro M404n',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'printer-2',
|
||||||
|
name: 'Brother MFC-L2750DW',
|
||||||
|
type: 'printers',
|
||||||
|
connectionType: 'network',
|
||||||
|
status: 'online',
|
||||||
|
ip: '192.168.1.51',
|
||||||
|
manufacturer: 'Brother',
|
||||||
|
model: 'MFC-L2750DW',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'printer-3',
|
||||||
|
name: 'Canon PIXMA',
|
||||||
|
type: 'printers',
|
||||||
|
connectionType: 'usb',
|
||||||
|
status: 'offline',
|
||||||
|
manufacturer: 'Canon',
|
||||||
|
model: 'PIXMA TR8620',
|
||||||
|
},
|
||||||
|
// Mock scanners
|
||||||
|
{
|
||||||
|
id: 'scanner-1',
|
||||||
|
name: 'Epson Perfection V600',
|
||||||
|
type: 'scanners',
|
||||||
|
connectionType: 'usb',
|
||||||
|
status: 'online',
|
||||||
|
manufacturer: 'Epson',
|
||||||
|
model: 'Perfection V600',
|
||||||
|
},
|
||||||
|
// Mock speakers
|
||||||
|
{
|
||||||
|
id: 'speaker-1',
|
||||||
|
name: 'Sonos One',
|
||||||
|
type: 'speakers',
|
||||||
|
connectionType: 'network',
|
||||||
|
status: 'online',
|
||||||
|
ip: '192.168.1.60',
|
||||||
|
manufacturer: 'Sonos',
|
||||||
|
model: 'One (Gen 2)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speaker-2',
|
||||||
|
name: 'HomePod mini',
|
||||||
|
type: 'speakers',
|
||||||
|
connectionType: 'network',
|
||||||
|
status: 'online',
|
||||||
|
ip: '192.168.1.61',
|
||||||
|
manufacturer: 'Apple',
|
||||||
|
model: 'HomePod mini',
|
||||||
|
},
|
||||||
|
// Mock NAS
|
||||||
|
{
|
||||||
|
id: 'nas-1',
|
||||||
|
name: 'Synology DS920+',
|
||||||
|
type: 'storage',
|
||||||
|
connectionType: 'network',
|
||||||
|
status: 'online',
|
||||||
|
ip: '192.168.1.100',
|
||||||
|
manufacturer: 'Synology',
|
||||||
|
model: 'DS920+',
|
||||||
|
},
|
||||||
|
// Mock UPS
|
||||||
|
{
|
||||||
|
id: 'ups-1',
|
||||||
|
name: 'APC Back-UPS Pro',
|
||||||
|
type: 'power',
|
||||||
|
connectionType: 'usb',
|
||||||
|
status: 'online',
|
||||||
|
manufacturer: 'APC',
|
||||||
|
model: 'Back-UPS Pro 1500',
|
||||||
|
},
|
||||||
|
// Mock cameras
|
||||||
|
{
|
||||||
|
id: 'camera-1',
|
||||||
|
name: 'Logitech C920',
|
||||||
|
type: 'cameras',
|
||||||
|
connectionType: 'usb',
|
||||||
|
status: 'online',
|
||||||
|
manufacturer: 'Logitech',
|
||||||
|
model: 'C920 HD Pro',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'camera-2',
|
||||||
|
name: 'Ring Indoor Cam',
|
||||||
|
type: 'cameras',
|
||||||
|
connectionType: 'network',
|
||||||
|
status: 'online',
|
||||||
|
ip: '192.168.1.70',
|
||||||
|
manufacturer: 'Ring',
|
||||||
|
model: 'Indoor Cam',
|
||||||
|
},
|
||||||
|
// Mock streaming devices
|
||||||
|
{
|
||||||
|
id: 'streaming-1',
|
||||||
|
name: 'Living Room Apple TV',
|
||||||
|
type: 'streaming',
|
||||||
|
connectionType: 'network',
|
||||||
|
status: 'online',
|
||||||
|
ip: '192.168.1.80',
|
||||||
|
manufacturer: 'Apple',
|
||||||
|
model: 'Apple TV 4K',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'streaming-2',
|
||||||
|
name: 'Bedroom Chromecast',
|
||||||
|
type: 'streaming',
|
||||||
|
connectionType: 'network',
|
||||||
|
status: 'online',
|
||||||
|
ip: '192.168.1.81',
|
||||||
|
manufacturer: 'Google',
|
||||||
|
model: 'Chromecast with Google TV',
|
||||||
|
},
|
||||||
|
// Mock USB devices
|
||||||
|
{
|
||||||
|
id: 'usb-1',
|
||||||
|
name: 'SanDisk Ultra',
|
||||||
|
type: 'usb',
|
||||||
|
connectionType: 'usb',
|
||||||
|
status: 'online',
|
||||||
|
manufacturer: 'SanDisk',
|
||||||
|
model: 'Ultra USB 3.0 128GB',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
private getMenuGroups(): ISecondaryMenuGroup[] {
|
||||||
|
const allCount = this.devices.length;
|
||||||
|
const getCount = (type: TPeripheralCategory) =>
|
||||||
|
this.devices.filter(d => d.type === type).length;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Devices',
|
||||||
|
iconName: 'lucide:monitor',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
iconName: 'lucide:layoutGrid',
|
||||||
|
action: () => this.activeCategory = 'all',
|
||||||
|
badge: allCount,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Output',
|
||||||
|
iconName: 'lucide:printer',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'printers',
|
||||||
|
iconName: 'lucide:printer',
|
||||||
|
action: () => this.activeCategory = 'printers',
|
||||||
|
badge: getCount('printers') || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'speakers',
|
||||||
|
iconName: 'lucide:speaker',
|
||||||
|
action: () => this.activeCategory = 'speakers',
|
||||||
|
badge: getCount('speakers') || undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Input',
|
||||||
|
iconName: 'lucide:scan',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'scanners',
|
||||||
|
iconName: 'lucide:scan',
|
||||||
|
action: () => this.activeCategory = 'scanners',
|
||||||
|
badge: getCount('scanners') || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cameras',
|
||||||
|
iconName: 'lucide:camera',
|
||||||
|
action: () => this.activeCategory = 'cameras',
|
||||||
|
badge: getCount('cameras') || undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Network',
|
||||||
|
iconName: 'lucide:network',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'storage',
|
||||||
|
iconName: 'lucide:hardDrive',
|
||||||
|
action: () => this.activeCategory = 'storage',
|
||||||
|
badge: getCount('storage') || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'streaming',
|
||||||
|
iconName: 'lucide:cast',
|
||||||
|
action: () => this.activeCategory = 'streaming',
|
||||||
|
badge: getCount('streaming') || undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Other',
|
||||||
|
iconName: 'lucide:plug',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'power',
|
||||||
|
iconName: 'lucide:batteryCharging',
|
||||||
|
action: () => this.activeCategory = 'power',
|
||||||
|
badge: getCount('power') || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'usb',
|
||||||
|
iconName: 'lucide:usb',
|
||||||
|
action: () => this.activeCategory = 'usb',
|
||||||
|
badge: getCount('usb') || undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSelectedItem(): ISecondaryMenuItem | null {
|
||||||
|
for (const group of this.getMenuGroups()) {
|
||||||
|
for (const item of group.items) {
|
||||||
|
if ('key' in item && item.key === this.activeCategory) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFilteredDevices(): IPeripheralDevice[] {
|
||||||
|
if (this.activeCategory === 'all') {
|
||||||
|
return this.devices;
|
||||||
|
}
|
||||||
|
return this.devices.filter(d => d.type === this.activeCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCategoryTitle(): string {
|
||||||
|
const titles: Record<TPeripheralCategory, string> = {
|
||||||
|
all: 'All Devices',
|
||||||
|
printers: 'Printers',
|
||||||
|
scanners: 'Scanners',
|
||||||
|
speakers: 'Speakers',
|
||||||
|
storage: 'Network Storage',
|
||||||
|
power: 'Power Devices',
|
||||||
|
cameras: 'Cameras',
|
||||||
|
streaming: 'Streaming Devices',
|
||||||
|
usb: 'USB Devices',
|
||||||
|
};
|
||||||
|
return titles[this.activeCategory];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCategoryDescription(): string {
|
||||||
|
const descriptions: Record<TPeripheralCategory, string> = {
|
||||||
|
all: 'View and manage all connected peripherals',
|
||||||
|
printers: 'Manage printers and print queues',
|
||||||
|
scanners: 'Configure scanners and scanning options',
|
||||||
|
speakers: 'Network speakers and audio devices',
|
||||||
|
storage: 'NAS devices and network storage',
|
||||||
|
power: 'UPS and power management devices',
|
||||||
|
cameras: 'Webcams and security cameras',
|
||||||
|
streaming: 'Apple TV, Chromecast, and streaming devices',
|
||||||
|
usb: 'USB storage and connected devices',
|
||||||
|
};
|
||||||
|
return descriptions[this.activeCategory];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceIcon(device: IPeripheralDevice): string {
|
||||||
|
const icons: Record<TPeripheralCategory, string> = {
|
||||||
|
all: 'lucide:monitor',
|
||||||
|
printers: 'lucide:printer',
|
||||||
|
scanners: 'lucide:scan',
|
||||||
|
speakers: 'lucide:speaker',
|
||||||
|
storage: 'lucide:hardDrive',
|
||||||
|
power: 'lucide:batteryCharging',
|
||||||
|
cameras: 'lucide:camera',
|
||||||
|
streaming: 'lucide:cast',
|
||||||
|
usb: 'lucide:usb',
|
||||||
|
};
|
||||||
|
return icons[device.type];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConnectionIcon(type: TConnectionType): string {
|
||||||
|
const icons: Record<TConnectionType, string> = {
|
||||||
|
network: 'lucide:wifi',
|
||||||
|
usb: 'lucide:usb',
|
||||||
|
bluetooth: 'lucide:bluetooth',
|
||||||
|
};
|
||||||
|
return icons[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleScan(): Promise<void> {
|
||||||
|
if (this.isScanning) return;
|
||||||
|
|
||||||
|
this.isScanning = true;
|
||||||
|
this.dispatchEvent(new CustomEvent('scan-start', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Simulate scanning
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
this.isScanning = false;
|
||||||
|
this.dispatchEvent(new CustomEvent('scan-complete', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDeviceClick(device: IPeripheralDevice): void {
|
||||||
|
this.dispatchEvent(new CustomEvent('device-select', {
|
||||||
|
detail: { device },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSetDefault(device: IPeripheralDevice, e: Event): void {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.devices = this.devices.map(d => ({
|
||||||
|
...d,
|
||||||
|
isDefault: d.type === device.type ? d.id === device.id : d.isDefault,
|
||||||
|
}));
|
||||||
|
this.dispatchEvent(new CustomEvent('device-set-default', {
|
||||||
|
detail: { device },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="peripherals-container">
|
||||||
|
<dees-appui-secondarymenu
|
||||||
|
.heading=${'Peripherals'}
|
||||||
|
.groups=${this.getMenuGroups()}
|
||||||
|
.selectedItem=${this.getSelectedItem()}
|
||||||
|
></dees-appui-secondarymenu>
|
||||||
|
<div class="content">
|
||||||
|
${this.renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderContent(): TemplateResult {
|
||||||
|
const devices = this.getFilteredDevices();
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-header-left">
|
||||||
|
<div class="panel-title">${this.getCategoryTitle()}</div>
|
||||||
|
<div class="panel-description">${this.getCategoryDescription()}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="scan-button ${this.isScanning ? 'scanning' : ''}"
|
||||||
|
@click=${this.handleScan}
|
||||||
|
?disabled=${this.isScanning}
|
||||||
|
>
|
||||||
|
<dees-icon .icon=${this.isScanning ? 'lucide:loader2' : 'lucide:radar'} .iconSize=${16}></dees-icon>
|
||||||
|
${this.isScanning ? 'Scanning...' : 'Scan for Devices'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.activeCategory === 'all'
|
||||||
|
? this.renderGroupedDevices(devices)
|
||||||
|
: this.renderDeviceList(devices)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderGroupedDevices(devices: IPeripheralDevice[]): TemplateResult {
|
||||||
|
const groups = new Map<TPeripheralCategory, IPeripheralDevice[]>();
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
const existing = groups.get(device.type) || [];
|
||||||
|
existing.push(device);
|
||||||
|
groups.set(device.type, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLabels: Record<TPeripheralCategory, string> = {
|
||||||
|
all: 'All',
|
||||||
|
printers: 'Printers',
|
||||||
|
scanners: 'Scanners',
|
||||||
|
speakers: 'Speakers',
|
||||||
|
storage: 'Network Storage',
|
||||||
|
power: 'Power Devices',
|
||||||
|
cameras: 'Cameras',
|
||||||
|
streaming: 'Streaming',
|
||||||
|
usb: 'USB Devices',
|
||||||
|
};
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${Array.from(groups.entries()).map(([category, categoryDevices]) => html`
|
||||||
|
<div class="device-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">${categoryLabels[category]}</span>
|
||||||
|
<span class="device-count">${categoryDevices.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-list">
|
||||||
|
${categoryDevices.map(device => this.renderDeviceItem(device))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDeviceList(devices: IPeripheralDevice[]): TemplateResult {
|
||||||
|
if (devices.length === 0) {
|
||||||
|
return this.renderEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="device-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Discovered Devices</span>
|
||||||
|
<span class="device-count">${devices.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-list">
|
||||||
|
${devices.map(device => this.renderDeviceItem(device))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDeviceItem(device: IPeripheralDevice): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="device-item" @click=${() => this.handleDeviceClick(device)}>
|
||||||
|
<div class="device-icon-wrapper ${device.status}">
|
||||||
|
<dees-icon .icon=${this.getDeviceIcon(device)} .iconSize=${24}></dees-icon>
|
||||||
|
</div>
|
||||||
|
<div class="device-info">
|
||||||
|
<div class="device-name">
|
||||||
|
${device.name}
|
||||||
|
${device.isDefault ? html`<span class="default-badge">Default</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="device-details">
|
||||||
|
<div class="device-detail">
|
||||||
|
<span class="status-indicator ${device.status}"></span>
|
||||||
|
${device.status.charAt(0).toUpperCase() + device.status.slice(1)}
|
||||||
|
</div>
|
||||||
|
${device.ip ? html`
|
||||||
|
<div class="device-detail">${device.ip}</div>
|
||||||
|
` : ''}
|
||||||
|
<span class="connection-type">
|
||||||
|
<dees-icon .icon=${this.getConnectionIcon(device.connectionType)} .iconSize=${10}></dees-icon>
|
||||||
|
${device.connectionType.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-actions">
|
||||||
|
${!device.isDefault && (device.type === 'printers' || device.type === 'cameras') ? html`
|
||||||
|
<button class="action-button" @click=${(e: Event) => this.handleSetDefault(device, e)}>
|
||||||
|
Set Default
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<dees-icon .icon=${'lucide:chevronRight'} .iconSize=${16}></dees-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderEmptyState(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="device-section">
|
||||||
|
<div class="empty-state">
|
||||||
|
<dees-icon class="empty-icon" .icon=${'lucide:searchX'} .iconSize=${48}></dees-icon>
|
||||||
|
<div class="empty-title">No devices found</div>
|
||||||
|
<div class="empty-description">
|
||||||
|
Click "Scan for Devices" to discover ${this.getCategoryTitle().toLowerCase()} on your network or connected via USB.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts_web/views/eco-view-peripherals/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-view-peripherals.js';
|
||||||
20
ts_web/views/eco-view-saasshare/eco-view-saasshare.demo.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
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-saasshare
|
||||||
|
.activePanel=${'apps'}
|
||||||
|
@request-approved=${(e: CustomEvent) => console.log('Request approved:', e.detail)}
|
||||||
|
@request-denied=${(e: CustomEvent) => console.log('Request denied:', e.detail)}
|
||||||
|
></eco-view-saasshare>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||