Compare commits

..

86 Commits

Author SHA1 Message Date
113c013ea9 1.9.2
Some checks failed
Default (tags) / security (push) Failing after 26s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-24 23:57:32 +00:00
0571d5bf4b fi(wysiwyg): fix navigation 2025-06-24 23:56:40 +00:00
5f86fdba72 update 2025-06-24 23:46:52 +00:00
474385a939 update 2025-06-24 23:15:56 +00:00
71d64fccb8 1.9.1
Some checks failed
Default (tags) / security (push) Failing after 28s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-24 22:46:24 +00:00
e9541da8ff refactor 2025-06-24 22:45:50 +00:00
68b4e9ec8e feat(wysiwyg): Add more block types 2025-06-24 20:32:03 +00:00
856d354b5a update 2025-06-24 18:52:48 +00:00
89a4a15e78 update 2025-06-24 18:43:51 +00:00
fca3638f7f implement image upload 2025-06-24 17:16:13 +00:00
90fc8bed35 update selection reversal 2025-06-24 17:09:19 +00:00
bd223f77d0 selection manipulation 2025-06-24 16:53:54 +00:00
1041814823 update 2025-06-24 16:49:40 +00:00
366544befc fix(wysiwyg): Improve text selection detection with block-level approach
- Remove global selectionchange listener in favor of block-level detection
- Add comprehensive debugging logs to track selection detection
- Add multiple event listeners (mouseup, keyup, selectstart) for better coverage
- Add debounced selection checking to avoid race conditions
- Add click-outside handler to hide formatting menu
- Simplify selection detection logic by removing complex shadow DOM traversal
2025-06-24 16:31:00 +00:00
3b93bd63a7 fix(wysiwyg): Fix text selection detection for formatting menu in Shadow DOM
- Update selection detection to properly handle Shadow DOM boundaries
- Use getComposedRanges API correctly according to MDN documentation
- Add direct selection detection within block components
- Dispatch custom events from blocks when text is selected
- Fix formatting menu positioning using selection rect from events
2025-06-24 16:17:00 +00:00
ca525ce7e3 feat(wysiwyg): implement backspace 2025-06-24 15:52:28 +00:00
83f153f654 update 2025-06-24 15:17:37 +00:00
75637c7793 fix(wysiwyg): fix demo 2025-06-24 15:15:46 +00:00
e0a125c9bd fix(wysiwyg): cursor position 2025-06-24 13:53:47 +00:00
4b2178cedd fix(wysiwyg):Improve Wysiwyg editor 2025-06-24 13:41:12 +00:00
08a4c361fa fix(wysiwyg):Improve Wysiwyg editor 2025-06-24 12:24:02 +00:00
35a648d450 refactor(wysiwyg): Clean up code and ensure drag-drop works with programmatic rendering
- Update drag handler to work without requestUpdate calls
- Remove duplicate modal methods (using WysiwygModalManager)
- Clean up unused imports and methods
- Ensure all DOM updates are programmatic
- Add comprehensive test files for all features
- Follow separation of concerns principles
2025-06-24 11:12:56 +00:00
1c76ade150 fix(wysiwyg): Implement programmatic rendering to eliminate focus loss during typing
- Convert parent component to use static rendering with programmatic DOM manipulation
- Remove all reactive state that could trigger re-renders during editing
- Delay content sync to avoid interference with typing (2s auto-save)
- Update all block operations to use manual DOM updates instead of Lit re-renders
- Fix specific issue where typing + arrow keys caused focus loss
- Add comprehensive focus management documentation
2025-06-24 11:06:02 +00:00
8b02c5aea3 fix(dees-modal): theming 2025-06-24 10:45:06 +00:00
c82c407350 fix(dees-moadl): theming 2025-06-24 08:23:24 +00:00
169f74aa2e Improve Wysiwyg editor 2025-06-24 08:19:53 +00:00
e4a042907a Improve Wysiwyg editor 2025-06-24 07:21:09 +00:00
7ce282c500 feat(wysiwyg): Add language selection for code blocks, block settings menu, fix cursor navigation, and update demo to use dees-panel
- Added modal to select programming language when creating code blocks
- Added settings button (3 dots) on each block for configuration
- Fixed cursor not jumping to new block after pressing Enter
- Special handling for code blocks: Enter creates new line, Shift+Enter creates new block
- Display language indicator on code blocks
- Updated demo to use dees-panel instead of demo-section divs
- Added language selection in block settings modal for existing code blocks
2025-06-23 21:28:58 +00:00
302777feff feat(editor): Add wysiwyg editor 2025-06-23 21:18:03 +00:00
cdcd4f79c8 feat(editor): Add wysiwyg editor 2025-06-23 21:15:04 +00:00
f2e6342a61 feat(editor): Add wysiwyg editor 2025-06-23 18:12:24 +00:00
0f02e7d00f feat(editor): Add wysiwyg editor 2025-06-23 18:07:46 +00:00
a1079cbbdd feat(editor): Add wysiwyg editor 2025-06-23 18:02:40 +00:00
58af08cb0d feat(editor): Add wysiwyg editor 2025-06-23 17:52:10 +00:00
6626726029 feat(editor): Add wysiwyg editor 2025-06-23 17:36:39 +00:00
f3afbb2e48 feat(editor): Add wysiwyg editor 2025-06-23 17:14:47 +00:00
fbd52ee9a5 feat(editor): Add wysiwyg editor 2025-06-23 17:09:53 +00:00
3284d91c2a feat(editor): Add wysiwyg editor 2025-06-23 17:05:28 +00:00
22e6b74c4f 1.9.0
Some checks failed
Default (tags) / security (push) Failing after 27s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-22 20:32:59 +00:00
4de835474b feat(form-inputs): Improve form input consistency and auto spacing across inputs and buttons 2025-06-22 20:32:59 +00:00
024d8af40d 1.8.20
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 21s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-20 00:11:30 +00:00
808b74fa17 fix(deps): Update dependency versions: bump @design.estate/dees-domtools from ^2.1.1 to ^2.3.3, @design.estate/dees-element from ^2.0.42 to ^2.0.44, lucide from ^0.515.0 to ^0.518.0, and @git.zone/tsbundle from ^2.0.15 to ^2.4.0 2025-06-20 00:11:30 +00:00
202881ef1a 1.8.19
Some checks failed
Default (tags) / security (push) Failing after 42s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 14:01:14 +00:00
7de3d451ad fix: Change import to type for DeesForm in dees-form-submit 2025-06-19 14:01:07 +00:00
f0e0430016 1.8.18
Some checks failed
Default (tags) / security (push) Failing after 44s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 13:48:18 +00:00
873579fc97 fix: Import dees-button in dees-form-submit for button functionality 2025-06-19 13:47:52 +00:00
d321db363d 1.8.17
Some checks failed
Default (tags) / security (push) Failing after 45s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:54:51 +00:00
73c1874e3f 1.8.16
Some checks failed
Default (tags) / security (push) Failing after 45s
Default (tags) / test (push) Failing after 38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:53:28 +00:00
1aa06398a0 1.8.15
Some checks failed
Default (tags) / security (push) Failing after 47s
Default (tags) / test (push) Failing after 38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:42:53 +00:00
99b23236a1 fix: Update default button text handling and improve demo example in dees-form-submit 2025-06-19 12:42:50 +00:00
d1e7e5447c 1.8.14
Some checks failed
Default (tags) / security (push) Failing after 49s
Default (tags) / test (push) Failing after 38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:31:45 +00:00
4f22a98b78 refactor: Remove unnecessary imports in dees-form-submit and dees-simple-login 2025-06-19 12:31:33 +00:00
eb09aee264 1.8.13
Some checks failed
Default (tags) / security (push) Failing after 51s
Default (tags) / test (push) Failing after 38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:10:41 +00:00
c3fca1db36 1.8.12
Some checks failed
Default (tags) / security (push) Failing after 45s
Default (tags) / test (push) Failing after 18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:10:18 +00:00
2a5e6ee37a feat: Register dees-button in dees-form-submit and import necessary components in dees-simple-login 2025-06-19 12:09:48 +00:00
41e2125dc7 1.8.11
Some checks failed
Default (tags) / security (push) Failing after 55s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 11:55:09 +00:00
2a76b67e9a 1.8.10 2025-06-19 11:50:37 +00:00
d697958536 feat: Improve login event handling and form data validation in dees-simple-login component 2025-06-19 11:50:24 +00:00
1789807f90 1.8.9 2025-06-19 11:39:23 +00:00
03315db863 feat: Enhance demo components with new input types and layout options
- Added dropdown and radio input components to the demo for application settings.
- Introduced horizontal layout for display preferences and notification settings.
- Implemented checkbox demo with programmatic selection and clear functionality.
- Created file upload and quantity selector demos with various states and configurations.
- Added comprehensive radio input demo showcasing group behavior and various states.
- Developed text input demo with validation states and advanced features like password visibility.
- Introduced a new panel component for better content organization in demos.
2025-06-19 11:39:16 +00:00
79b1a4ea9f feat: Implement unified input component architecture with standardized margins and layout modes 2025-06-19 09:41:00 +00:00
8fb5e2e2a2 1.8.8 2025-06-17 11:51:47 +00:00
640a69f4cd feat: Integrate dees-statsgrid component into dashboard view with dynamic stats tiles 2025-06-17 11:51:34 +00:00
bdb666cbe2 feat: Enhance demo components with improved layout, styling, and functionality for login and dashboard views 2025-06-17 11:45:25 +00:00
8a1d830376 feat: Enhance context menu functionality with keyboard navigation and improved item handling 2025-06-17 11:39:16 +00:00
c1e8f8c2a6 feat: Enhance selection options with icons and dividers for improved UI 2025-06-17 10:00:50 +00:00
a8f0e5659e feat: Add profile dropdown component and integrate with appbar for user menu 2025-06-17 09:55:28 +00:00
cd3c7c8e63 feat: Refactor theming in app components to use dynamic CSS variables 2025-06-17 08:58:47 +00:00
5b4319432c feat: Enhance dees-appui components with dynamic tab and menu configurations
- Updated dees-appui-mainmenu to accept dynamic tabs with actions and icons.
- Modified dees-appui-mainselector to support dynamic selection options.
- Introduced dees-appui-tabs for improved tab navigation with customizable styles.
- Added dees-appui-view to manage views with tabs and content dynamically.
- Implemented event dispatching for tab and option selections.
- Created a comprehensive architecture documentation for dees-appui system.
- Added demo implementations for dees-appui-base and other components.
- Improved responsiveness and user interaction feedback across components.
2025-06-17 08:41:36 +00:00
e33f4e7a70 1.8.7 2025-06-16 23:48:47 +00:00
f101df9329 1.8.6 2025-06-16 23:48:37 +00:00
d926f5c5e4 1.8.5 2025-06-16 23:48:13 +00:00
8ad754c9bc feat(dees-appui-appbar): implement dynamic menu system with support for submenus, shortcuts, and user account features
feat(dees-contextmenu): adjust menu item positioning for improved alignment
fix(dees-appui-appbar.demo): add demo functionality for app bar with dynamic menu items and user interactions
feat(interfaces): create IAppBarMenuItem interface for enhanced menu item configurations
docs: add comprehensive improvement plan for dees-appui-appbar component
2025-06-16 23:16:25 +00:00
ed20e04e96 fix(dees-catalog): update @design.estate/dees-wcctools dependency to version 1.0.98 for compatibility and enhance demo functionality with real-time data updates 2025-06-16 22:23:22 +00:00
daef1aa841 fix(dees-catalog): update @design.estate/dees-wcctools dependency to version 1.0.97 for compatibility 2025-06-16 16:04:04 +00:00
339ea2d7d4 fix(dees-catalog): update @design.estate/dees-wcctools dependency to version 1.0.96 for compatibility and add demotools import in demo files 2025-06-16 15:11:52 +00:00
036bba44ae fix(dees-catalog): update @design.estate/dees-wcctools dependency to version 1.0.95 for compatibility
feat(dees-chart-area): refactor demo function for improved dataset handling and real-time updates
feat(dees-chart-log): enhance demo function with simulation controls for server log generation
2025-06-16 14:59:22 +00:00
48fbeb397d feat(dees-button-group): add new button group component with demo and styling
fix(dees-chart-area): improve real-time updates and chart element handling
fix(dees-chart-log): refactor demo to store log element reference
chore: update dependencies in package.json and pnpm-lock.yaml
2025-06-16 14:37:09 +00:00
346abfa685 1.8.4
Some checks failed
Default (tags) / security (push) Failing after 32s
Default (tags) / test (push) Failing after 18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-12 11:11:36 +00:00
f1123f319f fix(dees-catalog): downgrade @webcontainer/api to version 1.2.0 for compatibility 2025-06-12 11:11:21 +00:00
ac15da9c82 1.8.3
Some checks failed
Default (tags) / security (push) Failing after 34s
Default (tags) / test (push) Failing after 19s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-12 11:09:27 +00:00
b9432c8489 feat(dees-chart-area): Enhance chart component with dynamic datasets, real-time updates, and improved demo features 2025-06-12 11:09:14 +00:00
b35b1fbae7 1.8.2
Some checks failed
Default (tags) / security (push) Failing after 36s
Default (tags) / test (push) Failing after 21s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-12 11:01:20 +00:00
e39590df2c fix(dees-chart-area): Improve resize handling and initial rendering for better responsiveness
fix(dees-chart-log): Simplify firstUpdated method by removing unnecessary variable
2025-06-12 11:00:33 +00:00
fad7fda2a6 feat(dees-chart-log): Enhance log component with realistic log simulation and improved UI controls 2025-06-12 10:44:21 +00:00
987f557c60 Enhance DeesToast component with new features and improved demo
- Updated README to reflect new toast positions and convenience methods.
- Expanded demo functionality to showcase various toast types, positions, and durations.
- Added programmatic control for toast dismissal and multiple toast notifications.
- Introduced new toast positions: top-center and bottom-center.
- Implemented a progress bar for auto-dismiss functionality.
- Improved styling and animations for better user experience.
2025-06-12 09:33:46 +00:00
118 changed files with 24810 additions and 2195 deletions

View File

@ -1,5 +1,22 @@
# Changelog
## 2025-06-22 - 1.9.0 - feat(form-inputs)
Improve form input consistency and auto spacing across inputs and buttons
- Add an 'insideForm' property to dees-button for auto-detection and proper margin adjustment in forms.
- Update dees-input-radio to include a 'name' property so that radio buttons in the same group are mutually exclusive.
- Enhance dees-form to group radio inputs properly when collecting form data.
- Revise readme.hints.md and readme.plan.md to document changes and provide guidance for dees-input-radio.
- Update demos for dees-button and dees-form to showcase correct spacing in vertical and horizontal layouts.
## 2025-06-20 - 1.8.20 - fix(deps)
Update dependency versions: bump @design.estate/dees-domtools from ^2.1.1 to ^2.3.3, @design.estate/dees-element from ^2.0.42 to ^2.0.44, lucide from ^0.515.0 to ^0.518.0, and @git.zone/tsbundle from ^2.0.15 to ^2.4.0
- Upgrade @design.estate/dees-domtools from ^2.1.1 to ^2.3.3
- Upgrade @design.estate/dees-element from ^2.0.42 to ^2.0.44
- Upgrade lucide from ^0.515.0 to ^0.518.0
- Upgrade @git.zone/tsbundle from ^2.0.15 to ^2.4.0
## 2025-06-10 - 1.8.1 - fix(dees-statsgrid)
Adjust stats grid styling for better alignment and improved visualizations in gauge and trend tiles.

View File

@ -1,13 +1,13 @@
{
"name": "@design.estate/dees-catalog",
"version": "1.8.1",
"version": "1.9.2",
"private": false,
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
"main": "dist_ts_web/index.js",
"typings": "dist_ts_web/index.d.ts",
"type": "module",
"scripts": {
"test": "tstest test/ --web",
"test": "tstest test/ --web --verbose --timeout 30",
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
"watch": "tswatch element",
"buildDocs": "tsdoc"
@ -15,9 +15,9 @@
"author": "Lossless GmbH",
"license": "MIT",
"dependencies": {
"@design.estate/dees-domtools": "^2.1.1",
"@design.estate/dees-element": "^2.0.41",
"@design.estate/dees-wcctools": "^1.0.90",
"@design.estate/dees-domtools": "^2.3.3",
"@design.estate/dees-element": "^2.0.45",
"@design.estate/dees-wcctools": "^1.0.98",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
@ -25,25 +25,25 @@
"@push.rocks/smarti18n": "^1.0.4",
"@push.rocks/smartpromise": "^4.2.0",
"@push.rocks/smartstring": "^4.0.15",
"@tsclass/tsclass": "^9.0.0",
"@tsclass/tsclass": "^9.2.0",
"@webcontainer/api": "1.2.0",
"apexcharts": "^4.3.0",
"apexcharts": "^4.7.0",
"highlight.js": "11.11.1",
"ibantools": "^4.5.1",
"lucide": "^0.501.0",
"lucide": "^0.522.0",
"monaco-editor": "^0.52.2",
"pdfjs-dist": "^4.10.38",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.1.84",
"@git.zone/tsbundle": "^2.0.15",
"@git.zone/tstest": "^1.0.90",
"@git.zone/tsbuild": "^2.6.4",
"@git.zone/tsbundle": "^2.4.0",
"@git.zone/tstest": "^2.3.1",
"@git.zone/tswatch": "^2.0.37",
"@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/tapbundle": "^5.5.6",
"@types/node": "^22.14.1"
"@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^22.0.0"
},
"files": [
"ts/**/*",

2759
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,513 @@
# Building Applications with dees-appui Architecture
## Overview
The dees-appui system provides a comprehensive framework for building desktop-style web applications with a consistent layout, navigation, and view management system. This document outlines the architecture and best practices for building applications using these components.
## Core Architecture
### Component Hierarchy
```
dees-appui-base
├── dees-appui-appbar (top menu bar)
├── dees-appui-mainmenu (left sidebar - primary navigation)
├── dees-appui-mainselector (second sidebar - contextual navigation)
├── dees-appui-maincontent (main content area)
│ └── dees-appui-view (view container)
│ └── dees-appui-tabs (tab navigation within views)
└── dees-appui-activitylog (right sidebar - optional)
```
### View-Based Architecture
The system is built around the concept of **Views** - self-contained modules that represent different sections of your application. Each view can have:
- Its own tabs for sub-navigation
- Menu items for the selector (contextual navigation)
- Content areas with dynamic loading
- State management
- Event handling
## Implementation Plan
### Phase 1: Application Shell Setup
```typescript
// app-shell.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { IAppView } from '@design.estate/dees-catalog';
@customElement('my-app-shell')
export class MyAppShell extends LitElement {
@property({ type: Array })
views: IAppView[] = [];
@property({ type: String })
activeViewId: string = '';
render() {
const activeView = this.views.find(v => v.id === this.activeViewId);
return html`
<dees-appui-base
.appbarMenuItems=${this.getAppBarMenuItems()}
.appbarBreadcrumbs=${this.getBreadcrumbs()}
.appbarTheme=${'dark'}
.appbarUser=${{ name: 'User', status: 'online' }}
.mainmenuTabs=${this.getMainMenuTabs()}
.mainselectorOptions=${activeView?.menuItems || []}
@mainmenu-tab-select=${this.handleMainMenuSelect}
@mainselector-option-select=${this.handleSelectorSelect}
>
<dees-appui-view
slot="maincontent"
.viewConfig=${activeView}
@view-tab-select=${this.handleViewTabSelect}
></dees-appui-view>
</dees-appui-base>
`;
}
}
```
### Phase 2: View Definition
```typescript
// views/dashboard-view.ts
export const dashboardView: IAppView = {
id: 'dashboard',
name: 'Dashboard',
description: 'System overview and metrics',
iconName: 'home',
tabs: [
{
key: 'overview',
iconName: 'chart-line',
action: () => console.log('Overview selected'),
content: () => html`
<dashboard-overview></dashboard-overview>
`
},
{
key: 'metrics',
iconName: 'tachometer-alt',
action: () => console.log('Metrics selected'),
content: () => html`
<dashboard-metrics></dashboard-metrics>
`
},
{
key: 'alerts',
iconName: 'bell',
action: () => console.log('Alerts selected'),
content: () => html`
<dashboard-alerts></dashboard-alerts>
`
}
],
menuItems: [
{ key: 'Time Range', action: () => showTimeRangeSelector() },
{ key: 'Refresh Rate', action: () => showRefreshSettings() },
{ key: 'Export Data', action: () => exportDashboardData() }
]
};
```
### Phase 3: View Management System
```typescript
// services/view-manager.ts
export class ViewManager {
private views: Map<string, IAppView> = new Map();
private activeView: IAppView | null = null;
private viewCache: Map<string, any> = new Map();
registerView(view: IAppView) {
this.views.set(view.id, view);
}
async activateView(viewId: string) {
const view = this.views.get(viewId);
if (!view) throw new Error(`View ${viewId} not found`);
// Deactivate current view
if (this.activeView) {
await this.deactivateView(this.activeView.id);
}
// Activate new view
this.activeView = view;
// Update navigation
this.updateMainSelector(view.menuItems);
this.updateBreadcrumbs(view);
// Load view data if needed
if (!this.viewCache.has(viewId)) {
await this.loadViewData(view);
}
return view;
}
private async loadViewData(view: IAppView) {
// Implement lazy loading of view data
const viewData = await import(`./views/${view.id}/data.js`);
this.viewCache.set(view.id, viewData);
}
}
```
### Phase 4: Navigation Integration
```typescript
// navigation/app-navigation.ts
export class AppNavigation {
constructor(
private viewManager: ViewManager,
private appShell: MyAppShell
) {}
setupMainMenu(): ITab[] {
return [
{
key: 'dashboard',
iconName: 'home',
action: () => this.navigateToView('dashboard')
},
{
key: 'projects',
iconName: 'folder',
action: () => this.navigateToView('projects')
},
{
key: 'analytics',
iconName: 'chart-bar',
action: () => this.navigateToView('analytics')
},
{
key: 'settings',
iconName: 'cog',
action: () => this.navigateToView('settings')
}
];
}
async navigateToView(viewId: string) {
const view = await this.viewManager.activateView(viewId);
this.appShell.activeViewId = viewId;
// Update URL
window.history.pushState(
{ viewId },
view.name,
`/${viewId}`
);
}
handleBrowserNavigation() {
window.addEventListener('popstate', (event) => {
if (event.state?.viewId) {
this.navigateToView(event.state.viewId);
}
});
}
}
```
### Phase 5: Dynamic View Loading
```typescript
// views/view-loader.ts
export class ViewLoader {
private loadedViews: Set<string> = new Set();
async loadView(viewId: string): Promise<IAppView> {
if (this.loadedViews.has(viewId)) {
return this.getViewConfig(viewId);
}
// Dynamic import
const viewModule = await import(`./views/${viewId}/index.js`);
const viewConfig = viewModule.default as IAppView;
// Register custom elements if needed
if (viewModule.registerElements) {
await viewModule.registerElements();
}
this.loadedViews.add(viewId);
return viewConfig;
}
async preloadViews(viewIds: string[]) {
const promises = viewIds.map(id => this.loadView(id));
await Promise.all(promises);
}
}
```
## Best Practices
### 1. View Organization
```
src/
├── views/
│ ├── dashboard/
│ │ ├── index.ts # View configuration
│ │ ├── data.ts # Data fetching/management
│ │ ├── components/ # View-specific components
│ │ │ ├── dashboard-overview.ts
│ │ │ ├── dashboard-metrics.ts
│ │ │ └── dashboard-alerts.ts
│ │ └── styles.ts # View-specific styles
│ ├── projects/
│ │ └── ...
│ └── settings/
│ └── ...
├── services/
│ ├── view-manager.ts
│ ├── navigation.ts
│ └── state-manager.ts
└── app-shell.ts
```
### 2. State Management
```typescript
// services/state-manager.ts
export class StateManager {
private viewStates: Map<string, any> = new Map();
saveViewState(viewId: string, state: any) {
this.viewStates.set(viewId, {
...this.getViewState(viewId),
...state,
lastUpdated: Date.now()
});
}
getViewState(viewId: string): any {
return this.viewStates.get(viewId) || {};
}
// Persist to localStorage
persistState() {
const serialized = JSON.stringify(
Array.from(this.viewStates.entries())
);
localStorage.setItem('app-state', serialized);
}
restoreState() {
const saved = localStorage.getItem('app-state');
if (saved) {
const entries = JSON.parse(saved);
this.viewStates = new Map(entries);
}
}
}
```
### 3. View Communication
```typescript
// events/view-events.ts
export class ViewEventBus {
private eventTarget = new EventTarget();
emit(eventName: string, detail: any) {
this.eventTarget.dispatchEvent(
new CustomEvent(eventName, { detail })
);
}
on(eventName: string, handler: (detail: any) => void) {
this.eventTarget.addEventListener(eventName, (e: CustomEvent) => {
handler(e.detail);
});
}
// Cross-view communication
sendMessage(fromView: string, toView: string, message: any) {
this.emit('view-message', {
from: fromView,
to: toView,
message
});
}
}
```
### 4. Responsive Design
```typescript
// views/responsive-view.ts
export const createResponsiveView = (config: IAppView): IAppView => {
return {
...config,
tabs: config.tabs.map(tab => ({
...tab,
content: () => html`
<div class="view-content ${getDeviceClass()}">
${tab.content()}
</div>
`
}))
};
};
function getDeviceClass(): string {
const width = window.innerWidth;
if (width < 768) return 'mobile';
if (width < 1024) return 'tablet';
return 'desktop';
}
```
### 5. Performance Optimization
```typescript
// optimization/lazy-components.ts
export const lazyComponent = (
importFn: () => Promise<any>,
componentName: string
) => {
let loaded = false;
return () => {
if (!loaded) {
importFn().then(() => {
loaded = true;
});
return html`<dees-spinner></dees-spinner>`;
}
return html`<${componentName}></${componentName}>`;
};
};
// Usage in view
tabs: [
{
key: 'heavy-component',
content: lazyComponent(
() => import('./components/heavy-component.js'),
'heavy-component'
)
}
]
```
## Advanced Features
### 1. View Permissions
```typescript
interface IAppViewWithPermissions extends IAppView {
requiredPermissions?: string[];
visibleTo?: (user: User) => boolean;
}
class PermissionManager {
canAccessView(view: IAppViewWithPermissions, user: User): boolean {
if (view.visibleTo) {
return view.visibleTo(user);
}
if (view.requiredPermissions) {
return view.requiredPermissions.every(
perm => user.permissions.includes(perm)
);
}
return true;
}
}
```
### 2. View Lifecycle Hooks
```typescript
interface IAppViewLifecycle extends IAppView {
onActivate?: () => Promise<void>;
onDeactivate?: () => Promise<void>;
onTabChange?: (oldTab: string, newTab: string) => void;
onDestroy?: () => void;
}
```
### 3. Dynamic Menu Generation
```typescript
class DynamicMenuBuilder {
buildMainMenu(views: IAppView[], user: User): ITab[] {
return views
.filter(view => this.canShowInMenu(view, user))
.map(view => ({
key: view.id,
iconName: view.iconName || 'file',
action: () => this.navigation.navigateToView(view.id)
}));
}
buildSelectorMenu(view: IAppView, context: any): ISelectionOption[] {
const baseItems = view.menuItems || [];
const contextItems = this.getContextualItems(view, context);
return [...baseItems, ...contextItems];
}
}
```
## Migration Strategy
For existing applications:
1. **Identify Views**: Map existing routes/pages to views
2. **Extract Components**: Move page-specific components into view folders
3. **Define View Configs**: Create IAppView configurations
4. **Update Navigation**: Replace existing routing with view navigation
5. **Migrate State**: Move page state to ViewManager
6. **Test & Optimize**: Ensure smooth transitions and performance
## Example Application Structure
```typescript
// main.ts
import { ViewManager } from './services/view-manager.js';
import { AppNavigation } from './services/navigation.js';
import { dashboardView } from './views/dashboard/index.js';
import { projectsView } from './views/projects/index.js';
import { settingsView } from './views/settings/index.js';
const app = new MyAppShell();
const viewManager = new ViewManager();
const navigation = new AppNavigation(viewManager, app);
// Register views
viewManager.registerView(dashboardView);
viewManager.registerView(projectsView);
viewManager.registerView(settingsView);
// Setup navigation
app.views = [dashboardView, projectsView, settingsView];
navigation.setupMainMenu();
navigation.handleBrowserNavigation();
// Initial navigation
navigation.navigateToView('dashboard');
document.body.appendChild(app);
```
This architecture provides:
- **Modularity**: Each view is self-contained
- **Scalability**: Easy to add new views
- **Performance**: Lazy loading and caching
- **Consistency**: Unified navigation and layout
- **Flexibility**: Customizable per view
- **Maintainability**: Clear separation of concerns

View File

@ -2,3 +2,515 @@
* Give a short rundown of components and a few points abputspecific features on each.
* Try to list all components in a summary.
* Then list all components with a short description.
## Chart Components
### dees-chart-area
- Fully functional area chart component using ApexCharts
- Displays time-series data with gradient fills
- Responsive with ResizeObserver (debounced to prevent flicker)
- Fixed: Chart now properly respects container boundaries on initial render
- Overflow prevention with proper CSS containment
- Enhanced demo features:
- Multiple dataset examples (System Usage, Network Traffic, Sales Analytics)
- Real-time data simulation with automatic updates
- Dynamic dataset switching
- Customizable Y-axis formatters (percentages, currency, units)
- Data randomization for testing
- Manual data point addition
- Properties:
- `label`: Chart title
- `series`: ApexAxisChartSeries data
- `yAxisFormatter`: Custom Y-axis label formatter function
- Methods:
- `updateSeries()`: Update chart data
- `appendData()`: Add new data points to existing series
- Demo uses global reference to access chart element (window.__demoChartElement)
### dees-chart-log
- Server log viewer component (not a chart despite the name)
- Terminal-style interface with monospace font
- Supports log levels: debug, info, warn, error, success
- Features:
- Auto-scroll toggle
- Clear logs button
- Colored log levels
- Timestamp with milliseconds
- Source labels for log entries
- Maximum 1000 entries (configurable)
- Light/dark theme support
- Demo includes realistic server log simulation
- Note: In demos, buttons use `@clicked` event (not `@click`)
- Demo uses global reference to access log element (window.__demoLogElement)
## UI Components
### dees-button-group
- Groups multiple buttons together with a unified background
- Properties:
- `label`: Optional label text displayed before the buttons
- `direction`: 'horizontal' | 'vertical' layout
- Features:
- Light/dark theme support
- Flexible layout with proper spacing
- Works with all button types (normal, highlighted, success, danger)
- Use cases:
- View mode selectors
- Action grouping
- Navigation options
- Filter controls
## Form Components
### dees-input-radio
- Radio button component with proper group behavior
- Properties:
- `name`: Group name for mutually exclusive selection
- `key`: Unique identifier for the radio option
- `value`: Boolean indicating selection state
- `label`: Display label
- Features:
- Automatic group management (radios with same name are mutually exclusive)
- Cannot be deselected by clicking (proper radio behavior)
- Form integration: Radio groups are collected by name, value is the selected radio's key
- Works both inside and outside forms
- Supports disabled state
- Fixed: Radio buttons now properly deselect others in the group on first click
- Note: When using in forms, set both `name` (for grouping) and `key` (for the value)
## WYSIWYG Editor Architecture
### Recent Refactoring (2025-06-24)
The WYSIWYG editor has been refactored to improve maintainability and separation of concerns:
#### New Handler Classes
1. **WysiwygBlockOperations** (`wysiwyg.blockoperations.ts`)
- Manages all block-related operations
- Methods: createBlock, insertBlockAfter, removeBlock, findBlock, focusBlock, etc.
- Centralized block manipulation logic
2. **WysiwygInputHandler** (`wysiwyg.inputhandler.ts`)
- Handles all input events for blocks
- Manages block content updates based on type
- Detects block type transformations
- Handles slash commands
- Manages auto-save with debouncing
3. **WysiwygKeyboardHandler** (`wysiwyg.keyboardhandler.ts`)
- Handles all keyboard events
- Manages formatting shortcuts (Cmd/Ctrl + B/I/U/K)
- Handles special keys: Tab, Enter, Backspace
- Manages slash menu navigation
4. **WysiwygDragDropHandler** (`wysiwyg.dragdrophandler.ts`)
- Manages drag and drop operations
- Tracks drag state
- Handles visual feedback during drag
- Manages block reordering
5. **WysiwygModalManager** (`wysiwyg.modalmanager.ts`)
- Static methods for showing modals
- Language selection for code blocks
- Block settings modal
- Reusable modal patterns
#### Main Component Updates
The main `DeesInputWysiwyg` component now:
- Instantiates handler classes in `connectedCallback`
- Delegates complex operations to appropriate handlers
- Maintains cleaner, more focused code
- Better separation of concerns
#### Benefits
- Reduced main component size from 1100+ lines
- Each handler class is focused on a single responsibility
- Easier to test individual components
- Better code organization
- Improved maintainability
#### Fixed Issues
- Enter key no longer duplicates content in new blocks
- Removed problematic `setBlockContents()` method
- Content is now managed directly through DOM properties
- Better timing for block creation and focus
- Slash menu no longer disappears immediately on first "/" press
- Focus is properly maintained when slash menu opens
- Removed duplicate event handling methods from main component
- Simplified focus management throughout the editor
#### Additional Refactoring (2025-06-24 - Part 2)
- **Removed duplicate code**: handleBlockInput and handleBlockKeyDown methods removed from main component
- **Simplified focus management**: Removed complex lifecycle methods and timers
- **Fixed slash menu behavior**: Changed to click events and proper event prevention
- **dees-wysiwyg-block component**: Now uses static HTML rendering for better content control
- **Improved formatting preservation**: HTML formatting (bold, italic, etc.) properly preserved in all block types
#### Notes
- All input handling now goes through WysiwygInputHandler
- All keyboard handling goes through WysiwygKeyboardHandler
- The slash menu uses click events instead of mousedown for better UX
- Focus is maintained using requestAnimationFrame for better timing
- The refactoring maintains all existing functionality with improved reliability
### Global Menu Architecture (2025-06-24 - Part 3)
The slash menu and formatting menu have been refactored to render globally instead of inside the wysiwyg component. This fixes focus loss issues that were occurring when the menus were re-rendered with the component.
#### Key Components:
1. **DeesSlashMenu** (`dees-slash-menu.ts`)
- Singleton component that renders globally in the document body
- Accessed via `DeesSlashMenu.getInstance()`
- Manages its own visibility, position, and filtering
- Emits callbacks when items are selected
2. **DeesFormattingMenu** (`dees-formatting-menu.ts`)
- Singleton component that renders globally in the document body
- Accessed via `DeesFormattingMenu.getInstance()`
- Shows when text is selected
- Applies formatting commands via callback
3. **Integration in DeesInputWysiwyg**
- Stores singleton instances: `private slashMenu = DeesSlashMenu.getInstance()`
- Shows menus with absolute positioning
- Menus handle their own rendering and state management
#### Benefits:
- No focus loss when menus appear/disappear
- Better performance (menus don't re-render with component)
- Cleaner separation of concerns
- Menus persist across component updates
#### Usage:
```typescript
// Show slash menu
this.slashMenu.show(
{ x: cursorX, y: cursorY },
(type: string) => this.insertBlock(type)
);
// Show formatting menu
this.formattingMenu.show(
{ x: selectionX, y: selectionY },
(command: string) => this.applyFormat(command)
);
```
#### Previous Issues Fixed:
- Slash menu was disappearing immediately on first "/" press
- Focus was lost when menus appeared
- Text selection was not working properly
- Cursor position was lost after menu interactions
### Arrow Key Navigation (2025-06-24 - Part 4)
Enhanced arrow key handling for seamless navigation between blocks:
#### Features:
1. **ArrowUp at block start**: Automatically navigates to the end of the previous block
2. **ArrowDown at block end**: Automatically navigates to the beginning of the next block
3. **Smart detection**: Checks actual cursor position within the block content
4. **Slash menu integration**: When slash menu is open, arrow keys navigate menu items instead
5. **No focus loss**: Navigation maintains focus throughout
#### Implementation:
- Added `handleArrowUp()` and `handleArrowDown()` methods to `WysiwygKeyboardHandler`
- Smart cursor position detection for different block types (text, lists, etc.)
- Helper method `getLastTextNode()` for finding the last text position in complex HTML
- Prevents default behavior only when navigating between blocks
- Skips divider blocks during navigation
### Focus Management Improvements (2025-06-24 - Part 5)
Enhanced focus management to prevent focus loss during various operations:
#### Key Improvements:
1. **Formatting Without execCommand**:
- Replaced deprecated `document.execCommand` with modern DOM manipulation
- Proper selection restoration after formatting
- Async formatting operations to maintain focus
2. **Link Dialog**:
- Replaced `prompt()` with custom modal dialog
- Maintains focus context during async operations
- Auto-focuses input field in modal
3. **Robust Focus Methods**:
- Double `requestAnimationFrame` for DOM update timing
- Fallback focus attempts with microtasks
- Contenteditable attribute verification
4. **Cursor Positioning**:
- Enhanced `setCursorToStart/End` with edge case handling
- Zero-width space insertion for empty elements
- Recursive node traversal for complex HTML structures
5. **Async Keyboard Shortcuts**:
- Formatting shortcuts use Promise resolution
- Prevents focus loss during rapid keyboard input
#### Implementation Details:
- `focusWithCursor()` method now handles empty blocks and complex HTML
- `applyFormat()` is async and properly restores selection
- Link creation no longer uses blocking `prompt()` dialog
- All focus operations use proper timing with RAF and microtasks
### Focus Loss Prevention for Menus (2025-06-24 - Part 6)
Fixed focus loss issues when slash menu and formatting menu appear:
#### Key Fixes:
1. **Timeout Reduction**:
- Replaced 50ms setTimeout with requestAnimationFrame
- Immediate focus attempt before falling back to RAF
- Reduced delay when inserting blocks
2. **Menu Focus Prevention**:
- Added `tabindex="-1"` to prevent menus from taking focus
- Added focus event prevention on menus
- Menus now use mousedown prevention consistently
3. **Blur Event Handling**:
- Skip value updates when slash menu is visible
- Prevent auto-save during slash menu interaction
- Maintain focus after menu appears with RAF
4. **Block Focus Optimization**:
- Try immediate focus if block element exists
- Fall back to RAF only when necessary
- Consistent focus handling across all block types
#### Implementation:
- `handleBlockBlur()` checks if slash menu is visible before updating
- `scheduleAutoSave()` skips saving when slash menu is open
- Slash menu show adds RAF to restore focus if lost
- Reduced timing delays throughout the focus chain
### Slash Command Cleanup (2025-06-24 - Part 7)
Fixed the issue where "/" remained in the editor after selecting a block type:
#### The Fix:
1. **In `insertBlock()`**:
- Clear slash command before transforming block type
- Use regex `/^\/[^\s]*\s*/` to match slash + filter text
- Trim the result to ensure clean content
- Set content to empty for transformed blocks
2. **Improved Content Handling**:
- Wait for `updateComplete` before focusing
- Ensure lists start with empty content
- Consistent cleanup in both `insertBlock` and `closeSlashMenu`
3. **Edge Cases**:
- Handle filtered commands (e.g., "/hea" for heading)
- Clear content even with partial matches
- Proper content reset for all block types
Now when selecting a block type from the slash menu, the "/" and any filter text is properly removed before the block transformation occurs.
### Enhanced Enter Key and Block Settings (2025-06-24 - Part 8)
Added two major improvements to the wysiwyg editor:
#### 1. Smart Enter Key Behavior:
When pressing Enter, content after the cursor is now moved to the next block:
- **Content Splitting**: Uses Range API to extract content after cursor
- **HTML Preservation**: Maintains formatting when splitting blocks
- **Clean Split**: Current block keeps content before cursor, new block gets content after
- **Empty Block**: If cursor is at end, creates empty new block
Implementation in `WysiwygKeyboardHandler.handleEnter()`:
```typescript
// Clone the range to extract content after cursor
const afterRange = range.cloneRange();
afterRange.selectNodeContents(target);
afterRange.setStart(range.endContainer, range.endOffset);
// Extract content after cursor
const afterContent = afterRange.extractContents();
```
#### 2. Block Type Changing via Settings Menu:
The block settings menu (three dots) now includes block type selection:
- **Type Selector Grid**: Shows all available block types with icons
- **Smart Metadata Handling**:
- Clears code language when changing from code block
- Clears list type when changing from list
- Prompts for language when changing to code block
- **Visual Feedback**: Currently selected type is highlighted
- **Instant Update**: Block transforms immediately on selection
Features:
- Works for all block types (not just code blocks)
- Preserves content during type transformation
- Handles special cases like code block language selection
- Modal closes automatically after selection
### Complete WYSIWYG Refactoring (2025-06-24 - Part 9)
Major architectural improvements to fix Enter key behavior and left arrow focus loss:
#### 1. Async Operation Architecture:
- All focus operations are now async with proper Promise handling
- `insertBlockAfter()` waits for component updates before focusing
- `focusBlock()` ensures DOM is ready with `updateComplete`
- Eliminated arbitrary timeouts in favor of proper async/await
#### 2. Enter Key Split Content Fix:
- Added `getSplitContent()` method to block component
- Properly extracts content before/after cursor using Range API
- Updates current block and creates new block atomically
- Content after cursor correctly moves to new block
```typescript
// In block component
public getSplitContent(): { before: string; after: string } | null {
const beforeRange = range.cloneRange();
beforeRange.selectNodeContents(this.blockElement);
beforeRange.setEnd(range.startContainer, range.startOffset);
// ... extract and return split content
}
```
#### 3. Arrow Key Navigation:
- Added ArrowLeft/ArrowRight handlers for block boundaries
- Prevents focus loss when navigating between blocks
- Only intercepts at block boundaries, normal navigation otherwise
- All arrow key operations are async for proper timing
#### 4. Interface Architecture:
Created `wysiwyg.interfaces.ts` with proper typing:
- `IWysiwygComponent` - Main component contract
- `IBlockOperations` - Block operation methods
- `IWysiwygBlockComponent` - Block component interface
- `IBlockEventHandlers` - Event handler signatures
#### 5. Focus Management Improvements:
- Eliminated double RAF in favor of single async flow
- Focus operations wait for DOM updates via `updateComplete`
- Proper cursor positioning after all operations
- No more focus loss during navigation
#### Key Changes:
1. Keyboard handler methods are now async
2. Block operations return Promises
3. Enter key properly splits content at cursor
4. Arrow keys handle block navigation without focus loss
5. All timing is handled via proper async/await patterns
The refactoring eliminates race conditions and timing issues that were causing focus loss and content duplication problems.
### Programmatic Rendering Solution (2025-06-24 - Part 10)
Fixed persistent focus loss issue by implementing fully programmatic rendering:
#### The Problem:
- User would click in a block, type text, then press arrow keys and lose focus
- Root cause: Lit was re-rendering components when block content was mutated
- Even with shouldUpdate() preventing re-renders, parent re-evaluation caused focus loss
#### The Solution:
1. **Static Parent Rendering**:
- Parent component renders only once with empty editor content div
- All blocks are created and managed programmatically via DOM manipulation
- No Lit re-renders triggered by state changes
2. **Manual Block Management**:
- `renderBlocksProgrammatically()` creates all block elements manually
- `createBlockElement()` builds block wrapper with all event handlers
- `updateBlockElement()` replaces individual blocks when needed
- No reactive properties trigger parent re-renders
3. **Content Update Strategy**:
- During typing, content is NOT immediately synced to data model
- Auto-save delayed to 2 seconds to avoid interference
- Content synced from DOM only on blur or before save
- `syncAllBlockContent()` reads from DOM when needed
4. **Focus Preservation**:
- Block components prevent re-renders with `shouldUpdate()`
- Parent never re-renders after initial load
- Focus remains stable during all editing operations
- Arrow key navigation works without focus loss
5. **Implementation Details**:
```typescript
// Parent render method - static after first render
render(): TemplateResult {
return html`
<div class="editor-content" id="editor-content">
<!-- Blocks rendered programmatically -->
</div>
`;
}
// All block operations use DOM manipulation
private renderBlocksProgrammatically() {
this.editorContentRef.innerHTML = '';
this.blocks.forEach(block => {
const blockWrapper = this.createBlockElement(block);
this.editorContentRef.appendChild(blockWrapper);
});
}
```
This approach completely eliminates focus loss by taking full control of the DOM and preventing any framework-induced re-renders during editing.
### Code Refactoring and Cleanup (2025-06-24 - Part 11)
Completed comprehensive refactoring to ensure clean, maintainable code with separated concerns:
#### Refactoring Changes:
1. **Drag and Drop Handler Cleanup**:
- Removed all `requestUpdate()` calls from drag handler
- Handler now only updates internal state
- Parent component handles DOM updates programmatically
- Simplified drag state management
2. **Unused Code Removal**:
- Removed duplicate `showBlockSettingsModal` method (using WysiwygModalManager)
- Removed duplicate `showLanguageSelectionModal` method
- Removed unused `renderBlock` method
- Cleaned up unused imports (WysiwygBlocks, ISlashMenuItem)
3. **Import Cleanup**:
- Removed unused type imports
- Organized imports logically
- Kept only necessary dependencies
4. **Separated Concerns**:
- Modal management in WysiwygModalManager
- Block operations in WysiwygBlockOperations
- Input handling in WysiwygInputHandler
- Keyboard handling in WysiwygKeyboardHandler
- Drag/drop in WysiwygDragDropHandler
- Each class has a single responsibility
5. **Programmatic DOM Management**:
- All DOM updates happen through explicit methods
- No reactive re-renders during user interaction
- Manual class management for drag states
- Direct DOM manipulation for performance
6. **Test Files Created**:
- `test-focus-fix.html` - Verifies focus management
- `test-drag-drop.html` - Tests drag and drop functionality
- `test-comprehensive.html` - Tests all features together
The refactoring follows the principles in instructions.md:
- Uses static templates with manual DOM operations
- Maintains separated concerns in different classes
- Results in clean, concise, and manageable code

289
readme.md
View File

@ -104,7 +104,7 @@ Loading indicator with customizable appearance.
```
#### `DeesToast`
Notification toast messages with various styles and auto-dismiss.
Notification toast messages with various styles, positions, and auto-dismiss functionality.
```typescript
// Programmatic usage
@ -112,18 +112,43 @@ DeesToast.show({
message: 'Operation successful',
type: 'success', // Options: info, success, warning, error
duration: 3000, // Time in milliseconds before auto-dismiss
position: 'top-right' // Options: top-right, top-left, bottom-right, bottom-left
position: 'top-right' // Options: top-right, top-left, bottom-right, bottom-left, top-center, bottom-center
});
// Component usage
// Convenience methods
DeesToast.info('Information message');
DeesToast.success('Success message');
DeesToast.warning('Warning message');
DeesToast.error('Error message');
// Advanced control
const toast = await DeesToast.show({
message: 'Processing...',
type: 'info',
duration: 0 // No auto-dismiss
});
// Later dismiss programmatically
toast.dismiss();
// Component usage (not typically used directly)
<dees-toast
message="Changes saved"
type="success"
autoClose
duration="3000"
></dees-toast>
```
Key Features:
- Multiple toast types with distinct icons and colors
- 6 position options for flexible placement
- Auto-dismiss with visual progress indicator
- Manual dismiss by clicking
- Smooth animations and transitions
- Automatic stacking of multiple toasts
- Theme-aware styling
- Programmatic control
### Form Components
#### `DeesForm`
@ -281,17 +306,119 @@ Submit button component specifically designed for `DeesForm`.
### Layout Components
#### `DeesAppuiBase`
Base container component for application layout structure.
Base container component for application layout structure with integrated appbar, menu system, and content areas.
```typescript
<dees-appui-base>
<dees-appui-mainmenu></dees-appui-mainmenu>
<dees-appui-mainselector></dees-appui-mainselector>
<dees-appui-maincontent></dees-appui-maincontent>
<dees-appui-appbar></dees-appui-appbar>
<dees-appui-base
// Appbar configuration
.appbarMenuItems=${[
{
name: 'File',
action: async () => {},
submenu: [
{ name: 'New', shortcut: 'Cmd+N', iconName: 'file-plus', action: async () => {} },
{ name: 'Open', shortcut: 'Cmd+O', iconName: 'folder-open', action: async () => {} },
{ divider: true },
{ name: 'Save', shortcut: 'Cmd+S', iconName: 'save', action: async () => {} }
]
},
{
name: 'Edit',
action: async () => {},
submenu: [
{ name: 'Undo', shortcut: 'Cmd+Z', iconName: 'undo', action: async () => {} },
{ name: 'Redo', shortcut: 'Cmd+Shift+Z', iconName: 'redo', action: async () => {} }
]
}
]}
.appbarBreadcrumbs=${'Dashboard > Overview'}
.appbarTheme=${'dark'}
.appbarUser=${{
name: 'John Doe',
status: 'online'
}}
.appbarShowSearch=${true}
.appbarShowWindowControls=${true}
// Main menu configuration (left sidebar)
.mainmenuTabs=${[
{ key: 'dashboard', iconName: 'home', action: () => {} },
{ key: 'projects', iconName: 'folder', action: () => {} },
{ key: 'settings', iconName: 'cog', action: () => {} }
]}
.mainmenuSelectedTab=${selectedTab}
// Selector configuration (second sidebar)
.mainselectorOptions=${[
{ key: 'Overview', action: () => {} },
{ key: 'Components', action: () => {} },
{ key: 'Services', action: () => {} }
]}
.mainselectorSelectedOption=${selectedOption}
// Main content tabs
.maincontentTabs=${[
{ key: 'tab1', iconName: 'file', action: () => {} }
]}
// Event handlers
@appbar-menu-select=${(e) => handleMenuSelect(e.detail)}
@appbar-breadcrumb-navigate=${(e) => handleBreadcrumbNav(e.detail)}
@appbar-search-click=${() => handleSearch()}
@appbar-user-menu-open=${() => handleUserMenu()}
@mainmenu-tab-select=${(e) => handleTabSelect(e.detail)}
@mainselector-option-select=${(e) => handleOptionSelect(e.detail)}
>
<div slot="maincontent">
<!-- Your main application content goes here -->
</div>
</dees-appui-base>
```
Key Features:
- **Integrated Layout System**: Automatically arranges appbar, sidebars, and content area
- **Centralized Configuration**: Pass properties to all child components from one place
- **Event Propagation**: All child component events are re-emitted for easy handling
- **Responsive Grid**: Uses CSS Grid for flexible, responsive layout
- **Slot Support**: Main content area supports custom content via slots
Layout Structure:
```
┌─────────────────────────────────────────────────┐
│ AppBar │
├────┬──────────────┬─────────────────┬──────────┤
│ │ │ │ │
│ M │ Selector │ Main Content │ Activity │
│ e │ │ │ Log │
│ n │ │ │ │
│ u │ │ │ │
│ │ │ │ │
└────┴──────────────┴─────────────────┴──────────┘
```
Grid Configuration:
- Main Menu: 60px width
- Selector: 240px width
- Main Content: Flexible (1fr)
- Activity Log: 240px width
Child Component Access:
```typescript
// Access child components after firstUpdated
const base = document.querySelector('dees-appui-base');
base.appbar; // DeesAppuiAppbar instance
base.mainmenu; // DeesAppuiMainmenu instance
base.mainselector; // DeesAppuiMainselector instance
base.maincontent; // DeesAppuiMaincontent instance
base.activitylog; // DeesAppuiActivitylog instance
```
Best Practices:
1. **Configuration**: Set all properties on the base component for consistency
2. **Event Handling**: Listen to events on the base component rather than child components
3. **Content**: Use the `maincontent` slot for your application's primary interface
4. **State Management**: Manage selected tabs and options at the base component level
#### `DeesAppuiMainmenu`
Main navigation menu component for application-wide navigation.
@ -353,28 +480,148 @@ Main content area with tab management support.
```
#### `DeesAppuiAppbar`
Top application bar with actions and status information.
Professional application bar component with hierarchical menus, breadcrumb navigation, and user account management.
```typescript
<dees-appui-appbar
title="My Application"
.actions=${[
.menuItems=${[
{
icon: 'bell',
label: 'Notifications',
action: () => showNotifications()
name: 'File',
action: async () => {}, // No-op for parent menu items
submenu: [
{
name: 'New File',
shortcut: 'Cmd+N',
iconName: 'file-plus',
action: async () => handleNewFile()
},
{
icon: 'user',
label: 'Profile',
action: () => showProfile()
name: 'Open...',
shortcut: 'Cmd+O',
iconName: 'folder-open',
action: async () => handleOpen()
},
{ divider: true }, // Menu separator
{
name: 'Save',
shortcut: 'Cmd+S',
iconName: 'save',
action: async () => handleSave(),
disabled: true // Disabled state
}
]
},
{
name: 'Edit',
action: async () => {},
submenu: [
{ name: 'Undo', shortcut: 'Cmd+Z', iconName: 'undo', action: async () => handleUndo() },
{ name: 'Redo', shortcut: 'Cmd+Shift+Z', iconName: 'redo', action: async () => handleRedo() }
]
}
]}
showSearch // Optional: display search bar
@search=${handleSearch}
.breadcrumbs=${'Project > src > components > AppBar.ts'}
.breadcrumbSeparator=${' > '}
.showWindowControls=${true}
.showSearch=${true}
.theme=${'dark'} // Options: 'light' | 'dark'
.user=${{
name: 'John Doe',
avatar: '/path/to/avatar.jpg', // Optional
status: 'online' // Options: 'online' | 'offline' | 'busy' | 'away'
}}
@menu-select=${(e) => handleMenuSelect(e.detail.item)}
@breadcrumb-navigate=${(e) => handleBreadcrumbClick(e.detail)}
@search-click=${() => handleSearchClick()}
@user-menu-open=${() => handleUserMenuOpen()}
></dees-appui-appbar>
```
Key Features:
- **Hierarchical Menu System**
- Top-level text-only menus (following desktop UI standards)
- Dropdown submenus with icons and keyboard shortcuts
- Support for nested submenus
- Menu dividers for visual grouping
- Disabled state support
- **Keyboard Navigation**
- Tab navigation between top-level items
- Arrow keys for dropdown navigation (Up/Down in dropdowns, Left/Right between top items)
- Enter to select items
- Escape to close dropdowns
- Home/End keys for first/last item
- **Breadcrumb Navigation**
- Customizable breadcrumb trail
- Configurable separator
- Click events for navigation
- **User Account Section**
- User avatar with fallback to initials
- Status indicator (online, offline, busy, away)
- Click handler for user menu
- **Visual Features**
- Light and dark theme support
- Smooth animations and transitions
- Window controls integration
- Search icon with click handler
- Responsive layout using CSS Grid
- **Accessibility**
- Full ARIA support (menubar, menuitem roles)
- Keyboard navigation
- Focus management
- Screen reader compatible
Menu Item Interface:
```typescript
// Regular menu item
interface IAppBarMenuItemRegular {
name: string; // Display text
action: () => Promise<any>; // Click handler
iconName?: string; // Optional icon (for dropdown items)
shortcut?: string; // Keyboard shortcut display
submenu?: IAppBarMenuItem[]; // Nested menu items
disabled?: boolean; // Disabled state
checked?: boolean; // For checkbox menu items
radioGroup?: string; // For radio button menu items
}
// Divider item
interface IAppBarMenuDivider {
divider: true;
}
// Combined type
type IAppBarMenuItem = IAppBarMenuItemRegular | IAppBarMenuDivider;
```
Best Practices:
1. **Menu Structure**
- Keep top-level menus text-only (no icons)
- Use icons in dropdown items for visual clarity
- Group related actions with dividers
- Provide keyboard shortcuts for common actions
2. **Navigation**
- Use breadcrumbs for deep navigation hierarchies
- Keep breadcrumb labels concise
- Provide meaningful navigation events
3. **User Experience**
- Show user status when relevant
- Provide clear visual feedback
- Ensure smooth transitions
- Handle edge cases (long menus, small screens)
4. **Accessibility**
- Always provide text labels
- Ensure keyboard navigation works
- Test with screen readers
- Maintain focus management
#### `DeesMobileNavigation`
Responsive navigation component for mobile devices.

BIN
readme.plan.md Normal file

Binary file not shown.

View File

@ -0,0 +1,138 @@
# WYSIWYG Editor Refactoring Progress Summary
## Latest Updates
### Selection Highlighting Fix ✅
- **Issue**: "Paragraphs are not highlighted consistently, headings are always highlighted"
- **Root Cause**: The `shouldUpdate` method in `dees-wysiwyg-block.ts` was using a generic `.block` selector that would match the first element with that class, not necessarily the correct block element
- **Solution**: Changed the selector to be more specific: `.block.${blockType}` which ensures the correct element is found for each block type
- **Result**: All block types now highlight consistently when selected
### Enter Key Block Creation Fix ✅
- **Issue**: "When pressing enter and jumping to new block then typing something: The cursor is not at the beginning of the new block and there is content"
- **Root Cause**: Block handlers were rendering content with template syntax `${block.content || ''}` in their render methods, which violates the static HTML principle
- **Solution**:
- Removed all `${block.content}` from render methods in paragraph, heading, quote, and code block handlers
- Content is now set programmatically in the setup() method only when needed
- Fixed `setCursorToStart` and `setCursorToEnd` to always find elements fresh instead of relying on cached `blockElement`
- **Result**: New empty blocks remain truly empty, cursor positioning works correctly
### Backspace Key Deletion Fix ✅
- **Issue**: "After typing in a new block, pressing backspace deletes the whole block instead of just the last character"
- **Root Cause**:
1. `getCursorPositionInElement` was using `element.contains()` which doesn't work across Shadow DOM boundaries
2. The backspace handler was checking `block.content === ''` which only contains the stored content, not the actual DOM content
- **Solution**:
1. Fixed `getCursorPositionInElement` to use `containsAcrossShadowDOM` for proper Shadow DOM support
2. Updated backspace handler to get actual content from DOM using `blockComponent.getContent()` instead of relying on stored `block.content`
3. Added debug logging to track cursor position and content state
- **Result**: Backspace now correctly deletes individual characters instead of the whole block
### Arrow Left Navigation Fix ✅
- **Issue**: "When jumping to the previous block from the beginning of a block with arrow left, the cursor should be at the end of the previous block, not at the start"
- **Root Cause**: Browser's default focus behavior places cursor at the beginning of contenteditable elements, overriding our cursor positioning
- **Solution**: For 'end' position, set up the selection range BEFORE focusing the element:
1. Create a range pointing to the end of content
2. Apply the selection
3. Then focus the element (which preserves the existing selection)
4. Only use setCursorToEnd for empty blocks
- **Result**: Arrow left navigation now correctly places cursor at the end of the previous block
## Completed Phases
### Phase 1: Infrastructure ✅
- Created modular block handler architecture
- Implemented `IBlockHandler` interface and `BaseBlockHandler` class
- Created `BlockRegistry` for dynamic block type registration
- Set up proper file structure under `blocks/` directory
### Phase 2: Proof of Concept ✅
- Successfully migrated divider block as the simplest example
- Validated the architecture works correctly
- Established patterns for block migration
### Phase 3: Text Blocks ✅
- **Paragraph Block**: Full editing support with text splitting, selection handling, and cursor tracking
- **Heading Blocks**: All three heading levels (h1, h2, h3) with unified handler
- **Quote Block**: Italic styling with border, full editing capabilities
- **Code Block**: Monospace font, tab handling, plain text paste support
- **List Block**: Bullet/numbered lists with proper list item management
## Key Achievements
### 1. Preserved Critical Knowledge
- **Static Rendering**: Blocks use `innerHTML` in `firstUpdated` to prevent focus loss during typing
- **Shadow DOM Selection**: Implemented `containsAcrossShadowDOM` utility for proper selection detection
- **Cursor Position Tracking**: All editable blocks track cursor position across multiple events
- **Content Splitting**: HTML-aware splitting using Range API preserves formatting
- **Focus Management**: Microtask-based focus restoration ensures reliable cursor placement
### 2. Enhanced Architecture
- Each block type is now self-contained in its own file
- Block handlers are dynamically registered and loaded
- Common functionality is shared through base classes
- Styles are co-located with their block handlers
### 3. Maintained Functionality
- All keyboard navigation works (arrows, backspace, delete, enter)
- Text selection across Shadow DOM boundaries functions correctly
- Block merging and splitting behave as before
- IME (Input Method Editor) support is preserved
- Formatting shortcuts (Cmd/Ctrl+B/I/U/K) continue to work
## Code Organization
```
ts_web/elements/wysiwyg/
├── dees-wysiwyg-block.ts (simplified main component)
├── wysiwyg.selection.ts (Shadow DOM selection utilities)
├── wysiwyg.blockregistration.ts (handler registration)
└── blocks/
├── index.ts (exports and registry)
├── block.base.ts (base handler interface)
├── decorative/
│ └── divider.block.ts
└── text/
├── paragraph.block.ts
├── heading.block.ts
├── quote.block.ts
├── code.block.ts
└── list.block.ts
```
## Next Steps
### Phase 4: Media Blocks (In Progress)
- Image block with upload/drag-drop support
- YouTube block with video embedding
- Attachment block for file uploads
### Phase 5: Content Blocks
- Markdown block with preview toggle
- HTML block with raw HTML editing
### Phase 6: Cleanup
- Remove old code from main component
- Optimize bundle size
- Update documentation
## Technical Improvements
1. **Modularity**: Each block type is now completely self-contained
2. **Extensibility**: New blocks can be added by creating a handler and registering it
3. **Maintainability**: Files are smaller and focused on single responsibilities
4. **Type Safety**: Strong TypeScript interfaces ensure consistent implementation
5. **Performance**: No degradation in performance; potential for lazy loading in future
## Migration Pattern
For future block migrations, follow this pattern:
1. Create block handler extending `BaseBlockHandler`
2. Implement required methods: `render()`, `setup()`, `getStyles()`
3. Add helper methods for cursor/content management
4. Handle Shadow DOM selection properly using utilities
5. Register handler in `wysiwyg.blockregistration.ts`
6. Test all interactions (typing, selection, navigation)
The refactoring has been successful in making the codebase more maintainable while preserving all the hard-won functionality and edge case handling from the original implementation.

82
readme.refactoring.md Normal file
View File

@ -0,0 +1,82 @@
# WYSIWYG Editor Refactoring
## Summary of Changes
This refactoring cleaned up the wysiwyg editor implementation to fix focus, cursor position, and selection issues.
### Phase 1: Code Organization
#### 1. Removed Duplicate Code
- Removed duplicate `handleBlockInput` method from main component (was already in inputHandler)
- Removed duplicate `handleBlockKeyDown` method from main component (was already in keyboardHandler)
- Consolidated all input handling in the respective handler classes
#### 2. Simplified Focus Management
- Removed complex `updated` lifecycle method that was trying to maintain focus
- Simplified `handleBlockBlur` to not immediately close menus
- Added `requestAnimationFrame` to focus operations for better timing
- Removed `slashMenuOpenTime` tracking which was no longer needed
#### 3. Fixed Slash Menu Behavior
- Changed from `@mousedown` to `@click` events for better UX
- Added proper event prevention to avoid focus loss
- Menu now closes when clicking outside
- Simplified the insertBlock method to close menu first
### Phase 2: Cursor & Selection Fixes
#### 4. Enhanced Cursor Position Management
- Added `focusWithCursor()` method to block component for precise cursor positioning
- Improved `handleSlashCommand` to preserve cursor position when menu opens
- Added `getCaretCoordinates()` for accurate menu positioning based on cursor location
- Updated `focusBlock()` to support numeric cursor positions
#### 5. Fixed Selection Across Shadow DOM
- Added custom `block-text-selected` event to communicate selections across shadow boundaries
- Implemented `handleMouseUp()` in block component to detect selections
- Updated main component to listen for selection events from blocks
- Selection now works properly even with nested shadow DOMs
#### 6. Improved Slash Menu Close Behavior
- Added optional `clearSlash` parameter to `closeSlashMenu()`
- Escape key now properly clears the slash command
- Clicking outside clears the slash if menu is open
- Selecting an item preserves content and just transforms the block
### Technical Improvements
#### Block Component (`dees-wysiwyg-block`)
- Better focus management with immediate focus (removed unnecessary requestAnimationFrame)
- Added cursor position control methods
- Custom event dispatching for cross-shadow-DOM communication
- Improved content handling for different block types
#### Input Handler
- Preserves cursor position when showing slash menu
- Better caret coordinate calculation for menu positioning
- Ensures focus stays in the block when menu appears
#### Block Operations
- Enhanced `focusBlock()` to support start/end/numeric positions
- Better timing with requestAnimationFrame for focus operations
### Key Benefits
- Slash menu no longer causes focus or cursor position loss
- Text selection works properly across shadow DOM boundaries
- Cursor position is preserved when interacting with menus
- Cleaner, more maintainable code structure
- Better separation of concerns
## Testing
Use the test files in `.nogit/debug/`:
- `test-slash-menu.html` - Tests slash menu focus behavior
- `test-wysiwyg-formatting.html` - Tests text formatting
## Known Issues Fixed
- Slash menu disappearing immediately on first "/"
- Focus lost when slash menu opens
- Cursor position lost when typing "/"
- Text selection not working properly
- Selection events not propagating across shadow DOM
- Duplicate event handling causing conflicts

View File

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

View File

@ -0,0 +1,175 @@
import { expect, tap, webhelpers } from '@push.rocks/tapbundle';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
import { WysiwygSelection } from '../ts_web/elements/wysiwyg/wysiwyg.selection.js';
tap.test('Shadow DOM containment should work correctly', async () => {
console.log('=== Testing Shadow DOM Containment ===');
// Create a WYSIWYG block component
const block = await webhelpers.fixture<DeesWysiwygBlock>(
'<dees-wysiwyg-block></dees-wysiwyg-block>'
);
// Set the block data
block.block = {
id: 'test-1',
type: 'paragraph',
content: 'Hello world test content'
};
block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
await block.updateComplete;
// 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');
}
}
});
tap.test('Shadow DOM containment across different shadow roots', async () => {
console.log('=== Testing Cross Shadow Root Containment ===');
// Create parent component with WYSIWYG editor
const parentDiv = document.createElement('div');
parentDiv.innerHTML = `
<dees-input-wysiwyg>
<dees-wysiwyg-block></dees-wysiwyg-block>
</dees-input-wysiwyg>
`;
document.body.appendChild(parentDiv);
// Wait for components to be ready
await new Promise(resolve => setTimeout(resolve, 100));
const wysiwygInput = parentDiv.querySelector('dees-input-wysiwyg') as any;
const blockElement = wysiwygInput?.shadowRoot?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
if (blockElement) {
// Set block data
blockElement.block = {
id: 'test-2',
type: 'paragraph',
content: 'Cross shadow DOM test'
};
blockElement.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
await blockElement.updateComplete;
// Get the paragraph inside the nested shadow DOM
const container = blockElement.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
if (paragraphBlock) {
console.log('Found nested paragraph block');
// Focus and set selection
paragraphBlock.focus();
const textNode = paragraphBlock.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
range.setStart(textNode, 5);
range.setEnd(textNode, 5);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
// Test containment from parent's perspective
const selRange = selection?.getRangeAt(0);
if (selRange) {
// This should fail because it crosses shadow DOM boundary
const regularContains = wysiwygInput.contains(selRange.startContainer);
console.log('Parent regular contains:', regularContains);
expect(regularContains).toBeFalse();
// This should work with our Shadow DOM-aware method
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(wysiwygInput, selRange.startContainer);
console.log('Parent shadow DOM contains:', shadowDOMContains);
expect(shadowDOMContains).toBeTrue();
}
}
}
}
// Clean up
document.body.removeChild(parentDiv);
});
export default tap.start();

View File

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

View File

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

View File

@ -0,0 +1,205 @@
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
import * as deesCatalog from '../ts_web/index.js';
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
// Import block registration to ensure handlers are registered
import '../ts_web/elements/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 () => {
const dividerBlock: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
dividerBlock.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
// Set a divider block
dividerBlock.block = {
id: 'test-divider',
type: 'divider',
content: ' '
};
await dividerBlock.updateComplete;
// Check that the divider is rendered
const dividerElement = dividerBlock.shadowRoot?.querySelector('.block.divider');
expect(dividerElement).toBeDefined();
expect(dividerElement?.getAttribute('tabindex')).toEqual('0');
// Check for the divider icon
const icon = dividerBlock.shadowRoot?.querySelector('.divider-icon');
expect(icon).toBeDefined();
});
tap.test('should render paragraph block using handler', async () => {
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// 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'
};
await paragraphBlock.updateComplete;
// Check that the paragraph is rendered
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
expect(paragraphElement).toBeDefined();
expect(paragraphElement?.getAttribute('contenteditable')).toEqual('true');
expect(paragraphElement?.textContent).toEqual('Test paragraph content');
});
tap.test('should render heading blocks using handler', async () => {
// Test heading-1
const heading1Block: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
heading1Block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
heading1Block.block = {
id: 'test-h1',
type: 'heading-1',
content: 'Heading 1 Test'
};
await heading1Block.updateComplete;
const h1Element = heading1Block.shadowRoot?.querySelector('.block.heading-1');
expect(h1Element).toBeDefined();
expect(h1Element?.textContent).toEqual('Heading 1 Test');
// Test heading-2
const heading2Block: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
heading2Block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
heading2Block.block = {
id: 'test-h2',
type: 'heading-2',
content: 'Heading 2 Test'
};
await heading2Block.updateComplete;
const h2Element = heading2Block.shadowRoot?.querySelector('.block.heading-2');
expect(h2Element).toBeDefined();
expect(h2Element?.textContent).toEqual('Heading 2 Test');
});
tap.test('paragraph block handler methods should work', async () => {
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
paragraphBlock.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
paragraphBlock.block = {
id: 'test-methods',
type: 'paragraph',
content: 'Initial content'
};
await paragraphBlock.updateComplete;
// 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');
});
export default tap.start();

View File

@ -0,0 +1,341 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/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
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('.block.code') as HTMLElement;
// 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));
// 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;
secondParagraph.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Press ArrowUp to move to first block
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));
// Check if first block is focused
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-1"]');
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const firstParagraph = firstBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
expect(firstBlockComponent.shadowRoot?.activeElement).toEqual(firstParagraph);
// Now press ArrowDown twice to get to third block
const arrowDownEvent = new KeyboardEvent('keydown', {
key: 'ArrowDown',
code: 'ArrowDown',
bubbles: true,
cancelable: true,
composed: true
});
firstParagraph.dispatchEvent(arrowDownEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Second block should be focused, dispatch again
const secondActiveElement = secondBlockComponent.shadowRoot?.activeElement;
if (secondActiveElement) {
secondActiveElement.dispatchEvent(arrowDownEvent);
await new Promise(resolve => setTimeout(resolve, 200));
}
// Check if third block is focused
const thirdBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-3"]');
const thirdBlockComponent = thirdBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const thirdParagraph = thirdBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
expect(thirdBlockComponent.shadowRoot?.activeElement).toEqual(thirdParagraph);
console.log('ArrowUp/Down navigation test complete');
});
tap.test('Keyboard: Formatting shortcuts', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a paragraph
editor.importBlocks([
{ id: 'format-1', type: 'paragraph', content: 'Test formatting' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus and select text
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="format-1"]');
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const blockContainer = blockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paragraph = blockContainer?.querySelector('.block.paragraph') as HTMLElement;
paragraph.focus();
// Select "formatting"
const textNode = paragraph.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 5); // After "Test "
range.setEnd(textNode, 15); // After "formatting"
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press Cmd/Ctrl+B for bold
const boldEvent = new KeyboardEvent('keydown', {
key: 'b',
code: 'KeyB',
metaKey: true, // Use metaKey for Mac, ctrlKey for Windows/Linux
bubbles: true,
cancelable: true,
composed: true
});
paragraph.dispatchEvent(boldEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if bold was applied
const content = paragraph.innerHTML;
expect(content).toContain('<strong>') || expect(content).toContain('<b>');
console.log('Formatting shortcuts test complete');
});
export default tap.start();

View File

@ -0,0 +1,150 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/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
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('.block.code') as HTMLElement;
expect(codeElement).toBeTruthy();
expect(codeElement?.textContent).toEqual('const x = 42;');
// Check if language label is shown
const languageLabel = codeContainer?.querySelector('.code-language');
expect(languageLabel?.textContent).toEqual('javascript');
// Check if monospace font is applied
const computedStyle = window.getComputedStyle(codeElement);
expect(computedStyle.fontFamily).toContain('monospace');
});
tap.test('Phase 3: List block should render correctly', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a list block
editor.importBlocks([
{ id: 'list-1', type: 'list', content: 'First item\nSecond item\nThird item' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check if list block was rendered
const listBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="list-1"]');
const listBlockComponent = listBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const listContainer = listBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const listElement = listContainer?.querySelector('.block.list') as HTMLElement;
expect(listElement).toBeTruthy();
// Check if list items were created
const listItems = listElement?.querySelectorAll('li');
expect(listItems?.length).toEqual(3);
expect(listItems?.[0].textContent).toEqual('First item');
expect(listItems?.[1].textContent).toEqual('Second item');
expect(listItems?.[2].textContent).toEqual('Third item');
// Check if it's an unordered list by default
const ulElement = listElement?.querySelector('ul');
expect(ulElement).toBeTruthy();
});
tap.test('Phase 3: Quote block split should work', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a quote block
editor.importBlocks([
{ id: 'quote-split', type: 'quote', content: 'To be or not to be' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get the quote block
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-split"]');
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
// Focus and set cursor after "To be"
quoteElement.focus();
const textNode = quoteElement.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 5); // After "To be"
range.setEnd(textNode, 5);
selection?.removeAllRanges();
selection?.addRange(range);
await new Promise(resolve => setTimeout(resolve, 100));
// Press Enter to split
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
cancelable: true,
composed: true
});
quoteElement.dispatchEvent(enterEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if split happened correctly
expect(editor.blocks.length).toEqual(2);
expect(editor.blocks[0].content).toEqual('To be');
expect(editor.blocks[1].content).toEqual(' or not to be');
expect(editor.blocks[1].type).toEqual('paragraph'); // New block should be paragraph
}
});
export default tap.start();

View File

@ -0,0 +1,105 @@
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
import { DividerBlockHandler } from '../ts_web/elements/wysiwyg/blocks/content/divider.block.js';
import { ParagraphBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/paragraph.block.js';
import { HeadingBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/heading.block.js';
// Import block registration to ensure handlers are registered
import '../ts_web/elements/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);
expect(rendered).toContain('contenteditable="true"');
expect(rendered).toContain('data-block-type="paragraph"');
expect(rendered).toContain('Test paragraph content');
}
});
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('divider-icon');
}
});
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);
expect(rendered).toContain('class="block heading-1"');
expect(rendered).toContain('contenteditable="true"');
expect(rendered).toContain('Test Heading');
}
});
tap.test('getAllTypes should return all registered types', async () => {
const allTypes = BlockRegistry.getAllTypes();
expect(allTypes).toContain('divider');
expect(allTypes).toContain('paragraph');
expect(allTypes).toContain('heading-1');
expect(allTypes).toContain('heading-2');
expect(allTypes).toContain('heading-3');
expect(allTypes.length).toBeGreaterThanOrEqual(5);
});
export default tap.start();

View File

@ -0,0 +1,156 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/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
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('.block.code') as HTMLElement;
// Focus code to select it
codeElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if code has selected class
const codeHasSelected = codeElement.classList.contains('selected');
console.log('Code 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();
expect(codeElement.classList.contains('selected')).toBeFalse();
console.log('Selection highlighting test complete');
});
tap.test('Selected class should toggle correctly when clicking between blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import two blocks
editor.importBlocks([
{ id: 'block-1', type: 'paragraph', content: 'First block' },
{ id: 'block-2', type: 'paragraph', content: 'Second block' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get both blocks
const block1Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
const block1Component = block1Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const block1Container = block1Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const block1Element = block1Container?.querySelector('.block.paragraph') as HTMLElement;
const block2Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
const block2Component = block2Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const block2Container = block2Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const block2Element = block2Container?.querySelector('.block.paragraph') as HTMLElement;
// Initially neither should be selected
expect(block1Element.classList.contains('selected')).toBeFalse();
expect(block2Element.classList.contains('selected')).toBeFalse();
// Click on first block
block1Element.click();
block1Element.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// First block should be selected
expect(block1Element.classList.contains('selected')).toBeTrue();
expect(block2Element.classList.contains('selected')).toBeFalse();
// Click on second block
block2Element.click();
block2Element.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Second block should be selected, first should not
expect(block1Element.classList.contains('selected')).toBeFalse();
expect(block2Element.classList.contains('selected')).toBeTrue();
console.log('Toggle test complete');
});
export default tap.start();

View File

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

View File

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

View File

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

View File

@ -22,15 +22,15 @@ export class DeesAppuiActivitylog extends DeesElement {
cssManager.defaultStyles,
css`
:host {
color: #fff;
color: ${cssManager.bdTheme('#333', '#fff')};
position: relative;
display: block;
width: 100%;
max-width: 300px;
height: 100%;
background: #111c28;
background: ${cssManager.bdTheme('#f8f8f8', '#111c28')};
font-family: 'Intel One Mono', sans-serif;
border-left: 1px solid #202020;
border-left: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
cursor: default;
}
.maincontainer {
@ -47,7 +47,8 @@ export class DeesAppuiActivitylog extends DeesElement {
height: 32px;
width: 100%;
padding: 0px 12px 0px 12px;
background: #0e151f;
background: ${cssManager.bdTheme('#ffffff', '#0e151f')};
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.topbar .heading {
@ -57,6 +58,7 @@ export class DeesAppuiActivitylog extends DeesElement {
font-weight: 500;
font-size: 14px;
font-family: 'Geist Sans', sans-serif;
color: ${cssManager.bdTheme('#666', '#ccc')};
}
.activityContainer {
@ -73,7 +75,7 @@ export class DeesAppuiActivitylog extends DeesElement {
font-size: 12px;
text-align: center;
padding-top: 16px;
color: #888
color: ${cssManager.bdTheme('#666', '#888')}
}
.streamingIndicator.bottom {
@ -85,19 +87,19 @@ export class DeesAppuiActivitylog extends DeesElement {
min-height: 30px;
font-size: 12px;
padding: 8px 16px;
border-bottom: 1px dotted #ffffff20;
border-bottom: 1px dotted ${cssManager.bdTheme('#00000020', '#ffffff20')};
}
.activityentry:last-of-type {
border-bottom: 1px solid #ffffff00;
border-bottom: 1px solid transparent;
}
.activityentry:hover {
background: #00000080;
background: ${cssManager.bdTheme('#00000005', '#00000080')};
}
.timestamp {
color: #ff8787;
color: ${cssManager.bdTheme('#e57373', '#ff8787')};
}
.searchbox {
@ -105,10 +107,11 @@ export class DeesAppuiActivitylog extends DeesElement {
bottom: 0px;
width: 100%;
height: 40px;
background: #0e151f;
background: ${cssManager.bdTheme('#ffffff', '#0e151f')};
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.searchbox input {
color: #fff;
color: ${cssManager.bdTheme('#333', '#fff')};
background: none;
width: 100%;
height: 40px;
@ -127,7 +130,10 @@ export class DeesAppuiActivitylog extends DeesElement {
width: 100%;
height: 32px;
bottom: 40px;
background: linear-gradient(180deg, #111c2800 0%, #0e151f 100%);
background: ${cssManager.bdTheme(
'linear-gradient(180deg, #f8f8f800 0%, #ffffff 100%)',
'linear-gradient(180deg, #111c2800 0%, #0e151f 100%)'
)};
pointer-events: none;
}
.topShadow {
@ -135,7 +141,10 @@ export class DeesAppuiActivitylog extends DeesElement {
width: 100%;
height: 32px;
top: 32px;
background: linear-gradient(0deg, #111c2800 0%, #0e151f 100%);
background: ${cssManager.bdTheme(
'linear-gradient(0deg, #f8f8f800 0%, #ffffff 100%)',
'linear-gradient(0deg, #111c2800 0%, #0e151f 100%)'
)};
pointer-events: none;
}
`,

View File

@ -0,0 +1,211 @@
import { html, css } from '@design.estate/dees-element';
import type { DeesAppuiBar } from './dees-appui-appbar.js';
import type { IAppBarMenuItem } from './interfaces/appbarmenuitem.js';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => {
// Sample menu items with various configurations
// Note: Following standard desktop UI patterns, top-level menu items don't have icons
// Icons are only used in dropdown menu items for better visual hierarchy
const menuItems: IAppBarMenuItem[] = [
{
name: 'File',
action: async () => {}, // No-op action for menu with submenu
submenu: [
{ name: 'New File', shortcut: 'Cmd+N', iconName: 'file-plus', action: async () => console.log('New file') },
{ name: 'Open...', shortcut: 'Cmd+O', iconName: 'folder-open', action: async () => console.log('Open') },
{ name: 'Open Recent', action: async () => {}, submenu: [
{ name: 'project-alpha.ts', action: async () => console.log('Open recent 1') },
{ name: 'config.json', action: async () => console.log('Open recent 2') },
{ name: 'readme.md', action: async () => console.log('Open recent 3') },
]},
{ divider: true },
{ name: 'Save', shortcut: 'Cmd+S', iconName: 'save', action: async () => console.log('Save') },
{ name: 'Save As...', shortcut: 'Cmd+Shift+S', action: async () => console.log('Save as'), disabled: true },
{ divider: true },
{ name: 'Exit', shortcut: 'Cmd+Q', action: async () => console.log('Exit') },
]
},
{
name: 'Edit',
action: async () => {}, // No-op action for menu with submenu
submenu: [
{ name: 'Undo', shortcut: 'Cmd+Z', iconName: 'undo', action: async () => console.log('Undo') },
{ name: 'Redo', shortcut: 'Cmd+Shift+Z', iconName: 'redo', action: async () => console.log('Redo') },
{ divider: true },
{ name: 'Cut', shortcut: 'Cmd+X', iconName: 'scissors', action: async () => console.log('Cut') },
{ name: 'Copy', shortcut: 'Cmd+C', iconName: 'copy', action: async () => console.log('Copy') },
{ name: 'Paste', shortcut: 'Cmd+V', iconName: 'clipboard', action: async () => console.log('Paste') },
{ divider: true },
{ name: 'Find', shortcut: 'Cmd+F', iconName: 'search', action: async () => console.log('Find') },
{ name: 'Replace', shortcut: 'Cmd+H', action: async () => console.log('Replace') },
]
},
{
name: 'View',
action: async () => {}, // No-op action for menu with submenu
submenu: [
{ name: 'Toggle Fullscreen', shortcut: 'F11', iconName: 'expand', action: async () => console.log('Fullscreen') },
{ name: 'Zoom In', shortcut: 'Cmd++', iconName: 'zoom-in', action: async () => console.log('Zoom in') },
{ name: 'Zoom Out', shortcut: 'Cmd+-', iconName: 'zoom-out', action: async () => console.log('Zoom out') },
{ name: 'Reset Zoom', shortcut: 'Cmd+0', action: async () => console.log('Reset zoom') },
{ divider: true },
{ name: 'Toggle Sidebar', shortcut: 'Cmd+B', action: async () => console.log('Toggle sidebar') },
{ name: 'Toggle Terminal', shortcut: 'Cmd+J', iconName: 'terminal', action: async () => console.log('Toggle terminal') },
]
},
{
name: 'Help',
action: async () => {}, // No-op action for menu with submenu
submenu: [
{ name: 'Documentation', iconName: 'book', action: async () => console.log('Documentation') },
{ name: 'Release Notes', iconName: 'file-text', action: async () => console.log('Release notes') },
{ divider: true },
{ name: 'Report Issue', iconName: 'bug', action: async () => console.log('Report issue') },
{ name: 'About', iconName: 'info', action: async () => console.log('About') },
]
}
];
return html`
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
const appbar = elementArg.querySelector('#appbar') as DeesAppuiBar;
// Set up status toggle
const statusButtons = elementArg.querySelectorAll('.status-toggle dees-button');
statusButtons[0].addEventListener('click', () => {
appbar.user = { ...appbar.user, status: 'online' };
});
statusButtons[1].addEventListener('click', () => {
appbar.user = { ...appbar.user, status: 'busy' };
});
statusButtons[2].addEventListener('click', () => {
appbar.user = { ...appbar.user, status: 'away' };
});
statusButtons[3].addEventListener('click', () => {
appbar.user = { ...appbar.user, status: 'offline' };
});
// Set up window controls toggle
const windowControlsButton = elementArg.querySelector('.window-controls-toggle dees-button');
windowControlsButton.addEventListener('click', () => {
appbar.showWindowControls = !appbar.showWindowControls;
});
// Set up breadcrumb buttons
const breadcrumbButtons = elementArg.querySelectorAll('.breadcrumb-toggle dees-button');
breadcrumbButtons[0].addEventListener('click', () => {
appbar.breadcrumbs = 'Home > Documents > Projects > MyApp > src > index.ts';
});
breadcrumbButtons[1].addEventListener('click', () => {
appbar.breadcrumbs = 'Dashboard';
});
}}>
<style>
${css`
.demo-container {
height: 600px;
width: 100%;
background: #1a1a1a;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
padding: 20px;
color: #ccc;
}
.controls {
padding: 20px;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.control-group label {
font-size: 12px;
color: #888;
}
`}
</style>
<div class="demo-container">
<dees-appui-appbar
id="appbar"
.menuItems=${menuItems}
.breadcrumbs=${'Project > src > components > AppBar.ts'}
.breadcrumbSeparator=${' > '}
.showWindowControls=${true}
.showSearch=${true}
.theme=${'dark'}
.user=${{
name: 'John Doe',
status: 'online' as 'online' | 'offline' | 'busy' | 'away'
}}
@menu-select=${(e: CustomEvent) => console.log('Menu selected:', e.detail.item)}
@breadcrumb-navigate=${(e: CustomEvent) => console.log('Breadcrumb clicked:', e.detail)}
@search-click=${() => console.log('Search clicked')}
@user-menu-open=${() => console.log('User menu clicked')}
></dees-appui-appbar>
<div class="content">
<h2>App Bar Demo</h2>
<p>This demo shows various features of the app bar component:</p>
<ul>
<li>Dynamic menu items with icons, shortcuts, and submenus</li>
<li>Breadcrumb navigation</li>
<li>User account section with status indicator</li>
<li>Search icon</li>
<li>Window controls (platform-specific)</li>
<li>Dark/light theme support</li>
<li>Keyboard navigation (Tab, Enter, Escape)</li>
<li>Custom events for all interactions</li>
</ul>
</div>
<div class="controls">
<div class="control-group">
<label>Theme</label>
<dees-button-group class="theme-toggle">
<dees-button>Dark</dees-button>
<dees-button>Light</dees-button>
</dees-button-group>
</div>
<div class="control-group">
<label>User Status</label>
<dees-button-group class="status-toggle">
<dees-button>Online</dees-button>
<dees-button>Busy</dees-button>
<dees-button>Away</dees-button>
<dees-button>Offline</dees-button>
</dees-button-group>
</div>
<div class="control-group">
<label>Window Controls</label>
<dees-button-group class="window-controls-toggle">
<dees-button>Toggle</dees-button>
</dees-button-group>
</div>
<div class="control-group">
<label>Breadcrumbs</label>
<dees-button-group class="breadcrumb-toggle">
<dees-button>Long Path</dees-button>
<dees-button>Short Path</dees-button>
</dees-button-group>
</div>
</div>
</div>
</dees-demowrapper>
`;
};

View File

@ -1,59 +1,310 @@
import {
DeesElement,
type TemplateResult,
property,
customElement,
property,
state,
html,
css,
cssManager,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import * as interfaces from './interfaces/index.js';
import * as plugins from './00plugins.js';
import { demoFunc } from './dees-appui-appbar.demo.js';
// Import required components
import './dees-icon.js';
import './dees-windowcontrols.js';
import './dees-appui-profiledropdown.js';
declare global {
interface HTMLElementTagNameMap {
'dees-appui-appbar': DeesAppuiBar;
}
}
@customElement('dees-appui-appbar')
export class DeesAppuiBar extends DeesElement {
public static demo = () => html`<dees-appui-appbar></dees-appui-appbar>`;
public static demo = demoFunc;
// INSTANCE PROPERTIES
@property({ type: Array })
public menuItems: interfaces.IAppBarMenuItem[] = [];
@property({ type: String })
public breadcrumbs: string = '';
@property({ type: String })
public breadcrumbSeparator: string = ' > ';
@property({ type: Boolean })
public showWindowControls: boolean = true;
@property({ type: Object })
public user?: {
name: string;
email?: string;
avatar?: string;
status?: 'online' | 'offline' | 'busy' | 'away';
};
@property({ type: Array })
public profileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
@property({ type: Boolean })
public showSearch: boolean = false;
// STATE
@state()
private activeMenu: string | null = null;
@state()
private openDropdowns: Set<string> = new Set();
@state()
private focusedItem: string | null = null;
@state()
private focusedDropdownItem: number = -1;
@state()
private isProfileDropdownOpen: boolean = false;
public static styles = [
cssManager.defaultStyles,
css`
:host {
/* CSS Variables for theming */
--appbar-height: 40px;
--appbar-font-size: 12px;
display: block;
position: relative;
height: 100%;
width: 100%;
height: 40px;
border-bottom: 1px solid #202020;
background: #000000;
color: #ffffff80;
font-size: 12px;
height: var(--appbar-height);
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
background: ${cssManager.bdTheme('#ffffff', '#000000')};
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
font-size: var(--appbar-font-size);
display: grid;
grid-template-columns: ${cssManager.cssGridColumns(3, 20)};
-webkit-app-region: drag;
user-select: none;
}
.menus {
display: flex;
padding-left: 8px;
align-items: center;
gap: 4px;
padding: 0 8px;
cursor: default;
}
.menuItem {
position: relative;
line-height: 24px;
padding: 0px 8px;
padding: 0px 12px;
margin: 8px 0px;
border-radius: 4px;
-webkit-app-region: no-drag;
transition: all 0.2s ease;
cursor: default;
outline: none;
display: flex;
align-items: center;
gap: 4px;
}
/* Optional: Style for menu items with icons (not typically used for top-level items) */
.menuItem dees-icon {
font-size: 14px;
opacity: 0.8;
}
.menuItem:hover {
background: #ffffff20;
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.menuItem.active {
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.menuItem[disabled] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.menuItem:focus-visible {
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#00000080', '#ffffff80')};
}
/* Dropdown styles */
.dropdown {
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: ${cssManager.bdTheme('#ffffff', '#000000')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
border-radius: 4px;
box-shadow: ${cssManager.bdTheme('0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.3)')};
margin-top: 4px;
z-index: 1000;
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.dropdown.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.dropdown-item {
padding: 8px 16px;
cursor: default;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.1s;
}
.dropdown-item:hover,
.dropdown-item.focused {
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
}
.dropdown-divider {
height: 1px;
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
margin: 4px 0;
}
.dropdown-item[disabled] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.dropdown-item .shortcut {
margin-left: auto;
opacity: 0.6;
font-size: 11px;
}
/* Breadcrumbs */
.breadcrumbs {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.breadcrumb-item {
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
cursor: default;
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.breadcrumb-separator {
margin: 0 8px;
opacity: 0.5;
}
/* Account section */
.account {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 16px;
gap: 12px;
}
.search-icon {
cursor: default;
opacity: 0.7;
transition: opacity 0.2s;
}
.search-icon:hover {
opacity: 1;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: default;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.user-info:hover {
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
}
.user-avatar {
position: relative;
width: 24px;
height: 24px;
line-height: 24px;
margin: 8px;
border-radius: 8px;
text-align: center;
border-radius: 50%;
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
}
.user-avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.user-status {
position: absolute;
bottom: -2px;
right: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#000000')};
}
.user-status.online {
background: #4caf50;
}
.user-status.offline {
background: #757575;
}
.user-status.busy {
background: #f44336;
}
.user-status.away {
background: #ff9800;
}
`,
];
@ -62,16 +313,391 @@ export class DeesAppuiBar extends DeesElement {
public render(): TemplateResult {
return html`
<div class="menus">
<dees-windowcontrols></dees-windowcontrols>
<div class="menuItem">File</div>
<div class="menuItem">View</div>
<div class="menuItem">Help</div>
<div class="menuItem">Terminal</div>
${this.showWindowControls ? html`<dees-windowcontrols></dees-windowcontrols>` : ''}
${this.renderMenuItems()}
</div>
<div class="breadcrumbs">
tool:social.io > org:design.estate > prop:lossless.com
${this.renderBreadcrumbs()}
</div>
<div class="account">
${this.renderAccountSection()}
</div>
<div class="account"></div>
`;
}
private renderMenuItems(): TemplateResult {
return html`
${this.menuItems.map((item, index) => this.renderMenuItem(item, `menu-${index}`))}
`;
}
private renderMenuItem(item: interfaces.IAppBarMenuItem, itemId: string): TemplateResult {
if ('divider' in item && item.divider) {
return html`<div class="dropdown-divider"></div>`;
}
const menuItem = item as interfaces.IAppBarMenuItemRegular;
const isActive = this.activeMenu === itemId;
const hasSubmenu = menuItem.submenu && menuItem.submenu.length > 0;
return html`
<div
class="menuItem ${isActive ? 'active' : ''}"
?disabled=${menuItem.disabled}
tabindex="${menuItem.disabled ? -1 : 0}"
data-item-id="${itemId}"
@click=${() => this.handleMenuClick(menuItem, itemId)}
@keydown=${(e: KeyboardEvent) => this.handleMenuKeydown(e, menuItem, itemId)}
role="menuitem"
aria-haspopup="${hasSubmenu}"
aria-expanded="${isActive}"
>
${menuItem.iconName ? html`<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>` : ''}
${menuItem.name}
${hasSubmenu ? this.renderDropdown(menuItem.submenu, itemId, isActive) : ''}
</div>
`;
}
private renderDropdown(items: interfaces.IAppBarMenuItem[], parentId: string, isOpen: boolean): TemplateResult {
return html`
<div
class="dropdown ${isOpen ? 'open' : ''}"
@click=${(e: Event) => e.stopPropagation()}
@keydown=${(e: KeyboardEvent) => this.handleDropdownKeydown(e, items, parentId)}
tabindex="${isOpen ? 0 : -1}"
role="menu"
>
${items.map((item, index) => this.renderDropdownItem(item, `${parentId}-${index}`))}
</div>
`;
}
private renderDropdownItem(item: interfaces.IAppBarMenuItem, itemId: string): TemplateResult {
if ('divider' in item && item.divider) {
return html`<div class="dropdown-divider"></div>`;
}
const menuItem = item as interfaces.IAppBarMenuItemRegular;
const itemIndex = parseInt(itemId.split('-').pop() || '0');
const isFocused = this.focusedDropdownItem === itemIndex;
return html`
<div
class="dropdown-item ${isFocused ? 'focused' : ''}"
?disabled=${menuItem.disabled}
@click=${() => this.handleDropdownItemClick(menuItem)}
@mouseenter=${() => this.focusedDropdownItem = itemIndex}
role="menuitem"
tabindex="${menuItem.disabled ? -1 : 0}"
>
${menuItem.iconName ? html`<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>` : ''}
<span>${menuItem.name}</span>
${menuItem.shortcut ? html`<span class="shortcut">${menuItem.shortcut}</span>` : ''}
</div>
`;
}
private renderBreadcrumbs(): TemplateResult {
if (!this.breadcrumbs) {
return html``;
}
const parts = this.breadcrumbs.split(this.breadcrumbSeparator);
return html`
${parts.map((part, index) => html`
${index > 0 ? html`<span class="breadcrumb-separator">${this.breadcrumbSeparator}</span>` : ''}
<span
class="breadcrumb-item"
@click=${() => this.handleBreadcrumbClick(part, index)}
>
${part}
</span>
`)}
`;
}
private renderAccountSection(): TemplateResult {
return html`
${this.showSearch ? html`
<dees-icon
class="search-icon"
.icon=${'lucide:search'}
@click=${this.handleSearchClick}
></dees-icon>
` : ''}
${this.user ? html`
<div style="position: relative;">
<div class="user-info" @click=${this.handleUserClick}>
<div class="user-avatar">
${this.user.avatar ?
html`<img src="${this.user.avatar}" alt="${this.user.name}">` :
html`${this.user.name.charAt(0).toUpperCase()}`
}
${this.user.status ? html`
<div class="user-status ${this.user.status}"></div>
` : ''}
</div>
<span>${this.user.name}</span>
</div>
<dees-appui-profiledropdown
.user=${this.user}
.menuItems=${this.profileMenuItems}
.isOpen=${this.isProfileDropdownOpen}
.position=${'top-right'}
@menu-select=${(e: CustomEvent) => this.handleProfileMenuSelect(e)}
></dees-appui-profiledropdown>
</div>
` : ''}
`;
}
// Event handlers
private handleMenuClick(item: interfaces.IAppBarMenuItemRegular, itemId: string) {
if (item.disabled) return;
if (item.submenu && item.submenu.length > 0) {
// Toggle dropdown
if (this.activeMenu === itemId) {
this.activeMenu = null;
} else {
this.activeMenu = itemId;
}
} else {
// Execute action
this.activeMenu = null;
if (item.action) {
item.action();
}
this.dispatchEvent(new CustomEvent('menu-select', {
detail: { item },
bubbles: true,
composed: true
}));
}
}
private handleDropdownItemClick(item: interfaces.IAppBarMenuItemRegular) {
if (item.disabled) return;
this.activeMenu = null;
if (item.action) {
item.action();
}
this.dispatchEvent(new CustomEvent('menu-select', {
detail: { item },
bubbles: true,
composed: true
}));
}
private handleMenuKeydown(e: KeyboardEvent, item: interfaces.IAppBarMenuItemRegular, itemId: string) {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
this.handleMenuClick(item, itemId);
break;
case 'ArrowDown':
if (item.submenu && this.activeMenu === itemId) {
e.preventDefault();
// Focus first non-disabled item in dropdown
this.focusedDropdownItem = 0;
const firstValidItem = this.findNextValidItem(item.submenu, -1, 1);
if (firstValidItem !== -1) {
this.focusedDropdownItem = firstValidItem;
// Focus the dropdown element
setTimeout(() => {
const dropdown = this.renderRoot.querySelector('.dropdown.open');
if (dropdown) {
(dropdown as HTMLElement).focus();
}
}, 0);
}
}
break;
case 'Escape':
this.activeMenu = null;
this.focusedDropdownItem = -1;
break;
case 'Tab':
// Let default tab navigation work but close dropdown
if (this.activeMenu === itemId) {
this.activeMenu = null;
this.focusedDropdownItem = -1;
}
break;
case 'ArrowRight':
e.preventDefault();
this.focusNextMenuItem(itemId, 1);
break;
case 'ArrowLeft':
e.preventDefault();
this.focusNextMenuItem(itemId, -1);
break;
}
}
private handleBreadcrumbClick(breadcrumb: string, index: number) {
this.dispatchEvent(new CustomEvent('breadcrumb-navigate', {
detail: { breadcrumb, index },
bubbles: true,
composed: true
}));
}
private handleSearchClick() {
this.dispatchEvent(new CustomEvent('search-click', {
bubbles: true,
composed: true
}));
}
private handleUserClick() {
this.isProfileDropdownOpen = !this.isProfileDropdownOpen;
// Also emit the event for backward compatibility
this.dispatchEvent(new CustomEvent('user-menu-open', {
bubbles: true,
composed: true
}));
}
private handleProfileMenuSelect(e: CustomEvent) {
this.isProfileDropdownOpen = false;
// Re-emit the event
this.dispatchEvent(new CustomEvent('profile-menu-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
// Lifecycle
async connectedCallback() {
await super.connectedCallback();
// Add global click listener to close dropdowns
this.addEventListener('click', this.handleGlobalClick);
document.addEventListener('click', this.handleDocumentClick);
}
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('click', this.handleDocumentClick);
}
private handleGlobalClick = (e: Event) => {
// Prevent closing when clicking inside
e.stopPropagation();
}
private handleDocumentClick = () => {
// Close all dropdowns when clicking outside
this.activeMenu = null;
this.focusedDropdownItem = -1;
// Note: Profile dropdown handles its own outside clicks
}
private handleDropdownKeydown(e: KeyboardEvent, items: interfaces.IAppBarMenuItem[], _parentId: string) {
const validItems = items.filter(item => !('divider' in item && item.divider));
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
const nextIndex = this.findNextValidItem(items, this.focusedDropdownItem, 1);
if (nextIndex !== -1) {
this.focusedDropdownItem = nextIndex;
}
break;
case 'ArrowUp':
e.preventDefault();
const prevIndex = this.findNextValidItem(items, this.focusedDropdownItem, -1);
if (prevIndex !== -1) {
this.focusedDropdownItem = prevIndex;
}
break;
case 'Enter':
e.preventDefault();
if (this.focusedDropdownItem !== -1) {
const focusedItem = validItems[this.focusedDropdownItem];
if (focusedItem && 'action' in focusedItem && !focusedItem.disabled) {
this.handleDropdownItemClick(focusedItem as interfaces.IAppBarMenuItemRegular);
}
}
break;
case 'Home':
e.preventDefault();
const firstIndex = this.findNextValidItem(items, -1, 1);
if (firstIndex !== -1) {
this.focusedDropdownItem = firstIndex;
}
break;
case 'End':
e.preventDefault();
const lastIndex = this.findNextValidItem(items, items.length, -1);
if (lastIndex !== -1) {
this.focusedDropdownItem = lastIndex;
}
break;
case 'Escape':
e.preventDefault();
this.activeMenu = null;
this.focusedDropdownItem = -1;
// Return focus to menu item
const menuItem = this.renderRoot.querySelector(`.menuItem.active`);
if (menuItem) {
(menuItem as HTMLElement).focus();
}
break;
}
}
private findNextValidItem(items: interfaces.IAppBarMenuItem[], currentIndex: number, direction: number): number {
let index = currentIndex + direction;
while (index >= 0 && index < items.length) {
const item = items[index];
// Skip dividers and disabled items
if (!('divider' in item && item.divider) && !('disabled' in item && item.disabled)) {
return index;
}
index += direction;
}
return -1;
}
private focusNextMenuItem(currentItemId: string, direction: number) {
const menuItems = Array.from(this.renderRoot.querySelectorAll('.menuItem'));
const currentIndex = menuItems.findIndex(item => item.getAttribute('data-item-id') === currentItemId);
if (currentIndex === -1) return;
let nextIndex = currentIndex + direction;
// Wrap around
if (nextIndex < 0) {
nextIndex = menuItems.length - 1;
} else if (nextIndex >= menuItems.length) {
nextIndex = 0;
}
// Find next non-disabled item
let attempts = 0;
while (attempts < menuItems.length) {
const nextItem = menuItems[nextIndex] as HTMLElement;
if (!nextItem.hasAttribute('disabled')) {
nextItem.focus();
// Close current dropdown if open
if (this.activeMenu) {
this.activeMenu = null;
this.focusedDropdownItem = -1;
}
break;
}
nextIndex = (nextIndex + direction + menuItems.length) % menuItems.length;
attempts++;
}
}
}

View File

@ -0,0 +1,157 @@
import { html, css } from '@design.estate/dees-element';
import type { DeesAppuiBase } from './dees-appui-base.js';
import type { IAppBarMenuItem } from './interfaces/appbarmenuitem.js';
import type { ITab } from './interfaces/tab.js';
import type { ISelectionOption } from './interfaces/selectionoption.js';
import * as plugins from './00plugins.js';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => {
// Menu items for the appbar
const menuItems: IAppBarMenuItem[] = [
{
name: 'File',
action: async () => {},
submenu: [
{ name: 'New Project', shortcut: 'Cmd+N', iconName: 'filePlus', action: async () => console.log('New project') },
{ name: 'Open Project...', shortcut: 'Cmd+O', iconName: 'folderOpen', action: async () => console.log('Open project') },
{ name: 'Recent Projects', action: async () => {}, submenu: [
{ name: 'my-app', action: async () => console.log('Open my-app') },
{ name: 'component-lib', action: async () => console.log('Open component-lib') },
{ name: 'api-server', action: async () => console.log('Open api-server') },
]},
{ divider: true },
{ name: 'Save All', shortcut: 'Cmd+Shift+S', iconName: 'save', action: async () => console.log('Save all') },
{ divider: true },
{ name: 'Close Project', action: async () => console.log('Close project') },
]
},
{
name: 'Edit',
action: async () => {},
submenu: [
{ name: 'Undo', shortcut: 'Cmd+Z', iconName: 'undo', action: async () => console.log('Undo') },
{ name: 'Redo', shortcut: 'Cmd+Shift+Z', iconName: 'redo', action: async () => console.log('Redo') },
{ divider: true },
{ name: 'Cut', shortcut: 'Cmd+X', iconName: 'scissors', action: async () => console.log('Cut') },
{ name: 'Copy', shortcut: 'Cmd+C', iconName: 'copy', action: async () => console.log('Copy') },
{ name: 'Paste', shortcut: 'Cmd+V', iconName: 'clipboard', action: async () => console.log('Paste') },
]
},
{
name: 'View',
action: async () => {},
submenu: [
{ name: 'Toggle Sidebar', shortcut: 'Cmd+B', action: async () => console.log('Toggle sidebar') },
{ name: 'Toggle Terminal', shortcut: 'Cmd+J', iconName: 'terminal', action: async () => console.log('Toggle terminal') },
{ divider: true },
{ name: 'Zoom In', shortcut: 'Cmd++', iconName: 'zoomIn', action: async () => console.log('Zoom in') },
{ name: 'Zoom Out', shortcut: 'Cmd+-', iconName: 'zoomOut', action: async () => console.log('Zoom out') },
{ name: 'Reset Zoom', shortcut: 'Cmd+0', action: async () => console.log('Reset zoom') },
]
},
{
name: 'Help',
action: async () => {},
submenu: [
{ name: 'Documentation', iconName: 'book', action: async () => console.log('Documentation') },
{ name: 'Release Notes', iconName: 'fileText', action: async () => console.log('Release notes') },
{ divider: true },
{ name: 'Report Issue', iconName: 'bug', action: async () => console.log('Report issue') },
{ name: 'About', iconName: 'info', action: async () => console.log('About') },
]
}
];
// Main menu tabs (left sidebar)
const mainMenuTabs: ITab[] = [
{ key: 'dashboard', iconName: 'home', action: () => console.log('Dashboard selected') },
{ key: 'projects', iconName: 'folder', action: () => console.log('Projects selected') },
{ key: 'analytics', iconName: 'lineChart', action: () => console.log('Analytics selected') },
{ key: 'settings', iconName: 'settings', action: () => console.log('Settings selected') },
];
// Selector options (second sidebar)
const selectorOptions: (ISelectionOption | { divider: true })[] = [
{ key: 'Overview', iconName: 'home', action: () => console.log('Overview selected') },
{ key: 'Components', iconName: 'package', action: () => console.log('Components selected') },
{ key: 'Services', iconName: 'server', action: () => console.log('Services selected') },
{ divider: true },
{ key: 'Database', iconName: 'database', action: () => console.log('Database selected') },
{ key: 'Settings', iconName: 'settings', action: () => console.log('Settings selected') },
];
// Main content tabs
const mainContentTabs: ITab[] = [
{ key: 'Details', iconName: 'file', action: () => console.log('Details tab') },
{ key: 'Logs', iconName: 'list', action: () => console.log('Logs tab') },
{ key: 'Metrics', iconName: 'lineChart', action: () => console.log('Metrics tab') },
];
// Profile menu items
const profileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [
{ name: 'Profile Settings', iconName: 'user', action: async () => console.log('Profile settings') },
{ name: 'Account', iconName: 'settings', action: async () => console.log('Account settings') },
{ divider: true },
{ name: 'Help & Support', iconName: 'helpCircle', action: async () => console.log('Help') },
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', shortcut: 'Cmd+K', action: async () => console.log('Shortcuts') },
{ divider: true },
{ name: 'Sign Out', iconName: 'logOut', action: async () => console.log('Sign out') }
];
return html`
<dees-demowrapper>
<style>
${css`
.demo-container {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
`}
</style>
<div class="demo-container">
<dees-appui-base
.appbarMenuItems=${menuItems}
.appbarBreadcrumbs=${'Dashboard'}
.appbarUser=${{
name: 'Jane Smith',
email: 'jane.smith@example.com',
status: 'online' as 'online' | 'offline' | 'busy' | 'away'
}}
.appbarProfileMenuItems=${profileMenuItems}
.appbarShowWindowControls=${true}
.appbarShowSearch=${true}
.mainmenuTabs=${mainMenuTabs}
.mainselectorOptions=${selectorOptions}
.maincontentTabs=${mainContentTabs}
@appbar-menu-select=${(e: CustomEvent) => console.log('Menu selected:', e.detail)}
@appbar-breadcrumb-navigate=${(e: CustomEvent) => console.log('Breadcrumb:', e.detail)}
@appbar-search-click=${() => console.log('Search clicked')}
@appbar-user-menu-open=${() => console.log('User menu opened')}
@appbar-profile-menu-select=${(e: CustomEvent) => console.log('Profile menu selected:', e.detail)}
@mainmenu-tab-select=${(e: CustomEvent) => console.log('Tab selected:', e.detail)}
@mainselector-option-select=${(e: CustomEvent) => console.log('Option selected:', e.detail)}
>
<div slot="maincontent" style="padding: 40px; color: #ccc;">
<h1>Application Content</h1>
<p>This is the main content area where your application's primary interface would be displayed.</p>
<p>The layout includes:</p>
<ul>
<li>App bar with menus, breadcrumbs, and user account</li>
<li>Main menu (left sidebar) for primary navigation</li>
<li>Selector menu (second sidebar) for sub-navigation</li>
<li>Main content area (this section)</li>
<li>Activity log (right sidebar)</li>
</ul>
</div>
</dees-appui-base>
</div>
</dees-demowrapper>
`;
};

View File

@ -6,11 +6,89 @@ import {
html,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as interfaces from './interfaces/index.js';
import * as plugins from './00plugins.js';
import type { DeesAppuiBar } from './dees-appui-appbar.js';
import type { DeesAppuiMainmenu } from './dees-appui-mainmenu.js';
import type { DeesAppuiMainselector } from './dees-appui-mainselector.js';
import type { DeesAppuiMaincontent } from './dees-appui-maincontent.js';
import type { DeesAppuiActivitylog } from './dees-appui-activitylog.js';
import { demoFunc } from './dees-appui-base.demo.js';
// Import child components
import './dees-appui-appbar.js';
import './dees-appui-mainmenu.js';
import './dees-appui-mainselector.js';
import './dees-appui-maincontent.js';
import './dees-appui-activitylog.js';
@customElement('dees-appui-base')
export class DeesAppuiBase extends DeesElement {
public static demo = () => html`<dees-appui-base></dees-appui-base>`;
public static demo = demoFunc;
// Properties for appbar
@property({ type: Array })
public appbarMenuItems: interfaces.IAppBarMenuItem[] = [];
@property({ type: String })
public appbarBreadcrumbs: string = '';
@property({ type: String })
public appbarBreadcrumbSeparator: string = ' > ';
@property({ type: Boolean })
public appbarShowWindowControls: boolean = true;
@property({ type: Object })
public appbarUser?: {
name: string;
email?: string;
avatar?: string;
status?: 'online' | 'offline' | 'busy' | 'away';
};
@property({ type: Array })
public appbarProfileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
@property({ type: Boolean })
public appbarShowSearch: boolean = false;
// Properties for mainmenu
@property({ type: Array })
public mainmenuTabs: interfaces.ITab[] = [];
@property({ type: Object })
public mainmenuSelectedTab?: interfaces.ITab;
// Properties for mainselector
@property({ type: Array })
public mainselectorOptions: (interfaces.ISelectionOption | { divider: true })[] = [];
@property({ type: Object })
public mainselectorSelectedOption?: interfaces.ISelectionOption;
// Properties for maincontent
@property({ type: Array })
public maincontentTabs: interfaces.ITab[] = [];
// References to child components
@state()
public appbar?: DeesAppuiBar;
@state()
public mainmenu?: DeesAppuiMainmenu;
@state()
public mainselector?: DeesAppuiMainselector;
@state()
public maincontent?: DeesAppuiMaincontent;
@state()
public activitylog?: DeesAppuiActivitylog;
public static styles = [
cssManager.defaultStyles,
@ -19,6 +97,7 @@ export class DeesAppuiBase extends DeesElement {
position: absolute;
height: 100%;
width: 100%;
background: ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
}
.maingrid {
position: absolute;
@ -26,7 +105,7 @@ export class DeesAppuiBase extends DeesElement {
height: calc(100% - 40px);
width: 100%;
display: grid;
grid-template-columns: 60px 240px auto 240px;
grid-template-columns: 60px 240px 1fr 240px;
}
`,
];
@ -35,13 +114,106 @@ export class DeesAppuiBase extends DeesElement {
public render(): TemplateResult {
return html`
<style></style>
<dees-appui-appbar></dees-appui-appbar>
<dees-appui-appbar
.menuItems=${this.appbarMenuItems}
.breadcrumbs=${this.appbarBreadcrumbs}
.breadcrumbSeparator=${this.appbarBreadcrumbSeparator}
.showWindowControls=${this.appbarShowWindowControls}
.user=${this.appbarUser}
.profileMenuItems=${this.appbarProfileMenuItems}
.showSearch=${this.appbarShowSearch}
@menu-select=${(e: CustomEvent) => this.handleAppbarMenuSelect(e)}
@breadcrumb-navigate=${(e: CustomEvent) => this.handleAppbarBreadcrumbNavigate(e)}
@search-click=${() => this.handleAppbarSearchClick()}
@user-menu-open=${() => this.handleAppbarUserMenuOpen()}
@profile-menu-select=${(e: CustomEvent) => this.handleAppbarProfileMenuSelect(e)}
></dees-appui-appbar>
<div class="maingrid">
<dees-appui-mainmenu></dees-appui-mainmenu>
<dees-appui-mainselector></dees-appui-mainselector>
<dees-appui-maincontent></dees-appui-maincontent>
<dees-appui-mainmenu
.tabs=${this.mainmenuTabs}
.selectedTab=${this.mainmenuSelectedTab}
@tab-select=${(e: CustomEvent) => this.handleMainmenuTabSelect(e)}
></dees-appui-mainmenu>
<dees-appui-mainselector
.selectionOptions=${this.mainselectorOptions}
.selectedOption=${this.mainselectorSelectedOption}
@option-select=${(e: CustomEvent) => this.handleMainselectorOptionSelect(e)}
></dees-appui-mainselector>
<dees-appui-maincontent
.tabs=${this.maincontentTabs}
>
<slot name="maincontent"></slot>
</dees-appui-maincontent>
<dees-appui-activitylog></dees-appui-activitylog>
</div>
`;
}
async firstUpdated() {
// Get references to child components
this.appbar = this.shadowRoot.querySelector('dees-appui-appbar');
this.mainmenu = this.shadowRoot.querySelector('dees-appui-mainmenu');
this.mainselector = this.shadowRoot.querySelector('dees-appui-mainselector');
this.maincontent = this.shadowRoot.querySelector('dees-appui-maincontent');
this.activitylog = this.shadowRoot.querySelector('dees-appui-activitylog');
}
// Event handlers for appbar
private handleAppbarMenuSelect(e: CustomEvent) {
this.dispatchEvent(new CustomEvent('appbar-menu-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
private handleAppbarBreadcrumbNavigate(e: CustomEvent) {
this.dispatchEvent(new CustomEvent('appbar-breadcrumb-navigate', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
private handleAppbarSearchClick() {
this.dispatchEvent(new CustomEvent('appbar-search-click', {
bubbles: true,
composed: true
}));
}
private handleAppbarUserMenuOpen() {
this.dispatchEvent(new CustomEvent('appbar-user-menu-open', {
bubbles: true,
composed: true
}));
}
private handleAppbarProfileMenuSelect(e: CustomEvent) {
this.dispatchEvent(new CustomEvent('appbar-profile-menu-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
// Event handlers for mainmenu
private handleMainmenuTabSelect(e: CustomEvent) {
this.mainmenuSelectedTab = e.detail.tab;
this.dispatchEvent(new CustomEvent('mainmenu-tab-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
// Event handlers for mainselector
private handleMainselectorOptionSelect(e: CustomEvent) {
this.mainselectorSelectedOption = e.detail.option;
this.dispatchEvent(new CustomEvent('mainselector-option-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
}

View File

@ -11,35 +11,47 @@ import {
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import './dees-appui-tabs.js';
import type { DeesAppuiTabs } from './dees-appui-tabs.js';
@customElement('dees-appui-maincontent')
export class DeesAppuiMaincontent extends DeesElement {
public static demo = () => html`<dees-appui-maincontent></dees-appui-maincontent>`;
public static demo = () => html`
<dees-appui-maincontent
.tabs=${[
{ key: 'Overview', iconName: 'home', action: () => console.log('Overview') },
{ key: 'Details', iconName: 'file', action: () => console.log('Details') },
{ key: 'Settings', iconName: 'cog', action: () => console.log('Settings') },
]}
>
<div slot="content" style="padding: 40px; color: #ccc;">
<h1>Main Content Area</h1>
<p>This is where your application content goes.</p>
</div>
</dees-appui-maincontent>
`;
// INSTANCE
@property({
type: Array,
})
public tabs: interfaces.ITab[] = [
{ key: 'option 1', action: () => {} },
{ key: 'a very long option', action: () => {} },
{ key: 'reminder: set your tabs', action: () => {} },
{ key: 'option 4', action: () => {} },
{ key: '⚠️ Please set tabs', action: () => console.warn('No tabs configured for maincontent') },
];
@property()
public selectedTab = null;
@property({ type: Object })
public selectedTab: interfaces.ITab | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
color: #fff;
color: ${cssManager.bdTheme('#333', '#fff')};
display: block;
width: 100%;
height: 100%;
position: relative;
background: #161616;
background: ${cssManager.bdTheme('#ffffff', '#161616')};
}
.maincontainer {
position: absolute;
@ -52,110 +64,58 @@ export class DeesAppuiMaincontent extends DeesElement {
.topbar {
position: absolute;
width: 100%;
background: #000000;
user-select: none;
}
.topbar .tabsContainer {
padding-top: 20px;
padding-bottom: 0px;
position: relative;
z-index: 1;
display: grid;
margin-left: 24px;
font-size: 14px;
}
.topbar .tabsContainer .tab {
color: #a0a0a0;
white-space: nowrap;
margin-right: 30px;
padding-top: 4px;
padding-bottom: 12px;
transition: color 0.1s;
}
.topbar .tabsContainer .tab:hover {
color: #ffffff;
}
.topbar .tabsContainer .tab.selectedTab {
color: #e0e0e0;
}
.topbar .tabIndicator {
.content-area {
position: absolute;
z-index: 0;
left: 40px;
bottom: 0px;
height: 40px;
width: 40px;
background: #161616;
transition: all 0.1s;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-top: 1px solid #444444;
}
.mainicon {
top: 60px;
left: 0;
right: 0;
bottom: 0;
overflow: auto;
}
`,
];
public render(): TemplateResult {
return html`
<style>
.topbar .tabsContainer {
grid-template-columns: repeat(${this.tabs.length}, min-content);
}
</style>
<div class="maincontainer">
<div class="topbar">
<div class="tabsContainer">
${this.tabs.map((tabArg) => {
return html`
<div
class="tab ${tabArg === this.selectedTab ? 'selectedTab' : null}"
@click="${() => {
this.selectedTab = tabArg;
this.updateTabIndicator();
tabArg.action();
}}"
>
${tabArg.key}
<dees-appui-tabs
.tabs=${this.tabs}
.selectedTab=${this.selectedTab}
.showTabIndicator=${true}
.tabStyle=${'horizontal'}
@tab-select=${(e: CustomEvent) => this.handleTabSelect(e)}
></dees-appui-tabs>
</div>
`;
})}
</div>
<div class="tabIndicator"></div>
<div class="content-area">
<slot></slot>
<slot name="content"></slot>
</div>
</div>
`;
}
/**
* updates the indicator
*/
private updateTabIndicator() {
let selectedTab = this.selectedTab;
const tabIndex = this.tabs.indexOf(selectedTab);
const selectedTabElement: HTMLElement = this.shadowRoot.querySelector(
`.tabsContainer .tab:nth-child(${tabIndex + 1})`
);
const tabsContainer: HTMLElement = this.shadowRoot.querySelector('.tabsContainer');
const marginLeft = parseInt(window.getComputedStyle(tabsContainer).getPropertyValue("margin-left"));
const tabIndicator: HTMLElement = this.shadowRoot.querySelector('.tabIndicator');
tabIndicator.style.width = selectedTabElement.clientWidth + 24 + 'px';
tabIndicator.style.left = selectedTabElement.offsetLeft + marginLeft - 12 + 'px';
private handleTabSelect(e: CustomEvent) {
this.selectedTab = e.detail.tab;
// Re-emit the event
this.dispatchEvent(new CustomEvent('tab-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
private updateTab(tabArg: interfaces.ITab) {
this.selectedTab = tabArg;
this.updateTabIndicator();
this.selectedTab.action();
}
firstUpdated() {
this.updateTab(this.tabs[0]);
async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
await super.firstUpdated(_changedProperties);
// Tab selection is now handled by the dees-appui-tabs component
// But we need to ensure the tabs component is ready
const tabsComponent = this.shadowRoot.querySelector('dees-appui-tabs') as DeesAppuiTabs;
if (tabsComponent) {
await tabsComponent.updateComplete;
}
}
}

View File

@ -18,17 +18,23 @@ import { DeesContextmenu } from './dees-contextmenu.js';
*/
@customElement('dees-appui-mainmenu')
export class DeesAppuiMainmenu extends DeesElement {
public static demo = () => html`<dees-appui-mainmenu></dees-appui-mainmenu>`;
public static demo = () => html`
<dees-appui-mainmenu
.tabs=${[
{ key: 'Dashboard', iconName: 'home', action: () => console.log('Dashboard') },
{ key: 'Projects', iconName: 'folder', action: () => console.log('Projects') },
{ key: 'Analytics', iconName: 'lineChart', action: () => console.log('Analytics') },
{ key: 'Settings', iconName: 'settings', action: () => console.log('Settings') },
]}
></dees-appui-mainmenu>
`;
// INSTANCE
// INSTANCE
@property()
@property({ type: Array })
public tabs: interfaces.ITab[] = [
{ key: 'option 1', iconName: 'building', action: () => {} },
{ key: 'option 2', iconName: 'building', action: () => {} },
{ key: 'option 3', iconName: 'building', action: () => {} },
{ key: 'option 4', iconName: 'building', action: () => {} },
{ key: '⚠️ Please set tabs', iconName: 'alertTriangle', action: () => console.warn('No tabs configured for mainmenu') },
];
@property()
@ -39,16 +45,16 @@ export class DeesAppuiMainmenu extends DeesElement {
css`
.mainContainer {
--menuSize: 60px;
color: #ccc;
color: ${cssManager.bdTheme('#666', '#ccc')};
z-index: 10;
display: block;
position: relative;
width: var(--menuSize);
height: 100%;
background: #000000;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.5);
background: ${cssManager.bdTheme('#f5f5f5', '#000000')};
box-shadow: ${cssManager.bdTheme('0px 0px 5px rgba(0, 0, 0, 0.1)', '0px 0px 5px rgba(0, 0, 0, 0.5)')};
user-select: none;
border-right: 1px solid #202020;
border-right: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.tabsContainer {
@ -64,17 +70,17 @@ export class DeesAppuiMainmenu extends DeesElement {
}
.tab:hover {
background: rgba(255, 255, 255, 0.15);
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.15)')};
}
.tab.selectedTab {
color: #fff;
background: rgba(255, 255, 255, 0.1);
color: ${cssManager.bdTheme('#000', '#fff')};
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
}
.tabIndicator {
opacity: 0;
background: #4e729a;
background: ${cssManager.bdTheme('#2196f3', '#4e729a')};
position: absolute;
width: 5px;
height: calc((var(--menuSize) / 3) * 2);
@ -105,7 +111,7 @@ export class DeesAppuiMainmenu extends DeesElement {
this.updateTab(tabArg);
}}"
>
<dees-icon iconFA="${tabArg.iconName as any}"></dees-icon>
<dees-icon .icon="${tabArg.iconName ? `lucide:${tabArg.iconName}` : ''}"></dees-icon>
</div>
`;
})}
@ -115,7 +121,7 @@ export class DeesAppuiMainmenu extends DeesElement {
`;
}
private async updateTabIndicator() {
private updateTabIndicator() {
let selectedTab = this.selectedTab;
if (!selectedTab) {
selectedTab = this.tabs[0];
@ -124,7 +130,12 @@ export class DeesAppuiMainmenu extends DeesElement {
const selectedTabElement: HTMLElement = this.shadowRoot.querySelector(
`.tabsContainer .tab:nth-child(${tabIndex + 1})`
);
if (!selectedTabElement) return;
const tabIndicator: HTMLElement = this.shadowRoot.querySelector('.tabIndicator');
if (!tabIndicator) return;
const offsetTop = selectedTabElement.offsetTop;
tabIndicator.style.opacity = `1`;
tabIndicator.style.top = `calc(${offsetTop}px + (var(--menuSize) / 6))`;
@ -134,6 +145,13 @@ export class DeesAppuiMainmenu extends DeesElement {
this.selectedTab = tabArg;
this.updateTabIndicator();
this.selectedTab.action();
// Emit tab-select event
this.dispatchEvent(new CustomEvent('tab-select', {
detail: { tab: tabArg },
bubbles: true,
composed: true
}));
}
firstUpdated() {

View File

@ -2,6 +2,7 @@ import * as plugins from './00plugins.js';
import * as interfaces from './interfaces/index.js';
import { DeesContextmenu } from './dees-contextmenu.js';
import './dees-icon.js';
import {
DeesElement,
@ -19,22 +20,22 @@ import {
*/
@customElement('dees-appui-mainselector')
export class DeesAppuiMainselector extends DeesElement {
public static demo = () => html`<dees-appui-mainselector></dees-appui-mainselector>`;
public static demo = () => html`
<dees-appui-mainselector
.selectionOptions=${[
{ key: 'Overview', iconName: 'home', action: () => console.log('Overview') },
{ key: 'Components', iconName: 'package', action: () => console.log('Components') },
{ key: 'Services', iconName: 'server', action: () => console.log('Services') },
{ key: 'Database', iconName: 'database', action: () => console.log('Database') },
{ key: 'Settings', iconName: 'settings', action: () => console.log('Settings') },
]}
></dees-appui-mainselector>
`;
// INSTANCE
@property()
public selectionOptions: interfaces.ISelectionOption[] = [
{
key: 'Overview',
action: () => {},
},
{
key: 'option 1',
action: () => {},
},
{ key: 'option 2', action: () => {} },
{ key: 'option 3', action: () => {} },
{ key: 'option 4', action: () => {} },
@property({ type: Array })
public selectionOptions: (interfaces.ISelectionOption | { divider: true })[] = [
{ key: '⚠️ Please set selection options', action: () => console.warn('No selection options configured for mainselector') },
];
@property()
@ -44,14 +45,14 @@ export class DeesAppuiMainselector extends DeesElement {
cssManager.defaultStyles,
css`
:host {
color: #fff;
color: ${cssManager.bdTheme('#333', '#fff')};
position: relative;
display: block;
width: 100%;
max-width: 300px;
height: 100%;
background: #000000;
border-right: 1px solid #222222;
background: ${cssManager.bdTheme('#fafafa', '#000000')};
border-right: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.maincontainer {
position: absolute;
@ -63,52 +64,79 @@ export class DeesAppuiMainselector extends DeesElement {
.topbar {
position: absolute;
height: 32px;
height: 40px;
width: 100%;
display: flex;
align-items: center;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.topbar .heading {
padding-left: 16px;
padding-top: 8px;
line-height: 24px;
padding-left: 12px;
font-family: 'Geist Sans', sans-serif;
font-weight: 600;
font-size: 14px;
font-size: 12px;
color: ${cssManager.bdTheme('#666', '#999')};
text-transform: uppercase;
letter-spacing: 0.5px;
}
.selectionOptions {
position: absolute;
top: 32px;
padding-top: 8px;
top: 40px;
left: 0px;
width: 100%;
right: 0px;
bottom: 0px;
overflow-y: auto;
font-family: 'Geist Sans', sans-serif;
font-size: 14px;
font-size: 12px;
padding: 4px 0;
}
.selectionOptions .selectionOption {
cursor: default;
margin-left: 16px;
margin-right: 16px;
padding-top: 8px;
padding-bottom: 8px;
border-top: 1px dotted #303030;
border-left: 0px solid rgba(0, 0, 0, 0);
transition: all 0.1s;
padding: 8px 12px;
margin: 0;
transition: background 0.1s;
display: flex;
align-items: center;
gap: 8px;
color: ${cssManager.bdTheme('#333', '#ccc')};
user-select: none;
}
.selectionOptions .selectionOption:hover {
border-left: 2px solid #26a69a50;
padding-left: 8px;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
}
.selectionOptions .selectionOption:first-child {
border-top: 1px solid rgba(0, 0, 0, 0);
.selectionOptions .selectionOption:active {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
}
.selectionOptions .selectionOption.selectedOption {
border-left: 4px solid #26a69a;
padding-left: 10px;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
color: ${cssManager.bdTheme('#000', '#fff')};
font-weight: 500;
}
.selectionOptions .selectionOption.selectedOption::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: ${cssManager.bdTheme('#26a69a', '#26a69a')};
}
.selectionOption {
position: relative;
}
.selection-divider {
height: 1px;
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
margin: 4px 0;
}
`,
];
@ -118,17 +146,22 @@ export class DeesAppuiMainselector extends DeesElement {
<style></style>
<div class="maincontainer">
<div class="topbar">
<div class="heading">Properties</div>
<div class="heading">Selector</div>
</div>
<div class="selectionOptions">
${this.selectionOptions.map((selectionOptionArg) => {
if ('divider' in selectionOptionArg && selectionOptionArg.divider) {
return html`<div class="selection-divider"></div>`;
}
const option = selectionOptionArg as interfaces.ISelectionOption;
return html`
<div
class="selectionOption ${this.selectedOption === selectionOptionArg
class="selectionOption ${this.selectedOption === option
? 'selectedOption'
: null}"
@click="${() => {
this.selectOption(selectionOptionArg);
this.selectOption(option);
}}"
@contextmenu="${(eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(eventArg, [
@ -140,7 +173,10 @@ export class DeesAppuiMainselector extends DeesElement {
]);
}}"
>
${selectionOptionArg.key}
${option.iconName ? html`
<dees-icon .icon="${`lucide:${option.iconName}`}" style="font-size: 14px; opacity: 0.7;"></dees-icon>
` : ''}
<span style="flex: 1;">${option.key}</span>
</div>
`;
})}
@ -152,9 +188,24 @@ export class DeesAppuiMainselector extends DeesElement {
private selectOption(optionArg: interfaces.ISelectionOption) {
this.selectedOption = optionArg;
this.selectedOption.action();
// Emit option-select event
this.dispatchEvent(new CustomEvent('option-select', {
detail: { option: optionArg },
bubbles: true,
composed: true
}));
}
firstUpdated() {
this.selectOption(this.selectionOptions[0]);
async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
await super.firstUpdated(_changedProperties);
if (this.selectionOptions && this.selectionOptions.length > 0) {
await this.updateComplete;
// Find first non-divider option
const firstOption = this.selectionOptions.find(option => !('divider' in option)) as interfaces.ISelectionOption;
if (firstOption) {
this.selectOption(firstOption);
}
}
}
}

View File

@ -0,0 +1,401 @@
import * as plugins from './00plugins.js';
import {
DeesElement,
type TemplateResult,
property,
customElement,
html,
css,
cssManager,
state,
} from '@design.estate/dees-element';
@customElement('dees-appui-profiledropdown')
export class DeesAppuiProfileDropdown extends DeesElement {
public static demo = () => html`
<dees-appui-profiledropdown
.user=${{
name: 'John Doe',
email: 'john.doe@example.com',
avatar: 'https://randomuser.me/api/portraits/men/1.jpg',
status: 'online' as 'online'
}}
.menuItems=${[
{ name: 'Profile Settings', iconName: 'user', action: async () => console.log('Profile') },
{ name: 'Account', iconName: 'settings', action: async () => console.log('Account') },
{ divider: true },
{ name: 'Help & Support', iconName: 'helpCircle', action: async () => console.log('Help') },
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', shortcut: 'Cmd+K', action: async () => console.log('Shortcuts') },
{ divider: true },
{ name: 'Sign Out', iconName: 'logOut', action: async () => console.log('Sign out') }
]}
.isOpen=${true}
></dees-appui-profiledropdown>
`;
@property({ type: Object })
public user?: {
name: string;
email?: string;
avatar?: string;
status?: 'online' | 'offline' | 'busy' | 'away';
};
@property({ type: Array })
public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
@property({ type: Boolean, reflect: true })
public isOpen: boolean = false;
@property({ type: String })
public position: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' = 'top-right';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
position: absolute;
top: 100%;
left: 0;
right: 0;
pointer-events: none;
}
.dropdown {
position: absolute;
min-width: 220px;
background: ${cssManager.bdTheme('#ffffff', '#000000')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
border-radius: 4px;
box-shadow: ${cssManager.bdTheme(
'0 4px 12px rgba(0, 0, 0, 0.15)',
'0 4px 12px rgba(0, 0, 0, 0.3)'
)};
z-index: 1000;
opacity: 0;
transform: scale(0.95) translateY(-10px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
overflow: hidden;
font-size: 12px;
}
:host([isopen]) .dropdown {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.backdrop {
display: none;
}
/* Position variants */
.dropdown.top-right {
top: 100%;
right: 0;
margin-top: 4px;
}
.dropdown.top-left {
top: 100%;
left: 0;
margin-top: 8px;
}
.dropdown.bottom-right {
bottom: 100%;
right: 0;
margin-bottom: 8px;
}
.dropdown.bottom-left {
bottom: 100%;
left: 0;
margin-bottom: 8px;
}
/* User section */
.user-section {
padding: 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
position: relative;
width: 36px;
height: 36px;
border-radius: 50%;
background: ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#666', '#999')};
overflow: hidden;
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-status {
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#000000')};
}
.user-status.online {
background: #4caf50;
}
.user-status.offline {
background: #757575;
}
.user-status.busy {
background: #f44336;
}
.user-status.away {
background: #ff9800;
}
.user-details {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#000', '#fff')};
line-height: 1.2;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: 11px;
color: ${cssManager.bdTheme('#666', '#999')};
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Menu section */
.menu-section {
padding: 4px 0;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: default;
transition: background 0.1s;
color: ${cssManager.bdTheme('#333', '#ccc')};
font-size: 12px;
line-height: 1;
user-select: none;
}
.menu-item:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
}
.menu-item:active {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
}
.menu-item dees-icon {
font-size: 14px;
opacity: 0.7;
}
.menu-item-text {
flex: 1;
}
.menu-shortcut {
font-size: 11px;
color: ${cssManager.bdTheme('#999', '#666')};
margin-left: auto;
opacity: 0.7;
}
.menu-divider {
height: 1px;
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
margin: 4px 0;
}
/* Backdrop for mobile */
@media (max-width: 768px) {
.backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 999;
opacity: 0;
transition: opacity 0.2s;
display: none;
}
:host([isopen]) .backdrop {
display: block;
opacity: 1;
pointer-events: auto;
}
.dropdown {
position: fixed;
top: 50%;
left: 50%;
right: auto;
bottom: auto;
transform: translate(-50%, -50%) scale(0.95);
margin: 0;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
overflow-y: auto;
}
:host([isopen]) .dropdown {
transform: translate(-50%, -50%) scale(1);
}
}
`,
];
public render(): TemplateResult {
return html`
<div class="backdrop" @click=${() => this.close()}></div>
<div class="dropdown ${this.position}">
${this.user ? html`
<div class="user-section">
<div class="user-info">
<div class="user-avatar">
${this.user.avatar
? html`<img src="${this.user.avatar}" alt="${this.user.name}">`
: this.getInitials(this.user.name)
}
${this.user.status ? html`
<div class="user-status ${this.user.status}"></div>
` : ''}
</div>
<div class="user-details">
<div class="user-name">${this.user.name}</div>
${this.user.email ? html`
<div class="user-email">${this.user.email}</div>
` : ''}
</div>
</div>
</div>
` : ''}
<div class="menu-section">
${this.menuItems.map(item => this.renderMenuItem(item))}
</div>
</div>
`;
}
private renderMenuItem(item: plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true }): TemplateResult {
if ('divider' in item && item.divider) {
return html`<div class="menu-divider"></div>`;
}
const menuItem = item as plugins.tsclass.website.IMenuItem & { iconName?: string; shortcut?: string };
return html`
<div class="menu-item" @click=${() => this.handleMenuClick(menuItem)}>
${menuItem.iconName ? html`
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>
` : ''}
<span class="menu-item-text">${menuItem.name}</span>
${menuItem.shortcut ? html`
<span class="menu-shortcut">${menuItem.shortcut}</span>
` : ''}
</div>
`;
}
private getInitials(name: string): string {
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
private async handleMenuClick(item: plugins.tsclass.website.IMenuItem & { iconName?: string; shortcut?: string }) {
await item.action();
this.close();
// Emit menu-select event
this.dispatchEvent(new CustomEvent('menu-select', {
detail: { item },
bubbles: true,
composed: true
}));
}
public open() {
this.isOpen = true;
}
public close() {
this.isOpen = false;
}
public toggle() {
this.isOpen = !this.isOpen;
}
// Handle clicks outside the dropdown
async connectedCallback() {
await super.connectedCallback();
this.handleOutsideClick = this.handleOutsideClick.bind(this);
document.addEventListener('click', this.handleOutsideClick);
}
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('click', this.handleOutsideClick);
}
private handleOutsideClick(event: MouseEvent) {
if (this.isOpen && !this.contains(event.target as Node)) {
// Check if the click is on the parent element (which contains the profile button)
const parentElement = this.parentElement;
if (parentElement && parentElement.contains(event.target as Node)) {
// Don't close if clicking within the parent element (e.g., on the profile button)
return;
}
this.close();
}
}
}

View File

@ -0,0 +1,247 @@
import * as interfaces from './interfaces/index.js';
import {
DeesElement,
type TemplateResult,
property,
customElement,
html,
css,
cssManager,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
@customElement('dees-appui-tabs')
export class DeesAppuiTabs extends DeesElement {
public static demo = () => html`
<dees-appui-tabs
.tabs=${[
{ key: 'Tab 1', action: () => console.log('Tab 1 clicked') },
{ key: 'Tab 2', action: () => console.log('Tab 2 clicked') },
{ key: 'Tab 3', action: () => console.log('Tab 3 clicked') },
]}
></dees-appui-tabs>
`;
// INSTANCE
@property({
type: Array,
})
public tabs: interfaces.ITab[] = [];
@property({ type: Object })
public selectedTab: interfaces.ITab | null = null;
@property({ type: Boolean })
public showTabIndicator: boolean = true;
@property({ type: String })
public tabStyle: 'horizontal' | 'vertical' = 'horizontal';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
width: 100%;
}
.tabs-wrapper {
position: relative;
background: ${cssManager.bdTheme('#f5f5f5', '#000000')};
height: 52px;
}
.tabsContainer {
position: relative;
z-index: 1;
user-select: none;
}
.tabsContainer.horizontal {
display: grid;
padding-top: 20px;
padding-bottom: 0px;
margin-left: 24px;
font-size: 14px;
}
.tabsContainer.vertical {
display: flex;
flex-direction: column;
padding: 20px;
font-size: 14px;
}
.tab {
color: ${cssManager.bdTheme('#666', '#a0a0a0')};
white-space: nowrap;
cursor: default;
transition: color 0.1s;
}
.horizontal .tab {
margin-right: 30px;
padding-top: 4px;
padding-bottom: 12px;
}
.vertical .tab {
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 4px;
width: 100%;
display: flex;
align-items: center;
gap: 8px;
}
.tab:hover {
color: ${cssManager.bdTheme('#000', '#ffffff')};
}
.vertical .tab:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
}
.tab.selectedTab {
color: ${cssManager.bdTheme('#333', '#e0e0e0')};
}
.vertical .tab.selectedTab {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
color: ${cssManager.bdTheme('#000', '#ffffff')};
}
.tab dees-icon {
font-size: 16px;
}
.tabs-wrapper .tabIndicator {
position: absolute;
z-index: 0;
left: 40px;
bottom: 0px;
height: 40px;
width: 40px;
background: ${cssManager.bdTheme('#ffffff', '#161616')};
transition: all 0.1s;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444444')};
}
.vertical .tabIndicator {
display: none;
}
.content {
margin-top: 20px;
}
`,
];
public render(): TemplateResult {
return html`
${this.tabStyle === 'horizontal' ? html`
<style>
.tabsContainer.horizontal {
grid-template-columns: repeat(${this.tabs.length}, min-content);
}
</style>
<div class="tabs-wrapper">
<div class="tabsContainer horizontal">
${this.tabs.map((tabArg) => {
return html`
<div
class="tab ${tabArg === this.selectedTab ? 'selectedTab' : ''}"
@click="${() => this.selectTab(tabArg)}"
>
${tabArg.key}
</div>
`;
})}
</div>
${this.showTabIndicator ? html`
<div class="tabIndicator"></div>
` : ''}
</div>
` : html`
<div class="tabsContainer vertical">
${this.tabs.map((tabArg) => {
return html`
<div
class="tab ${tabArg === this.selectedTab ? 'selectedTab' : ''}"
@click="${() => this.selectTab(tabArg)}"
>
${tabArg.iconName ? html`<dees-icon .iconName=${tabArg.iconName}></dees-icon>` : ''}
${tabArg.key}
</div>
`;
})}
</div>
`}
<div class="content">
<slot></slot>
</div>
`;
}
private selectTab(tabArg: interfaces.ITab) {
this.selectedTab = tabArg;
this.updateTabIndicator();
tabArg.action();
// Emit tab-select event
this.dispatchEvent(new CustomEvent('tab-select', {
detail: { tab: tabArg },
bubbles: true,
composed: true
}));
}
/**
* updates the indicator position
*/
private updateTabIndicator() {
if (!this.showTabIndicator || this.tabStyle !== 'horizontal' || !this.selectedTab) {
return;
}
const tabIndex = this.tabs.indexOf(this.selectedTab);
const selectedTabElement: HTMLElement = this.shadowRoot.querySelector(
`.tabs-wrapper .tabsContainer .tab:nth-child(${tabIndex + 1})`
);
if (!selectedTabElement) return;
const tabsContainer: HTMLElement = this.shadowRoot.querySelector('.tabs-wrapper .tabsContainer');
const marginLeft = parseInt(window.getComputedStyle(tabsContainer).getPropertyValue("margin-left"));
const tabIndicator: HTMLElement = this.shadowRoot.querySelector('.tabs-wrapper .tabIndicator');
if (tabIndicator) {
tabIndicator.style.width = selectedTabElement.clientWidth + 24 + 'px';
tabIndicator.style.left = selectedTabElement.offsetLeft + marginLeft - 12 + 'px';
}
}
firstUpdated() {
if (this.tabs && this.tabs.length > 0) {
this.selectTab(this.tabs[0]);
}
}
async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('tabs') && this.tabs && this.tabs.length > 0 && !this.selectedTab) {
this.selectTab(this.tabs[0]);
}
if (changedProperties.has('selectedTab') || changedProperties.has('tabs')) {
this.updateTabIndicator();
}
}
}

View File

@ -0,0 +1,192 @@
import * as interfaces from './interfaces/index.js';
import {
DeesElement,
type TemplateResult,
property,
customElement,
html,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import './dees-appui-tabs.js';
import type { DeesAppuiTabs } from './dees-appui-tabs.js';
export interface IAppViewTab extends interfaces.ITab {
content?: TemplateResult | (() => TemplateResult);
}
export interface IAppView {
id: string;
name: string;
description?: string;
iconName?: string;
tabs: IAppViewTab[];
menuItems?: interfaces.ISelectionOption[];
}
@customElement('dees-appui-view')
export class DeesAppuiView extends DeesElement {
public static demo = () => html`
<dees-appui-view
.viewConfig=${{
id: 'demo-view',
name: 'Demo View',
description: 'A demonstration view',
iconName: 'home',
tabs: [
{
key: 'overview',
iconName: 'chart-line',
action: () => console.log('Overview tab'),
content: html`<div style="padding: 20px;">Overview Content</div>`
},
{
key: 'details',
iconName: 'file-alt',
action: () => console.log('Details tab'),
content: html`<div style="padding: 20px;">Details Content</div>`
}
],
menuItems: [
{ key: 'General', action: () => console.log('General') },
{ key: 'Advanced', action: () => console.log('Advanced') },
]
}}
></dees-appui-view>
`;
// INSTANCE
@property({ type: Object })
public viewConfig: IAppView;
@state()
private selectedTab: IAppViewTab | null = null;
@state()
private tabs: DeesAppuiTabs;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
background: #161616;
}
.view-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.view-header {
background: #000000;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.view-content {
flex: 1;
position: relative;
overflow: hidden;
}
.tab-content {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: auto;
opacity: 0;
transition: opacity 0.2s;
}
.tab-content.active {
opacity: 1;
}
dees-appui-tabs {
height: 60px;
}
`,
];
public render(): TemplateResult {
if (!this.viewConfig) {
return html`<div>No view configuration provided</div>`;
}
return html`
<div class="view-container">
<div class="view-header">
<dees-appui-tabs
.tabs=${this.viewConfig.tabs}
.selectedTab=${this.selectedTab}
@tab-select=${(e: CustomEvent) => this.handleTabSelect(e)}
></dees-appui-tabs>
</div>
<div class="view-content">
${this.viewConfig.tabs.map((tab) => {
const isActive = tab === this.selectedTab;
const content = typeof tab.content === 'function' ? tab.content() : tab.content;
return html`
<div class="tab-content ${isActive ? 'active' : ''}">
${content || html`<slot name="${tab.key}"></slot>`}
</div>
`;
})}
</div>
</div>
`;
}
async firstUpdated() {
this.tabs = this.shadowRoot.querySelector('dees-appui-tabs');
if (this.viewConfig?.tabs?.length > 0) {
this.selectedTab = this.viewConfig.tabs[0];
}
}
private handleTabSelect(e: CustomEvent) {
this.selectedTab = e.detail.tab;
// Re-emit the event with view context
this.dispatchEvent(new CustomEvent('view-tab-select', {
detail: {
view: this.viewConfig,
tab: e.detail.tab
},
bubbles: true,
composed: true
}));
}
// Public methods for external control
public selectTab(tabKey: string) {
const tab = this.viewConfig.tabs.find(t => t.key === tabKey);
if (tab) {
this.selectedTab = tab;
if (this.tabs) {
this.tabs.selectedTab = tab;
}
}
}
public getMenuItems(): interfaces.ISelectionOption[] {
return this.viewConfig?.menuItems || [];
}
public getTabs(): IAppViewTab[] {
return this.viewConfig?.tabs || [];
}
}

View File

@ -0,0 +1,114 @@
import { html, css } from '@design.estate/dees-element';
export const demoFunc = () => {
return html`
<style>
${css`
.demoBox {
background: #000000;
padding: 40px;
min-height: 100vh;
box-sizing: border-box;
}
.demo-section {
margin-bottom: 32px;
}
.demo-title {
color: #fff;
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
font-family: 'Geist Sans', sans-serif;
}
.demo-description {
color: #999;
font-size: 14px;
margin-bottom: 24px;
font-family: 'Geist Sans', sans-serif;
}
`}
</style>
<div class="demoBox">
<div class="demo-section">
<h2 class="demo-title">Basic Button Groups</h2>
<p class="demo-description">Button groups without labels for simple grouping</p>
<dees-button-group>
<dees-button>Option 1</dees-button>
<dees-button>Option 2</dees-button>
<dees-button>Option 3</dees-button>
</dees-button-group>
</div>
<div class="demo-section">
<h2 class="demo-title">Labeled Button Groups</h2>
<p class="demo-description">Button groups with descriptive labels</p>
<dees-button-group label="View Mode:">
<dees-button type="highlighted">Grid</dees-button>
<dees-button>List</dees-button>
<dees-button>Cards</dees-button>
</dees-button-group>
</div>
<div class="demo-section">
<h2 class="demo-title">Multiple Groups</h2>
<p class="demo-description">Multiple button groups used together</p>
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
<dees-button-group label="Dataset:">
<dees-button type="highlighted">System</dees-button>
<dees-button>Network</dees-button>
<dees-button>Sales</dees-button>
</dees-button-group>
<dees-button-group label="Time Range:">
<dees-button>1H</dees-button>
<dees-button type="highlighted">24H</dees-button>
<dees-button>7D</dees-button>
<dees-button>30D</dees-button>
</dees-button-group>
<dees-button-group label="Actions:">
<dees-button>Refresh</dees-button>
<dees-button>Export</dees-button>
</dees-button-group>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Vertical Button Groups</h2>
<p class="demo-description">Button groups with vertical layout</p>
<div style="display: flex; gap: 24px;">
<dees-button-group direction="vertical" label="Navigation:">
<dees-button>Dashboard</dees-button>
<dees-button type="highlighted">Analytics</dees-button>
<dees-button>Reports</dees-button>
<dees-button>Settings</dees-button>
</dees-button-group>
<dees-button-group direction="vertical">
<dees-button>Add Item</dees-button>
<dees-button>Edit Item</dees-button>
<dees-button>Delete Item</dees-button>
</dees-button-group>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Mixed Button Types</h2>
<p class="demo-description">Different button types within groups</p>
<dees-button-group label="Status:">
<dees-button type="success">Active</dees-button>
<dees-button>Pending</dees-button>
<dees-button type="danger">Inactive</dees-button>
</dees-button-group>
</div>
</div>
`;
};

View File

@ -0,0 +1,83 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
property,
type TemplateResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { demoFunc } from './dees-button-group.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-button-group': DeesButtonGroup;
}
}
@customElement('dees-button-group')
export class DeesButtonGroup extends DeesElement {
public static demo = demoFunc;
@property()
public label: string = '';
@property()
public direction: 'horizontal' | 'vertical' = 'horizontal';
constructor() {
super();
domtools.elementBasic.setup();
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: inline-block;
}
.button-group {
display: flex;
gap: 8px;
align-items: center;
padding: 8px;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
border-radius: 6px;
}
.button-group.vertical {
flex-direction: column;
align-items: stretch;
}
.label {
color: ${cssManager.bdTheme('#666', '#999')};
font-size: 12px;
font-family: 'Geist Sans', sans-serif;
margin-right: 8px;
white-space: nowrap;
}
.button-group.vertical .label {
margin-right: 0;
margin-bottom: 8px;
}
::slotted(*) {
margin: 0 !important;
}
`,
];
public render(): TemplateResult {
return html`
<div class="button-group ${this.direction}">
${this.label ? html`<span class="label">${this.label}</span>` : ''}
<slot></slot>
</div>
`;
}
}

View File

@ -1,6 +1,42 @@
import { html } from '@design.estate/dees-element';
import { html, css } from '@design.estate/dees-element';
export const demoFunc = () => html`
<style>
${css`
h3 {
margin-top: 32px;
margin-bottom: 16px;
color: #333;
}
@media (prefers-color-scheme: dark) {
h3 {
color: #ccc;
}
}
.form-demo {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
@media (prefers-color-scheme: dark) {
.form-demo {
background: #1a1a1a;
}
}
.button-group {
display: flex;
gap: 16px;
margin: 20px 0;
}
`}
</style>
<h3>Button Types</h3>
<dees-button>This is a slotted Text</dees-button>
<p>
<dees-button text="Highlighted: This text shows" type="highlighted">Highlighted</dees-button>
@ -8,8 +44,34 @@ export const demoFunc = () => html`
<p><dees-button type="discreet">This is discreete button</dees-button></p>
<p><dees-button disabled>This is a disabled button</dees-button></p>
<p><dees-button type="big">This is a slotted Text</dees-button></p>
<h3>Button States</h3>
<p><dees-button status="normal">Normal Status</dees-button></p>
<p><dees-button disabled status="pending">Pending Status</dees-button></p>
<p><dees-button disabled status="success">Success Status</dees-button></p>
<p><dees-button disabled status="error">Error Status</dees-button></p>
<h3>Buttons in Forms (Auto-spacing)</h3>
<div class="form-demo">
<dees-form>
<dees-input-text label="Name" key="name"></dees-input-text>
<dees-input-text label="Email" key="email"></dees-input-text>
<dees-button>Save Draft</dees-button>
<dees-button type="highlighted">Save and Continue</dees-button>
<dees-form-submit>Submit Form</dees-form-submit>
</dees-form>
</div>
<h3>Buttons Outside Forms (No auto-spacing)</h3>
<div class="button-group">
<dees-button>Button 1</dees-button>
<dees-button>Button 2</dees-button>
<dees-button>Button 3</dees-button>
</div>
<h3>Manual Form Spacing</h3>
<div>
<dees-button inside-form="true">Manually spaced button 1</dees-button>
<dees-button inside-form="true">Manually spaced button 2</dees-button>
</div>
`;

View File

@ -55,10 +55,24 @@ export class DeesButton extends DeesElement {
})
public status: 'normal' | 'pending' | 'success' | 'error' = 'normal';
@property({
type: Boolean,
reflect: true
})
public insideForm: boolean = false;
constructor() {
super();
}
public async connectedCallback() {
await super.connectedCallback();
// Auto-detect if inside a form
if (!this.insideForm && this.closest('dees-form')) {
this.insideForm = true;
}
}
public static styles = [
cssManager.defaultStyles,
css`
@ -71,6 +85,27 @@ export class DeesButton extends DeesElement {
display: none;
}
/* Form spacing styles */
/* Default vertical form layout */
:host([inside-form]) {
margin-bottom: 16px; /* Using standard 16px like inputs */
}
:host([inside-form]:last-child) {
margin-bottom: 0;
}
/* Horizontal form layout - auto-detected via parent */
dees-form[horizontal-layout] :host([inside-form]) {
display: inline-block;
margin-right: 16px;
margin-bottom: 0;
}
dees-form[horizontal-layout] :host([inside-form]:last-child) {
margin-right: 0;
}
.button {
transition: all 0.1s , color 0s;
position: relative;
@ -181,7 +216,7 @@ export class DeesButton extends DeesElement {
${this.status === 'normal' ? html``: html`
<dees-spinner .bnw=${true} status="${this.status}"></dees-spinner>
`}
<div class="textbox">${this.text ? this.text : this.textContent}</div>
<div class="textbox">${this.text || html`<slot>Button</slot>`}</div>
</div>
`;
}
@ -202,9 +237,6 @@ export class DeesButton extends DeesElement {
}
public async firstUpdated() {
if (!this.textContent) {
this.textContent = 'Button';
this.performUpdate();
}
// Don't set default text here as it interferes with slotted content
}
}

View File

@ -1,8 +1,405 @@
import { html } from '@design.estate/dees-element';
import { html, css } from '@design.estate/dees-element';
import type { DeesChartArea } from './dees-chart-area.js';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => {
// Initial dataset values
const initialDatasets = {
system: {
label: 'System Usage (%)',
series: [
{
name: 'CPU',
data: [
{ x: new Date(Date.now() - 300000).toISOString(), y: 25 },
{ x: new Date(Date.now() - 240000).toISOString(), y: 30 },
{ x: new Date(Date.now() - 180000).toISOString(), y: 28 },
{ x: new Date(Date.now() - 120000).toISOString(), y: 35 },
{ x: new Date(Date.now() - 60000).toISOString(), y: 32 },
{ x: new Date().toISOString(), y: 38 },
],
},
{
name: 'Memory',
data: [
{ x: new Date(Date.now() - 300000).toISOString(), y: 45 },
{ x: new Date(Date.now() - 240000).toISOString(), y: 48 },
{ x: new Date(Date.now() - 180000).toISOString(), y: 46 },
{ x: new Date(Date.now() - 120000).toISOString(), y: 52 },
{ x: new Date(Date.now() - 60000).toISOString(), y: 50 },
{ x: new Date().toISOString(), y: 55 },
],
},
],
},
};
const initialFormatters = {
system: (val: number) => `${val}%`,
};
return html`
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
// Get the chart elements
const chartElement = elementArg.querySelector('#main-chart') as DeesChartArea;
const connectionsChartElement = elementArg.querySelector('#connections-chart') as DeesChartArea;
let intervalId: number;
let connectionsIntervalId: number;
let currentDataset = 'system';
// Y-axis formatters for different datasets
const formatters = {
system: (val: number) => `${val}%`,
network: (val: number) => `${val} Mbps`,
sales: (val: number) => `$${val.toLocaleString()}`,
};
// Time window configuration (in milliseconds)
const TIME_WINDOW = 2 * 60 * 1000; // 2 minutes
const UPDATE_INTERVAL = 1000; // 1 second
const DATA_POINT_INTERVAL = 5000; // Show data points every 5 seconds
// Store previous values for smooth transitions
let previousValues = {
cpu: 30,
memory: 50,
download: 150,
upload: 30,
connections: 150
};
// Generate initial data points for time window
const generateInitialData = (baseValue: number, variance: number, interval: number = DATA_POINT_INTERVAL) => {
const data = [];
const now = Date.now();
const pointCount = Math.floor(TIME_WINDOW / interval);
for (let i = pointCount; i >= 0; i--) {
const timestamp = new Date(now - (i * interval)).toISOString();
const value = baseValue + (Math.random() - 0.5) * variance;
data.push({ x: timestamp, y: Math.round(value) });
}
return data;
};
// Different datasets to showcase
const datasets = {
system: {
label: 'System Usage (%)',
series: [
{
name: 'CPU',
data: generateInitialData(previousValues.cpu, 10),
},
{
name: 'Memory',
data: generateInitialData(previousValues.memory, 8),
},
],
},
network: {
label: 'Network Traffic (Mbps)',
series: [
{
name: 'Download',
data: generateInitialData(previousValues.download, 30),
},
{
name: 'Upload',
data: generateInitialData(previousValues.upload, 10),
},
],
},
sales: {
label: 'Sales Analytics',
series: [
{
name: 'Revenue',
data: [
{ x: '2025-01-01', y: 45000 },
{ x: '2025-01-02', y: 52000 },
{ x: '2025-01-03', y: 48000 },
{ x: '2025-01-04', y: 61000 },
{ x: '2025-01-05', y: 58000 },
{ x: '2025-01-06', y: 65000 },
],
},
{
name: 'Profit',
data: [
{ x: '2025-01-01', y: 12000 },
{ x: '2025-01-02', y: 14000 },
{ x: '2025-01-03', y: 11000 },
{ x: '2025-01-04', y: 18000 },
{ x: '2025-01-05', y: 16000 },
{ x: '2025-01-06', y: 20000 },
],
},
],
},
};
// Generate smooth value transitions
const getNextValue = (current: number, min: number, max: number, maxChange: number = 5) => {
// Add some randomness but keep it close to current value
const change = (Math.random() - 0.5) * maxChange * 2;
let newValue = current + change;
// Apply some "pressure" to move towards center of range
const center = (min + max) / 2;
const pressure = (center - newValue) * 0.1;
newValue += pressure;
// Ensure within bounds
newValue = Math.max(min, Math.min(max, newValue));
return Math.round(newValue);
};
// Track time of last data point
let lastDataPointTime = Date.now();
let connectionsLastUpdate = Date.now();
// Add real-time data
const addRealtimeData = () => {
if (!chartElement) return;
const now = Date.now();
// Only add new data point every DATA_POINT_INTERVAL
const shouldAddPoint = (now - lastDataPointTime) >= DATA_POINT_INTERVAL;
if (shouldAddPoint) {
lastDataPointTime = now;
const newTimestamp = new Date(now).toISOString();
// Generate smooth transitions for new values
if (currentDataset === 'system') {
// Generate new values
previousValues.cpu = getNextValue(previousValues.cpu, 20, 50, 3);
previousValues.memory = getNextValue(previousValues.memory, 40, 70, 2);
// Get current data and add new points
const currentSeries = chartElement.chartSeries.map((series, index) => ({
name: series.name,
data: [
...(series.data as Array<{x: any; y: any}>),
index === 0
? { x: newTimestamp, y: previousValues.cpu }
: { x: newTimestamp, y: previousValues.memory }
]
}));
chartElement.updateSeries(currentSeries, false);
} else if (currentDataset === 'network') {
// Generate new values
previousValues.download = getNextValue(previousValues.download, 100, 200, 10);
previousValues.upload = getNextValue(previousValues.upload, 20, 50, 5);
// Get current data and add new points
const currentSeries = chartElement.chartSeries.map((series, index) => ({
name: series.name,
data: [
...(series.data as Array<{x: any; y: any}>),
index === 0
? { x: newTimestamp, y: previousValues.download }
: { x: newTimestamp, y: previousValues.upload }
]
}));
chartElement.updateSeries(currentSeries, false);
}
}
};
// Update connections chart data
const updateConnections = () => {
if (!connectionsChartElement) return;
const now = Date.now();
const newTimestamp = new Date(now).toISOString();
// Generate new connections value with discrete changes
const change = Math.floor(Math.random() * 21) - 10; // -10 to +10 connections
previousValues.connections = Math.max(50, Math.min(300, previousValues.connections + change));
// Get current data and add new point
const currentSeries = connectionsChartElement.chartSeries;
const newData = [{
name: currentSeries[0]?.name || 'Connections',
data: [
...(currentSeries[0]?.data as Array<{x: any; y: any}> || []),
{ x: newTimestamp, y: previousValues.connections }
]
}];
connectionsChartElement.updateSeries(newData, false);
};
// Switch dataset
const switchDataset = (name: string) => {
currentDataset = name;
const dataset = datasets[name];
chartElement.label = dataset.label;
chartElement.series = dataset.series;
chartElement.yAxisFormatter = formatters[name];
// Set appropriate y-axis scaling
if (name === 'system') {
chartElement.yAxisScaling = 'percentage';
chartElement.yAxisMax = 100;
} else if (name === 'network') {
chartElement.yAxisScaling = 'dynamic';
} else {
chartElement.yAxisScaling = 'dynamic';
}
// Reset last data point time to get fresh data immediately
lastDataPointTime = Date.now() - DATA_POINT_INTERVAL;
};
// Start/stop real-time updates
const startRealtime = () => {
if (!intervalId && (currentDataset === 'system' || currentDataset === 'network')) {
chartElement.realtimeMode = true;
// Only add data every 5 seconds, chart auto-scrolls independently
intervalId = window.setInterval(() => addRealtimeData(), DATA_POINT_INTERVAL);
}
// Start connections updates
if (!connectionsIntervalId) {
connectionsChartElement.realtimeMode = true;
// Update connections every second
connectionsIntervalId = window.setInterval(() => updateConnections(), UPDATE_INTERVAL);
}
};
const stopRealtime = () => {
if (intervalId) {
window.clearInterval(intervalId);
intervalId = null;
chartElement.realtimeMode = false;
}
// Stop connections updates
if (connectionsIntervalId) {
window.clearInterval(connectionsIntervalId);
connectionsIntervalId = null;
connectionsChartElement.realtimeMode = false;
}
};
// Randomize current data (spike/drop simulation)
const randomizeData = () => {
if (currentDataset === 'system') {
// Simulate CPU/Memory spike
previousValues.cpu = Math.random() > 0.5 ? 85 : 25;
previousValues.memory = Math.random() > 0.5 ? 80 : 45;
} else if (currentDataset === 'network') {
// Simulate network traffic spike
previousValues.download = Math.random() > 0.5 ? 250 : 100;
previousValues.upload = Math.random() > 0.5 ? 80 : 20;
}
// Also spike connections
previousValues.connections = Math.random() > 0.5 ? 280 : 80;
// Force immediate update by resetting timers
lastDataPointTime = 0;
connectionsLastUpdate = 0;
};
// Wire up button click handlers
const buttons = elementArg.querySelectorAll('dees-button');
buttons.forEach(button => {
const text = button.textContent?.trim();
if (text === 'System Usage') {
button.addEventListener('click', () => switchDataset('system'));
} else if (text === 'Network Traffic') {
button.addEventListener('click', () => switchDataset('network'));
} else if (text === 'Sales Data') {
button.addEventListener('click', () => switchDataset('sales'));
} else if (text === 'Start Live') {
button.addEventListener('click', () => startRealtime());
} else if (text === 'Stop Live') {
button.addEventListener('click', () => stopRealtime());
} else if (text === 'Spike Values') {
button.addEventListener('click', () => randomizeData());
}
});
// Update button states based on current dataset
const updateButtonStates = () => {
const buttons = elementArg.querySelectorAll('dees-button');
buttons.forEach(button => {
const text = button.textContent?.trim();
if (text === 'System Usage') {
button.type = currentDataset === 'system' ? 'highlighted' : 'normal';
} else if (text === 'Network Traffic') {
button.type = currentDataset === 'network' ? 'highlighted' : 'normal';
} else if (text === 'Sales Data') {
button.type = currentDataset === 'sales' ? 'highlighted' : 'normal';
}
});
};
// Configure main chart with rolling window
chartElement.rollingWindow = TIME_WINDOW;
chartElement.realtimeMode = false; // Will be enabled when starting live updates
chartElement.yAxisScaling = 'percentage'; // Initial system dataset uses percentage
chartElement.yAxisMax = 100;
chartElement.autoScrollInterval = 1000; // Auto-scroll every second
// Set initial time window
setTimeout(() => {
chartElement.updateTimeWindow();
}, 100);
// Update button states when dataset changes
const originalSwitchDataset = switchDataset;
const switchDatasetWithButtonUpdate = (name: string) => {
originalSwitchDataset(name);
updateButtonStates();
};
// Replace switchDataset with the one that updates buttons
buttons.forEach(button => {
const text = button.textContent?.trim();
if (text === 'System Usage') {
button.removeEventListener('click', () => switchDataset('system'));
button.addEventListener('click', () => switchDatasetWithButtonUpdate('system'));
} else if (text === 'Network Traffic') {
button.removeEventListener('click', () => switchDataset('network'));
button.addEventListener('click', () => switchDatasetWithButtonUpdate('network'));
} else if (text === 'Sales Data') {
button.removeEventListener('click', () => switchDataset('sales'));
button.addEventListener('click', () => switchDatasetWithButtonUpdate('sales'));
}
});
// Initialize connections chart with data
if (connectionsChartElement) {
const initialConnectionsData = generateInitialData(previousValues.connections, 30, UPDATE_INTERVAL);
connectionsChartElement.series = [{
name: 'Connections',
data: initialConnectionsData
}];
// Configure connections chart
connectionsChartElement.rollingWindow = TIME_WINDOW;
connectionsChartElement.realtimeMode = false; // Will be enabled when starting live updates
connectionsChartElement.yAxisScaling = 'fixed';
connectionsChartElement.yAxisMax = 350;
connectionsChartElement.autoScrollInterval = 1000; // Auto-scroll every second
// Set initial time window
setTimeout(() => {
connectionsChartElement.updateTimeWindow();
}, 100);
}
}}>
<style>
${css`
.demoBox {
position: relative;
background: #000000;
@ -10,12 +407,77 @@ export const demoFunc = () => {
width: 100%;
padding: 40px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 24px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
}
.chart-container {
flex: 1;
min-height: 400px;
}
.info {
color: #666;
font-size: 11px;
font-family: 'Geist Sans', sans-serif;
text-align: center;
margin-top: 8px;
}
`}
</style>
<div class="demoBox">
<div class="controls">
<dees-button-group label="Dataset:">
<dees-button type="highlighted">System Usage</dees-button>
<dees-button>Network Traffic</dees-button>
<dees-button>Sales Data</dees-button>
</dees-button-group>
<dees-button-group label="Real-time:">
<dees-button>Start Live</dees-button>
<dees-button>Stop Live</dees-button>
</dees-button-group>
<dees-button-group label="Actions:">
<dees-button>Spike Values</dees-button>
</dees-button-group>
</div>
<div class="chart-container">
<dees-chart-area
.label=${'System Usage'}
id="main-chart"
.label=${initialDatasets.system.label}
.series=${initialDatasets.system.series}
.yAxisFormatter=${initialFormatters.system}
></dees-chart-area>
</div>
<div class="chart-container" style="margin-top: 20px;">
<dees-chart-area
id="connections-chart"
.label=${'Active Connections'}
.series=${[{
name: 'Connections',
data: [] as Array<{x: any; y: any}>
}]}
.yAxisFormatter=${(val: number) => `${val}`}
></dees-chart-area>
</div>
<div class="info">
Real-time monitoring with 2-minute rolling window •
Updates every second with smooth value transitions •
Click 'Spike Values' to simulate load spikes
</div>
</div>
</dees-demowrapper>
`;
};

View File

@ -6,7 +6,6 @@ import {
html,
property,
state,
type CSSResult,
type TemplateResult,
} from '@design.estate/dees-element';
@ -32,29 +31,71 @@ export class DeesChartArea extends DeesElement {
@property()
public label: string = 'Untitled Chart';
@property({ type: Array })
public series: ApexAxisChartSeries = [];
// Override getter to return internal chart data
get chartSeries(): ApexAxisChartSeries {
return this.internalChartData.length > 0 ? this.internalChartData : this.series;
}
@property({ attribute: false })
public yAxisFormatter: (value: number) => string = (val) => `${val} Mbps`;
@property({ type: Number })
public rollingWindow: number = 0; // 0 means no rolling window
@property({ type: Boolean })
public realtimeMode: boolean = false;
@property({ type: String })
public yAxisScaling: 'fixed' | 'dynamic' | 'percentage' = 'dynamic';
@property({ type: Number })
public yAxisMax: number = 100; // Used when yAxisScaling is 'fixed' or 'percentage'
@property({ type: Number })
public autoScrollInterval: number = 1000; // Auto-scroll interval in milliseconds (0 to disable)
private resizeObserver: ResizeObserver;
private resizeTimeout: number;
private internalChartData: ApexAxisChartSeries = [];
private autoScrollTimer: number | null = null;
constructor() {
super();
domtools.elementBasic.setup();
this.resizeObserver = new ResizeObserver((entries) => {
// Debounce resize calls to prevent excessive updates
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
this.resizeTimeout = window.setTimeout(() => {
for (let entry of entries) {
if (entry.target.classList.contains('mainbox')) {
this.resizeChart(); // Call resizeChart when the .mainbox size changes
if (entry.target.classList.contains('mainbox') && this.chart) {
this.resizeChart();
}
}
}, 100); // 100ms debounce
});
this.registerStartupFunction(async () => {
this.updateComplete.then(() => {
const mainbox = this.shadowRoot.querySelector('.mainbox');
if (mainbox) {
this.resizeObserver.observe(mainbox); // Start observing the .mainbox element
this.resizeObserver.observe(mainbox);
}
});
});
this.registerGarbageFunction(async () => {
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
this.resizeObserver.disconnect();
this.stopAutoScroll();
});
}
@ -73,6 +114,7 @@ export class DeesChartArea extends DeesElement {
height: 400px;
background: #111;
border-radius: 8px;
overflow: hidden;
}
.chartTitle {
@ -82,6 +124,7 @@ export class DeesChartArea extends DeesElement {
width: 100%;
text-align: center;
padding-top: 16px;
z-index: 10;
}
.chartContainer {
position: absolute;
@ -90,6 +133,7 @@ export class DeesChartArea extends DeesElement {
bottom: 0px;
right: 0px;
padding: 32px 16px 16px 0px;
overflow: hidden;
}
`,
];
@ -104,9 +148,32 @@ export class DeesChartArea extends DeesElement {
}
public async firstUpdated() {
const domtoolsInstance = await this.domtoolsPromise;
var options: ApexCharts.ApexOptions = {
series: [
await this.domtoolsPromise;
// Wait for next animation frame to ensure layout is complete
await new Promise(resolve => requestAnimationFrame(resolve));
// Get actual dimensions of the container
const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox');
const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer');
if (!mainbox || !chartContainer) {
console.error('Chart containers not found');
return;
}
// Calculate initial dimensions
const styleChartContainer = window.getComputedStyle(chartContainer);
const paddingTop = parseInt(styleChartContainer.paddingTop, 10);
const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10);
const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10);
const paddingRight = parseInt(styleChartContainer.paddingRight, 10);
const initialWidth = mainbox.clientWidth - paddingLeft - paddingRight;
const initialHeight = mainbox.offsetHeight - paddingTop - paddingBottom;
// Use provided series data or default demo data
const chartSeries = this.series.length > 0 ? this.series : [
{
name: 'cpu',
data: [
@ -127,14 +194,32 @@ export class DeesChartArea extends DeesElement {
{ x: '2025-01-15T19:00:00', y: 40 },
],
},
],
];
// Store internal data
this.internalChartData = chartSeries;
var options: ApexCharts.ApexOptions = {
series: chartSeries,
chart: {
width: 0, // Adjusted for responsive width
height: 0, // Adjusted for responsive height
width: initialWidth || 100, // Use actual width or fallback
height: initialHeight || 100, // Use actual height or fallback
type: 'area',
toolbar: {
show: false, // This line disables the toolbar
},
animations: {
enabled: !this.realtimeMode, // Disable animations in realtime mode
speed: 400,
animateGradually: {
enabled: false, // Disable gradual animation for cleaner updates
delay: 0
},
dynamicAnimation: {
enabled: !this.realtimeMode,
speed: 350
}
},
},
dataLabels: {
enabled: false,
@ -146,10 +231,11 @@ export class DeesChartArea extends DeesElement {
xaxis: {
type: 'datetime', // Time-series data
labels: {
format: 'hh:mm A', // Time formatting
format: 'HH:mm:ss', // Time formatting with seconds
datetimeUTC: false,
style: {
colors: '#9e9e9e', // Label color
fontSize: '12px',
fontSize: '11px',
},
},
axisBorder: {
@ -161,10 +247,9 @@ export class DeesChartArea extends DeesElement {
},
yaxis: {
min: 0,
max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax,
labels: {
formatter: function (val: number) {
return `${val} Mbps`; // Format Y-axis labels
},
formatter: this.yAxisFormatter,
style: {
colors: '#9e9e9e', // Label color
fontSize: '12px',
@ -184,14 +269,11 @@ export class DeesChartArea extends DeesElement {
x: {
format: 'dd/MM/yy HH:mm',
},
custom: function ({ series, seriesIndex, dataPointIndex, w }) {
// Get the x value
const xValue = w.globals.labels[dataPointIndex];
custom: function ({ series, dataPointIndex, w }: any) {
// Iterate through each series and get its value
let tooltipContent = `<div style="padding: 10px; background: #1e1e2f; color: white; border-radius: 5px;">`;
tooltipContent += ``; // `<strong>Time:</strong> ${xValue}<br/>`;
series.forEach((s, index) => {
series.forEach((s: number[], index: number) => {
const label = w.globals.seriesNames[index]; // Get series label
const value = s[dataPointIndex]; // Get value at data point
tooltipContent += `<strong>${label}:</strong> ${value} Mbps<br/>`;
@ -235,15 +317,181 @@ export class DeesChartArea extends DeesElement {
};
this.chart = new ApexCharts(this.shadowRoot.querySelector('.chartContainer'), options);
await this.chart.render();
// Give the chart a moment to fully initialize before resizing
await new Promise(resolve => setTimeout(resolve, 100));
await this.resizeChart();
}
public async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
// Update chart if series data changes
if (changedProperties.has('series') && this.chart && this.series.length > 0) {
await this.updateSeries(this.series);
}
// Update y-axis formatter if it changes
if (changedProperties.has('yAxisFormatter') && this.chart) {
await this.chart.updateOptions({
yaxis: {
labels: {
formatter: this.yAxisFormatter,
},
},
});
}
// Handle realtime mode changes
if (changedProperties.has('realtimeMode') && this.chart) {
await this.chart.updateOptions({
chart: {
animations: {
enabled: !this.realtimeMode,
speed: 400,
animateGradually: {
enabled: false,
delay: 0
},
dynamicAnimation: {
enabled: !this.realtimeMode,
speed: 350
}
}
}
});
// Start/stop auto-scroll based on realtime mode
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
this.startAutoScroll();
} else {
this.stopAutoScroll();
}
}
// Handle auto-scroll interval changes
if (changedProperties.has('autoScrollInterval') && this.chart) {
this.stopAutoScroll();
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
this.startAutoScroll();
}
}
// Handle y-axis scaling changes
if ((changedProperties.has('yAxisScaling') || changedProperties.has('yAxisMax')) && this.chart) {
await this.chart.updateOptions({
yaxis: {
min: 0,
max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax
}
});
}
}
public async updateSeries(newSeries: ApexAxisChartSeries, animate: boolean = true) {
if (!this.chart) {
return;
}
// Store the new data first
this.internalChartData = newSeries;
// Handle rolling window if enabled
if (this.rollingWindow > 0 && this.realtimeMode) {
const now = Date.now();
const cutoffTime = now - this.rollingWindow;
// Filter data to only include points within the rolling window
const filteredSeries = newSeries.map(series => ({
name: series.name,
data: (series.data as any[]).filter(point => {
if (typeof point === 'object' && point !== null && 'x' in point) {
return new Date(point.x).getTime() > cutoffTime;
}
return false;
})
}));
// Only update if we have data
if (filteredSeries.some(s => s.data.length > 0)) {
// Handle y-axis scaling first
if (this.yAxisScaling === 'dynamic') {
const allValues = filteredSeries.flatMap(s => (s.data as any[]).map(d => d.y));
if (allValues.length > 0) {
const maxValue = Math.max(...allValues);
const dynamicMax = Math.ceil(maxValue * 1.1);
await this.chart.updateOptions({
yaxis: {
min: 0,
max: dynamicMax
}
}, false, false);
}
}
this.chart.updateSeries(filteredSeries, false);
}
} else {
this.chart.updateSeries(newSeries, animate);
}
}
// New method to update just the x-axis for smooth scrolling
public async updateTimeWindow() {
if (!this.chart || this.rollingWindow <= 0) {
return;
}
const now = Date.now();
const cutoffTime = now - this.rollingWindow;
await this.chart.updateOptions({
xaxis: {
min: cutoffTime,
max: now,
labels: {
format: 'HH:mm:ss',
datetimeUTC: false,
style: {
colors: '#9e9e9e',
fontSize: '11px',
},
},
tickAmount: 6,
}
}, false, false);
}
public async appendData(newData: { data: any[] }[]) {
if (!this.chart) {
return;
}
// Use ApexCharts' appendData method for smoother real-time updates
this.chart.appendData(newData);
}
public async updateOptions(options: ApexCharts.ApexOptions, redrawPaths?: boolean, animate?: boolean) {
if (!this.chart) {
return;
}
return this.chart.updateOptions(options, redrawPaths, animate);
}
public async resizeChart() {
if (!this.chart) {
return;
}
const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox');
const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer');
if (!mainbox || !chartContainer) {
return;
}
// Get computed style of the element
const styleMainbox = window.getComputedStyle(mainbox);
const styleChartContainer = window.getComputedStyle(chartContainer);
// Extract padding values
@ -263,4 +511,21 @@ export class DeesChartArea extends DeesElement {
},
});
}
private startAutoScroll() {
if (this.autoScrollTimer) {
return; // Already running
}
this.autoScrollTimer = window.setInterval(() => {
this.updateTimeWindow();
}, this.autoScrollInterval);
}
private stopAutoScroll() {
if (this.autoScrollTimer) {
window.clearInterval(this.autoScrollTimer);
this.autoScrollTimer = null;
}
}
}

View File

@ -1,7 +1,136 @@
import { html } from '@design.estate/dees-element';
import type { DeesChartLog } from './dees-chart-log.js';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => {
return html`
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
// Get the log element
const logElement = elementArg.querySelector('dees-chart-log') as DeesChartLog;
let intervalId: number;
const serverSources = ['Server', 'Database', 'API', 'Auth', 'Cache', 'Queue', 'WebSocket', 'Scheduler'];
const logTemplates = {
debug: [
'Loading module: {{module}}',
'Cache hit for key: {{key}}',
'SQL query executed in {{time}}ms',
'Request headers: {{headers}}',
'Environment variable loaded: {{var}}',
],
info: [
'Request received: {{method}} {{path}}',
'User {{userId}} authenticated successfully',
'Processing job {{jobId}} from queue',
'Scheduled task "{{task}}" started',
'WebSocket connection established from {{ip}}',
],
warn: [
'Slow query detected: {{query}} ({{time}}ms)',
'Memory usage at {{percent}}%',
'Rate limit approaching for IP {{ip}}',
'Deprecated API endpoint called: {{endpoint}}',
'Certificate expires in {{days}} days',
],
error: [
'Database connection lost: {{error}}',
'Failed to process request: {{error}}',
'Authentication failed for user {{user}}',
'File not found: {{path}}',
'Service unavailable: {{service}}',
],
success: [
'Server started successfully on port {{port}}',
'Database migration completed',
'Backup completed: {{size}} MB',
'SSL certificate renewed',
'Health check passed: all systems operational',
],
};
const generateRandomLog = () => {
const levels: Array<'debug' | 'info' | 'warn' | 'error' | 'success'> = ['debug', 'info', 'warn', 'error', 'success'];
const weights = [0.2, 0.5, 0.15, 0.1, 0.05]; // Weighted probability
const random = Math.random();
let cumulative = 0;
let level: typeof levels[0] = 'info';
for (let i = 0; i < weights.length; i++) {
cumulative += weights[i];
if (random < cumulative) {
level = levels[i];
break;
}
}
const source = serverSources[Math.floor(Math.random() * serverSources.length)];
const templates = logTemplates[level];
const template = templates[Math.floor(Math.random() * templates.length)];
// Replace placeholders with random values
const message = template
.replace('{{module}}', ['express', 'mongoose', 'redis', 'socket.io'][Math.floor(Math.random() * 4)])
.replace('{{key}}', 'user:' + Math.floor(Math.random() * 1000))
.replace('{{time}}', String(Math.floor(Math.random() * 500) + 50))
.replace('{{headers}}', 'Content-Type: application/json, Authorization: Bearer ...')
.replace('{{var}}', ['NODE_ENV', 'DATABASE_URL', 'API_KEY', 'PORT'][Math.floor(Math.random() * 4)])
.replace('{{method}}', ['GET', 'POST', 'PUT', 'DELETE'][Math.floor(Math.random() * 4)])
.replace('{{path}}', ['/api/users', '/api/auth/login', '/api/products', '/health'][Math.floor(Math.random() * 4)])
.replace('{{userId}}', String(Math.floor(Math.random() * 10000)))
.replace('{{jobId}}', 'job_' + Math.random().toString(36).substring(2, 11))
.replace('{{task}}', ['cleanup', 'backup', 'report-generation', 'cache-refresh'][Math.floor(Math.random() * 4)])
.replace('{{ip}}', `192.168.1.${Math.floor(Math.random() * 255)}`)
.replace('{{query}}', 'SELECT * FROM users WHERE ...')
.replace('{{percent}}', String(Math.floor(Math.random() * 30) + 70))
.replace('{{endpoint}}', '/api/v1/legacy')
.replace('{{days}}', String(Math.floor(Math.random() * 30) + 1))
.replace('{{error}}', ['ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND'][Math.floor(Math.random() * 3)])
.replace('{{user}}', 'user_' + Math.floor(Math.random() * 1000))
.replace('{{service}}', ['Redis', 'MongoDB', 'ElasticSearch'][Math.floor(Math.random() * 3)])
.replace('{{port}}', String(3000 + Math.floor(Math.random() * 10)))
.replace('{{size}}', String(Math.floor(Math.random() * 500) + 100));
logElement.addLog(level, message, source);
};
const startSimulation = () => {
if (!intervalId) {
// Generate logs at random intervals between 500ms and 2500ms
const scheduleNext = () => {
generateRandomLog();
const nextDelay = Math.random() * 2000 + 500;
intervalId = window.setTimeout(() => {
if (intervalId) {
scheduleNext();
}
}, nextDelay);
};
scheduleNext();
}
};
const stopSimulation = () => {
if (intervalId) {
window.clearTimeout(intervalId);
intervalId = null;
}
};
// Wire up button click handlers
const buttons = elementArg.querySelectorAll('dees-button');
buttons.forEach(button => {
const text = button.textContent?.trim();
if (text === 'Add Single Log') {
button.addEventListener('click', () => generateRandomLog());
} else if (text === 'Start Simulation') {
button.addEventListener('click', () => startSimulation());
} else if (text === 'Stop Simulation') {
button.addEventListener('click', () => stopSimulation());
}
});
}}>
<style>
.demoBox {
position: relative;
@ -9,12 +138,33 @@ export const demoFunc = () => {
height: 100%;
width: 100%;
padding: 40px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 20px;
}
.controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.info {
color: #888;
font-size: 12px;
font-family: 'Geist Sans', sans-serif;
}
</style>
<div class="demoBox">
<div class="controls">
<dees-button>Add Single Log</dees-button>
<dees-button>Start Simulation</dees-button>
<dees-button>Stop Simulation</dees-button>
</div>
<div class="info">Simulating realistic server logs with various levels and sources</div>
<dees-chart-log
.label=${'Event Log'}
.label=${'Production Server Logs'}
></dees-chart-log>
</div>
</dees-demowrapper>
`;
};

View File

@ -5,15 +5,12 @@ import {
customElement,
html,
property,
state,
type CSSResult,
type TemplateResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { demoFunc } from './dees-chart-log.demo.js';
import ApexCharts from 'apexcharts';
declare global {
interface HTMLElementTagNameMap {
@ -21,69 +18,303 @@ declare global {
}
}
export interface ILogEntry {
timestamp: string;
level: 'debug' | 'info' | 'warn' | 'error' | 'success';
message: string;
source?: string;
}
@customElement('dees-chart-log')
export class DeesChartLog extends DeesElement {
public static demo = demoFunc;
// instance
@state()
public chart: ApexCharts;
@property()
public label: string = 'Untitled Chart';
public label: string = 'Server Logs';
@property({ type: Array })
public logEntries: ILogEntry[] = [];
@property({ type: Boolean })
public autoScroll: boolean = true;
@property({ type: Number })
public maxEntries: number = 1000;
private logContainer: HTMLDivElement;
constructor() {
super();
domtools.elementBasic.setup();
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
font-family: 'Geist Sans', sans-serif;
font-family: 'Geist Mono', 'Consolas', 'Monaco', monospace;
color: #ccc;
font-weight: 600;
font-size: 12px;
line-height: 1.4;
}
.mainbox {
position: relative;
width: 100%;
height: 400px;
background: #222;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#dee2e6', '#333')};
border-radius: 8px;
padding: 32px 16px 16px 0px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chartTitle {
position: absolute;
top: 0;
left: 0;
width: 100%;
text-align: center;
padding-top: 16px;
.header {
background: ${cssManager.bdTheme('#e9ecef', '#1a1a1a')};
padding: 8px 16px;
border-bottom: 1px solid ${cssManager.bdTheme('#dee2e6', '#333')};
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.chartContainer {
position: relative;
width: 100%;
.title {
font-weight: 600;
color: ${cssManager.bdTheme('#212529', '#fff')};
}
.controls {
display: flex;
gap: 8px;
}
.control-button {
background: ${cssManager.bdTheme('#e9ecef', '#2a2a2a')};
border: 1px solid ${cssManager.bdTheme('#ced4da', '#444')};
border-radius: 4px;
padding: 4px 8px;
color: ${cssManager.bdTheme('#495057', '#ccc')};
cursor: pointer;
font-size: 11px;
transition: all 0.2s;
}
.control-button:hover {
background: ${cssManager.bdTheme('#dee2e6', '#3a3a3a')};
border-color: ${cssManager.bdTheme('#adb5bd', '#555')};
}
.control-button.active {
background: ${cssManager.bdTheme('#007bff', '#4a4a4a')};
color: ${cssManager.bdTheme('#fff', '#fff')};
}
.logContainer {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 16px;
font-size: 12px;
}
.logEntry {
margin-bottom: 2px;
display: flex;
white-space: pre-wrap;
word-break: break-all;
}
.timestamp {
color: ${cssManager.bdTheme('#6c757d', '#666')};
margin-right: 8px;
flex-shrink: 0;
}
.level {
margin-right: 8px;
padding: 0 6px;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
font-size: 10px;
flex-shrink: 0;
}
.level.debug {
color: ${cssManager.bdTheme('#6c757d', '#999')};
background: ${cssManager.bdTheme('rgba(108, 117, 125, 0.1)', '#333')};
}
.level.info {
color: ${cssManager.bdTheme('#0066cc', '#4a9eff')};
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.1)', 'rgba(74, 158, 255, 0.1)')};
}
.level.warn {
color: ${cssManager.bdTheme('#ff8800', '#ffb84a')};
background: ${cssManager.bdTheme('rgba(255, 136, 0, 0.1)', 'rgba(255, 184, 74, 0.1)')};
}
.level.error {
color: ${cssManager.bdTheme('#dc3545', '#ff4a4a')};
background: ${cssManager.bdTheme('rgba(220, 53, 69, 0.1)', 'rgba(255, 74, 74, 0.1)')};
}
.level.success {
color: ${cssManager.bdTheme('#28a745', '#4aff88')};
background: ${cssManager.bdTheme('rgba(40, 167, 69, 0.1)', 'rgba(74, 255, 136, 0.1)')};
}
.source {
color: ${cssManager.bdTheme('#6c757d', '#888')};
margin-right: 8px;
flex-shrink: 0;
}
.message {
color: ${cssManager.bdTheme('#212529', '#ddd')};
flex: 1;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: ${cssManager.bdTheme('#6c757d', '#666')};
font-style: italic;
}
/* Custom scrollbar */
.logContainer::-webkit-scrollbar {
width: 8px;
}
.logContainer::-webkit-scrollbar-track {
background: ${cssManager.bdTheme('#e9ecef', '#1a1a1a')};
}
.logContainer::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('#adb5bd', '#444')};
border-radius: 4px;
}
.logContainer::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('#6c757d', '#555')};
}
`,
];
public render(): TemplateResult {
return html` <div class="mainbox">
<div class="chartTitle">${this.label}</div>
<div class="chartContainer"></div>
</div> `;
return html`
<div class="mainbox">
<div class="header">
<div class="title">${this.label}</div>
<div class="controls">
<button
class="control-button ${this.autoScroll ? 'active' : ''}"
@click=${() => { this.autoScroll = !this.autoScroll; }}
>
Auto Scroll
</button>
<button
class="control-button"
@click=${() => { this.clearLogs(); }}
>
Clear
</button>
</div>
</div>
<div class="logContainer">
${this.logEntries.length === 0
? html`<div class="empty-state">No logs to display</div>`
: this.logEntries.map(entry => this.renderLogEntry(entry))
}
</div>
</div>
`;
}
private renderLogEntry(entry: ILogEntry): TemplateResult {
const timestamp = new Date(entry.timestamp).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
return html`
<div class="logEntry">
<span class="timestamp">${timestamp}</span>
<span class="level ${entry.level}">${entry.level}</span>
${entry.source ? html`<span class="source">[${entry.source}]</span>` : ''}
<span class="message">${entry.message}</span>
</div>
`;
}
public async firstUpdated() {
const domtoolsInstance = await this.domtoolsPromise;
await this.domtoolsPromise;
this.logContainer = this.shadowRoot.querySelector('.logContainer');
// Initialize with demo server logs
const demoLogs: ILogEntry[] = [
{ timestamp: new Date().toISOString(), level: 'info', message: 'Server started on port 3000', source: 'Server' },
{ timestamp: new Date().toISOString(), level: 'debug', message: 'Loading configuration from /etc/app/config.json', source: 'Config' },
{ timestamp: new Date().toISOString(), level: 'info', message: 'Connected to MongoDB at mongodb://localhost:27017', source: 'Database' },
{ timestamp: new Date().toISOString(), level: 'success', message: 'Database connection established successfully', source: 'Database' },
{ timestamp: new Date().toISOString(), level: 'warn', message: 'No SSL certificate found, using self-signed certificate', source: 'Security' },
{ timestamp: new Date().toISOString(), level: 'info', message: 'API routes initialized: GET /api/users, POST /api/users, DELETE /api/users/:id', source: 'Router' },
{ timestamp: new Date().toISOString(), level: 'debug', message: 'Middleware stack: cors, bodyParser, authentication, errorHandler', source: 'Middleware' },
{ timestamp: new Date().toISOString(), level: 'info', message: 'WebSocket server listening on ws://localhost:3001', source: 'WebSocket' },
];
this.logEntries = demoLogs;
this.scrollToBottom();
}
public async updateLog() {
public async updateLog(entries?: ILogEntry[]) {
if (entries) {
// Add new entries
this.logEntries = [...this.logEntries, ...entries];
// Trim if exceeds max entries
if (this.logEntries.length > this.maxEntries) {
this.logEntries = this.logEntries.slice(-this.maxEntries);
}
// Trigger re-render
this.requestUpdate();
// Auto-scroll if enabled
await this.updateComplete;
if (this.autoScroll) {
this.scrollToBottom();
}
}
}
public clearLogs() {
this.logEntries = [];
this.requestUpdate();
}
private scrollToBottom() {
if (this.logContainer) {
this.logContainer.scrollTop = this.logContainer.scrollHeight;
}
}
public addLog(level: ILogEntry['level'], message: string, source?: string) {
const newEntry: ILogEntry = {
timestamp: new Date().toISOString(),
level,
message,
source
};
this.updateLog([newEntry]);
}
}

View File

@ -9,49 +9,143 @@ export const demoFunc = () => html`
display: block;
margin: 20px;
}
.demo-container {
display: flex;
flex-direction: column;
gap: 20px;
padding: 40px;
background: #f5f5f5;
min-height: 400px;
}
.demo-area {
background: white;
padding: 40px;
border-radius: 8px;
border: 1px solid #e0e0e0;
text-align: center;
cursor: context-menu;
}
</style>
<dees-button @contextmenu=${(eventArg) => {
<div class="demo-container">
<div class="demo-area" @contextmenu=${(eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(eventArg, [
{
name: 'copy',
iconName: 'copySolid',
name: 'Cut',
iconName: 'scissors',
shortcut: 'Cmd+X',
action: async () => {
return null;
console.log('Cut action');
},
},
{
name: 'edit',
iconName: 'penToSquare',
name: 'Copy',
iconName: 'copy',
shortcut: 'Cmd+C',
action: async () => {
return null;
console.log('Copy action');
},
},{
name: 'paste',
iconName: 'pasteSolid',
},
{
name: 'Paste',
iconName: 'clipboard',
shortcut: 'Cmd+V',
action: async () => {
return null;
console.log('Paste action');
},
},
{ divider: true },
{
name: 'Delete',
iconName: 'trash2',
action: async () => {
console.log('Delete action');
},
},
{ divider: true },
{
name: 'Select All',
shortcut: 'Cmd+A',
action: async () => {
console.log('Select All action');
},
},
]);
}}>Right-Click for contextmenu</dees-button>
<dees-contextmenu class="withMargin"></dees-contextmenu>
}}>
<h3>Right-click anywhere in this area</h3>
<p>A context menu will appear with various options</p>
</div>
<dees-button @contextmenu=${(eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(eventArg, [
{
name: 'Button Action 1',
iconName: 'play',
action: async () => {
console.log('Button action 1');
},
},
{
name: 'Button Action 2',
iconName: 'pause',
action: async () => {
console.log('Button action 2');
},
},
{
name: 'Disabled Action',
iconName: 'ban',
disabled: true,
action: async () => {
console.log('This should not run');
},
},
{ divider: true },
{
name: 'Settings',
iconName: 'settings',
action: async () => {
console.log('Settings');
},
},
]);
}}>Right-click on this button for a different menu</dees-button>
<div style="margin-top: 20px;">
<h4>Static Context Menu (always visible):</h4>
<dees-contextmenu
class="withMargin"
.menuItems=${[
{
name: 'copy',
iconName: 'copySolid',
action: async () => {},
name: 'New File',
iconName: 'filePlus',
shortcut: 'Cmd+N',
action: async () => console.log('New file'),
},
{
name: 'edit',
iconName: 'penToSquare',
action: async () => {},
},{
name: 'paste',
iconName: 'pasteSolid',
action: async () => {},
name: 'Open File',
iconName: 'folderOpen',
shortcut: 'Cmd+O',
action: async () => console.log('Open file'),
},
] as plugins.tsclass.website.IMenuItem[]}
{
name: 'Save',
iconName: 'save',
shortcut: 'Cmd+S',
action: async () => console.log('Save'),
},
{ divider: true },
{
name: 'Export',
iconName: 'download',
action: async () => console.log('Export'),
},
{
name: 'Import',
iconName: 'upload',
action: async () => console.log('Import'),
},
]}
></dees-contextmenu>
</div>
</div>
`;

View File

@ -1,4 +1,3 @@
import * as colors from './00colors.js';
import * as plugins from './00plugins.js';
import { demoFunc } from './dees-contextmenu.demo.js';
import {
@ -15,6 +14,7 @@ import {
import * as domtools from '@design.estate/dees-domtools';
import { DeesWindowLayer } from './dees-windowlayer.js';
import './dees-icon.js';
declare global {
interface HTMLElementTagNameMap {
@ -30,7 +30,7 @@ export class DeesContextmenu extends DeesElement {
// STATIC
// This will store all the accumulated menu items
public static contextMenuDeactivated = false;
public static accumulatedMenuItems: plugins.tsclass.website.IMenuItem[] = [];
public static accumulatedMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] = [];
// Add a global event listener for the right-click context menu
public static initializeGlobalListener() {
@ -49,7 +49,13 @@ export class DeesContextmenu extends DeesElement {
// Traverse up the DOM tree to accumulate menu items
while (target) {
if ((target as any).getContextMenuItems) {
DeesContextmenu.accumulatedMenuItems.push(...(target as any).getContextMenuItems());
const items = (target as any).getContextMenuItems();
if (items && items.length > 0) {
if (DeesContextmenu.accumulatedMenuItems.length > 0) {
DeesContextmenu.accumulatedMenuItems.push({ divider: true });
}
DeesContextmenu.accumulatedMenuItems.push(...items);
}
}
target = (target as Node).parentNode;
}
@ -60,7 +66,7 @@ export class DeesContextmenu extends DeesElement {
}
// allows opening of a contextmenu with options
public static async openContextMenuWithOptions(eventArg: MouseEvent, menuItemsArg: plugins.tsclass.website.IMenuItem[]) {
public static async openContextMenuWithOptions(eventArg: MouseEvent, menuItemsArg: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[]) {
if (this.contextMenuDeactivated) {
return;
}
@ -68,32 +74,60 @@ export class DeesContextmenu extends DeesElement {
eventArg.stopPropagation();
const contextMenu = new DeesContextmenu();
contextMenu.style.position = 'fixed';
contextMenu.style.zIndex = '2000';
contextMenu.style.top = `${eventArg.clientY.toString()}px`;
contextMenu.style.left = `${eventArg.clientX.toString()}px`;
contextMenu.style.zIndex = '10000';
contextMenu.style.opacity = '0';
contextMenu.style.transform = 'scale(0.95,0.95)';
contextMenu.style.transformOrigin = 'top left';
contextMenu.style.transform = 'scale(0.95) translateY(-10px)';
contextMenu.menuItems = menuItemsArg;
contextMenu.windowLayer = await DeesWindowLayer.createAndShow();
contextMenu.windowLayer.addEventListener('click', async () => {
await contextMenu.destroy();
})
document.body.append(contextMenu);
// Get dimensions after adding to DOM
await domtools.plugins.smartdelay.delayFor(0);
const rect = contextMenu.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
// Calculate position
let top = eventArg.clientY;
let left = eventArg.clientX;
// Adjust if menu would go off right edge
if (left + rect.width > windowWidth) {
left = windowWidth - rect.width - 10;
}
// Adjust if menu would go off bottom edge
if (top + rect.height > windowHeight) {
top = windowHeight - rect.height - 10;
}
// Ensure menu doesn't go off left or top edge
if (left < 10) left = 10;
if (top < 10) top = 10;
contextMenu.style.top = `${top}px`;
contextMenu.style.left = `${left}px`;
contextMenu.style.transformOrigin = 'top left';
// Animate in
await domtools.plugins.smartdelay.delayFor(0);
contextMenu.style.opacity = '1';
contextMenu.style.transform = 'scale(1,1)';
contextMenu.style.transform = 'scale(1) translateY(0)';
}
// INSTANCE
@property({
type: Array,
})
public menuItems: plugins.tsclass.website.IMenuItem[] = [];
public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; divider?: never } | { divider: true })[] = [];
windowLayer: DeesWindowLayer;
constructor() {
super();
this.tabIndex = 0;
}
/**
@ -104,40 +138,70 @@ export class DeesContextmenu extends DeesElement {
css`
:host {
display: block;
transition: all 0.1s;
transition: opacity 0.2s, transform 0.2s;
outline: none;
}
.mainbox {
color: ${cssManager.bdTheme('#222', '#ccc')};
font-size: 14px;
width: 200px;
border: 1px solid ${cssManager.bdTheme('#fff', '#ffffff10')};
min-height: 34px;
border-radius: 3px;
background: ${cssManager.bdTheme('#fff', '#222')};
box-shadow: 0px 1px 4px ${cssManager.bdTheme('#00000020', '#000000')};
min-width: 200px;
max-width: 280px;
background: ${cssManager.bdTheme('#ffffff', '#000000')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
border-radius: 4px;
box-shadow: ${cssManager.bdTheme(
'0 4px 12px rgba(0, 0, 0, 0.15)',
'0 4px 12px rgba(0, 0, 0, 0.3)'
)};
user-select: none;
padding: 4px;
padding: 4px 0;
font-size: 12px;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.mainbox .menuitem {
padding: 4px 8px;
border-radius: 3px;
.menuitem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: default;
transition: background 0.1s;
line-height: 1;
}
.mainbox .menuitem dees-icon {
display: inline-block;
margin-right: 8px;
width: 14px;
transform: translateY(2px);
.menuitem:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
}
.mainbox .menuitem:hover {
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
.menuitem:active {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
}
.mainbox .menuitem:active {
background: #ffffff05;
.menuitem.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.menuitem dees-icon {
font-size: 14px;
opacity: 0.7;
}
.menuitem-text {
flex: 1;
}
.menuitem-shortcut {
font-size: 11px;
color: ${cssManager.bdTheme('#999', '#666')};
margin-left: auto;
opacity: 0.7;
}
.menu-divider {
height: 1px;
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
margin: 4px 0;
}
`,
];
@ -146,10 +210,20 @@ export class DeesContextmenu extends DeesElement {
return html`
<div class="mainbox">
${this.menuItems.map((menuItemArg) => {
if ('divider' in menuItemArg && menuItemArg.divider) {
return html`<div class="menu-divider"></div>`;
}
const menuItem = menuItemArg as plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean };
return html`
<div class="menuitem" @click=${() => this.handleClick(menuItemArg)}>
<dees-icon .iconFA=${(menuItemArg.iconName as any) || 'minus'}></dees-icon
>${menuItemArg.name}
<div class="menuitem ${menuItem.disabled ? 'disabled' : ''}" @click=${() => !menuItem.disabled && this.handleClick(menuItem)}>
${menuItem.iconName ? html`
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>
` : ''}
<span class="menuitem-text">${menuItem.name}</span>
${menuItem.shortcut ? html`
<span class="menuitem-shortcut">${menuItem.shortcut}</span>
` : ''}
</div>
`;
})}
@ -158,8 +232,8 @@ export class DeesContextmenu extends DeesElement {
DeesContextmenu.contextMenuDeactivated = true;
this.destroy();
}}>
<dees-icon .iconFA=${'xmark'}></dees-icon
>allow native context
<dees-icon .icon="lucide:x"></dees-icon>
<span class="menuitem-text">Allow native context</span>
</div>
` : html``}
</div>
@ -167,10 +241,45 @@ export class DeesContextmenu extends DeesElement {
}
public async firstUpdated() {
// Focus on the menu for keyboard navigation
this.focus();
// Add keyboard event listeners
this.addEventListener('keydown', this.handleKeydown);
}
public async handleClick(menuItem: plugins.tsclass.website.IMenuItem) {
private handleKeydown = (event: KeyboardEvent) => {
const menuItems = Array.from(this.shadowRoot.querySelectorAll('.menuitem:not(.disabled)'));
const currentIndex = menuItems.findIndex(item => item.matches(':hover'));
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
const nextIndex = currentIndex + 1 < menuItems.length ? currentIndex + 1 : 0;
(menuItems[nextIndex] as HTMLElement).dispatchEvent(new MouseEvent('mouseenter'));
break;
case 'ArrowUp':
event.preventDefault();
const prevIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : menuItems.length - 1;
(menuItems[prevIndex] as HTMLElement).dispatchEvent(new MouseEvent('mouseenter'));
break;
case 'Enter':
event.preventDefault();
if (currentIndex >= 0) {
(menuItems[currentIndex] as HTMLElement).click();
}
break;
case 'Escape':
event.preventDefault();
this.destroy();
break;
}
}
public async handleClick(menuItem: plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean }) {
menuItem.action();
await this.destroy();
}
@ -180,7 +289,7 @@ export class DeesContextmenu extends DeesElement {
this.windowLayer.destroy();
}
this.style.opacity = '0';
this.style.transform = 'scale(0.95,0,95)';
this.style.transform = 'scale(0.95) translateY(-10px)';
await domtools.plugins.smartdelay.delayFor(100);
this.parentElement.removeChild(this);
}

View File

@ -0,0 +1,3 @@
import { html } from '@design.estate/dees-element';
export const demoFunc = () => html`<dees-form-submit>Submit Form</dees-form-submit>`;

View File

@ -1,3 +1,4 @@
import { demoFunc } from './dees-form-submit.demo.js';
import {
customElement,
html,
@ -5,9 +6,8 @@ import {
css,
cssManager,
property,
type CSSResult,
} from '@design.estate/dees-element';
import { DeesForm } from './dees-form.js';
import type { DeesForm } from './dees-form.js';
declare global {
interface HTMLElementTagNameMap {
@ -17,7 +17,7 @@ declare global {
@customElement('dees-form-submit')
export class DeesFormSubmit extends DeesElement {
public static demo = () => html`<dees-form-submit>This is a sloted text</dees-form-submit>`;
public static demo = demoFunc;
@property({
type: Boolean,
@ -44,11 +44,11 @@ export class DeesFormSubmit extends DeesElement {
public render() {
return html`
<dees-button
status=${this.status}
@click=${this.submit}
.disabled=${this.disabled}
.text=${this.text ? this.text : this.textContent}
status="${this.status}"
@click="${this.submit}"
?disabled="${this.disabled}"
>
${this.text || html`<slot></slot>`}
</dees-button>
`;
}
@ -58,13 +58,15 @@ export class DeesFormSubmit extends DeesElement {
return;
}
const parentElement: DeesForm = this.parentElement as DeesForm;
if (parentElement && parentElement.gatherAndDispatch) {
parentElement.gatherAndDispatch();
}
}
public async focus() {
const domtools = await this.domtoolsPromise;
if (!this.disabled) {
domtools.convenience.smartdelay.delayFor(0);
await domtools.convenience.smartdelay.delayFor(0);
this.submit();
}
}

View File

@ -1,69 +1,187 @@
import { html, domtools, cssManager } from '@design.estate/dees-element';
import { html, css, domtools, cssManager } from '@design.estate/dees-element';
import type { DeesForm } from './dees-form.js';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
.demoContainer {
max-width: 400px;
margin: 24px auto;
padding: 16px;
background: ${cssManager.bdTheme('#eeeeeb', '#111')};
box-shadow: 0px 1px 3px #00000030;
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
`}
</style>
<div class="demoContainer">
<div class="demo-container">
<dees-panel .heading="Complete Form Example" .description="A comprehensive form with various input types, validation, and form submission handling">
<dees-form
style="display: block; margin:auto; max-width: 500px; padding: 20px"
@formData=${async (eventArg) => {
const form: DeesForm = eventArg.currentTarget;
form.setStatus('pending', 'authenticating...');
await domtools.plugins.smartdelay.delayFor(1000);
form.setStatus('success', 'authenticated!');
form.setStatus('pending', 'Processing...');
await domtools.plugins.smartdelay.delayFor(2000);
form.setStatus('success', 'Form submitted successfully!');
await domtools.plugins.smartdelay.delayFor(2000);
form.reset();
}}
>
<dees-input-text
.required=${true}
key="firstName"
label="First Name"
.description=${'Your given name'}
></dees-input-text>
<dees-input-text
.required=${true}
key="lastName"
label="Last Name"
></dees-input-text>
<dees-input-text
.required=${true}
key="email"
label="Email Address"
.description=${'We will use this to contact you'}
></dees-input-text>
<dees-input-dropdown
.label=${'title'}
.required=${true}
key="country"
.label=${'Country'}
.options=${[
{ option: 'option 1', key: 'option1' },
{ option: 'option 2', key: 'option2' },
{ option: 'option 3', key: 'option3' },
{ option: 'United States', key: 'us' },
{ option: 'Canada', key: 'ca' },
{ option: 'Germany', key: 'de' },
{ option: 'France', key: 'fr' },
{ option: 'United Kingdom', key: 'uk' },
]}
></dees-input-dropdown>
<dees-input-multiselect
.label=${'title'}
.options=${[
{ option: 'option 1', key: 'option1' },
{ option: 'option 2', key: 'option2' },
{ option: 'option 3', key: 'option3' },
]}></dees-input-multiselect>
<dees-input-typelist
.label=${'a type list'}
></dees-input-typelist>
<dees-input-text .required="${true}" key="hello1" label="a text" .description=${`
This is an awesome description.
`}></dees-input-text>
<dees-input-text .required="${true}" key="hello2" label="also a text"></dees-input-text>
<dees-input-text
.required="${true}"
key="hello3"
label="a password"
.required=${true}
key="password"
label="Password"
isPasswordBool
.description=${'Minimum 8 characters'}
></dees-input-text>
<dees-input-checkbox
.required="${true}"
key="hello3"
label="another text"
.required=${true}
key="terms"
label="I agree to the Terms and Conditions"
></dees-input-checkbox>
<dees-input-iban></dees-input-iban>
<dees-input-multitoggle
.label=${'multi select'}
.options=${['option 1', 'option 2', 'option 3']}
.selectedOption=${'option 1'}
></dees-input-multitoggle>
<dees-input-fileupload
.label=${'attachments'}
></dees-input-fileupload>
<dees-form-submit>Submit</dees-form-submit>
<dees-input-checkbox
key="newsletter"
label="Send me promotional emails"
.value=${true}
></dees-input-checkbox>
<dees-form-submit>Create Account</dees-form-submit>
</dees-form>
</dees-panel>
<dees-panel .heading="Horizontal Form Layout" .description="Compact form with inputs arranged horizontally - perfect for filters and quick forms">
<dees-form horizontal-layout>
<dees-input-text
key="search"
label="Search"
></dees-input-text>
<dees-input-dropdown
key="category"
.label=${'Category'}
.enableSearch=${false}
.options=${[
{ option: 'All', key: 'all' },
{ option: 'Products', key: 'products' },
{ option: 'Services', key: 'services' },
{ option: 'Support', key: 'support' },
]}
></dees-input-dropdown>
<dees-input-dropdown
key="sort"
.label=${'Sort By'}
.enableSearch=${false}
.options=${[
{ option: 'Newest', key: 'newest' },
{ option: 'Popular', key: 'popular' },
{ option: 'Price: Low to High', key: 'price_asc' },
{ option: 'Price: High to Low', key: 'price_desc' },
]}
></dees-input-dropdown>
<dees-input-checkbox
key="inStock"
label="In Stock Only"
.value=${true}
></dees-input-checkbox>
</dees-form>
</dees-panel>
<dees-panel .heading="Advanced Form Features" .description="Form with specialized input types and complex validation">
<dees-form
@formData=${async (eventArg) => {
const form: DeesForm = eventArg.currentTarget;
const data = eventArg.detail.data;
console.log('Form data:', data);
form.setStatus('success', 'Data logged to console!');
}}
>
<dees-input-iban
key="iban"
label="IBAN"
.required=${true}
></dees-input-iban>
<dees-input-phone
key="phone"
label="Phone Number"
.required=${true}
></dees-input-phone>
<dees-input-multitoggle
key="preferences"
.label=${'Notification Preferences'}
.options=${['Email', 'SMS', 'Push', 'In-App']}
.selectedOption=${'Email'}
></dees-input-multitoggle>
<dees-input-multiselect
key="interests"
.label=${'Areas of Interest'}
.options=${[
{ option: 'Technology', key: 'tech' },
{ option: 'Design', key: 'design' },
{ option: 'Business', key: 'business' },
{ option: 'Marketing', key: 'marketing' },
{ option: 'Sales', key: 'sales' },
]}
></dees-input-multiselect>
<dees-input-fileupload
key="documents"
.label=${'Upload Documents'}
.description=${'PDF, DOC, or DOCX files up to 10MB'}
></dees-input-fileupload>
<dees-form-submit>Submit Application</dees-form-submit>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -4,6 +4,7 @@ import {
type TemplateResult,
DeesElement,
type CSSResult,
property,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
@ -11,27 +12,42 @@ import { DeesInputCheckbox } from './dees-input-checkbox.js';
import { DeesInputText } from './dees-input-text.js';
import { DeesInputQuantitySelector } from './dees-input-quantityselector.js';
import { DeesInputRadio } from './dees-input-radio.js';
import { DeesInputDropdown } from './dees-input-dropdown.js';
import { DeesInputFileupload } from './dees-input-fileupload.js';
import { DeesInputIban } from './dees-input-iban.js';
import { DeesInputMultitoggle } from './dees-input-multitoggle.js';
import { DeesInputPhone } from './dees-input-phone.js';
import { DeesInputTypelist } from './dees-input-typelist.js';
import { DeesFormSubmit } from './dees-form-submit.js';
import { DeesTable } from './dees-table.js';
import { demoFunc } from './dees-form.demo.js';
import { DeesInputIban } from './dees-input-iban.js';
// Unified set for form input types
const FORM_INPUT_TYPES = [
DeesInputCheckbox,
DeesInputDropdown,
DeesInputFileupload,
DeesInputIban,
DeesInputText,
DeesInputMultitoggle,
DeesInputPhone,
DeesInputQuantitySelector,
DeesInputRadio,
DeesInputText,
DeesInputTypelist,
DeesTable,
];
export type TFormInputElement =
| DeesInputCheckbox
| DeesInputDropdown
| DeesInputFileupload
| DeesInputIban
| DeesInputText
| DeesInputMultitoggle
| DeesInputPhone
| DeesInputQuantitySelector
| DeesInputRadio
| DeesInputText
| DeesInputTypelist
| DeesTable<any>;
declare global {
@ -48,6 +64,13 @@ export class DeesForm extends DeesElement {
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
public readyDeferred = domtools.plugins.smartpromise.defer();
/**
* Controls the layout mode of child input components
* When true, sets all child inputs to horizontal layout
*/
@property({ type: Boolean, reflect: true, attribute: 'horizontal-layout' })
public horizontalLayout: boolean = false;
public render(): TemplateResult {
return html`
<style>
@ -62,6 +85,7 @@ export class DeesForm extends DeesElement {
public async firstUpdated() {
const formChildren = this.getFormElements();
this.updateRequiredStatus();
this.updateChildrenLayoutMode();
for (const child of formChildren) {
child.changeSubject.subscribe(async () => {
@ -107,13 +131,32 @@ export class DeesForm extends DeesElement {
*/
public async collectFormData() {
const children = this.getFormElements();
const valueObject: { [key: string]: string | number | boolean | any[] } = {};
const valueObject: { [key: string]: string | number | boolean | any[] | File[] | { option: string; key: string; payload?: any } } = {};
const radioGroups = new Map<string, DeesInputRadio[]>();
for (const child of children) {
if (!child.key) {
console.log(`form element with label "${child.label}" has no key. skipping.`);
continue;
}
// Handle radio buttons specially
if (child instanceof DeesInputRadio && child.name) {
if (!radioGroups.has(child.name)) {
radioGroups.set(child.name, []);
}
radioGroups.get(child.name).push(child);
} else {
valueObject[child.key] = child.value;
}
}
// Process radio groups - use the name as key and selected radio's key as value
for (const [groupName, radios] of radioGroups) {
const selectedRadio = radios.find(radio => radio.value === true);
valueObject[groupName] = selectedRadio ? selectedRadio.key : null;
}
return valueObject;
}
@ -202,4 +245,28 @@ export class DeesForm extends DeesElement {
}
});
}
/**
* Updates the layout mode of child input components based on form's horizontalLayout property
*/
private updateChildrenLayoutMode() {
const formChildren = this.getFormElements();
for (const child of formChildren) {
if ('layoutMode' in child) {
// The child's auto mode will detect this form's horizontal-layout attribute
(child as any).layoutMode = 'auto';
}
}
}
/**
* Called when properties change
*/
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('horizontalLayout')) {
this.updateChildrenLayoutMode();
}
}
}

View File

@ -0,0 +1,184 @@
import {
DeesElement,
property,
css,
type CSSResult,
cssManager,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
/**
* Base class for all dees-input components
* Provides unified margin system and layout mode support
*/
export abstract class DeesInputBase<T = any> extends DeesElement {
/**
* Layout mode for the input component
* - vertical: Traditional form layout (label on top)
* - horizontal: Inline layout (label position configurable)
* - auto: Detect from parent context
*/
@property({ type: String })
public layoutMode: 'vertical' | 'horizontal' | 'auto' = 'auto';
/**
* Position of the label relative to the input
*/
@property({ type: String })
public labelPosition: 'top' | 'left' | 'right' | 'none' = 'top';
/**
* Common properties for all inputs
*/
@property({ type: String })
public key: string;
@property({ type: String })
public label: string;
@property({ type: Boolean })
public required: boolean = false;
@property({ type: Boolean })
public disabled: boolean = false;
@property({ type: String })
public description: string;
/**
* Common styles for all input components
*/
public static get baseStyles(): CSSResult[] {
return [
css`
/* CSS Variables for consistent spacing */
:host {
--dees-input-spacing-unit: 8px;
--dees-input-vertical-gap: calc(var(--dees-input-spacing-unit) * 2); /* 16px */
--dees-input-horizontal-gap: calc(var(--dees-input-spacing-unit) * 2); /* 16px */
--dees-input-label-gap: var(--dees-input-spacing-unit); /* 8px */
}
/* Default vertical stacking mode (for forms) */
:host {
display: block;
margin: 0;
margin-bottom: var(--dees-input-vertical-gap);
}
/* Last child in container should have no bottom margin */
:host(:last-child) {
margin-bottom: 0;
}
/* Horizontal layout mode - activated by attribute */
:host([layout-mode="horizontal"]) {
display: inline-block;
margin: 0;
margin-right: var(--dees-input-horizontal-gap);
margin-bottom: 0;
}
:host([layout-mode="horizontal"]:last-child) {
margin-right: 0;
}
/* Auto mode - inherit from parent dees-form if present */
/* Label position variations */
:host([label-position="left"]) .input-wrapper {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--dees-input-label-gap);
align-items: center;
}
:host([label-position="right"]) .input-wrapper {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--dees-input-label-gap);
align-items: center;
}
:host([label-position="top"]) .input-wrapper {
display: block;
}
:host([label-position="none"]) dees-label {
display: none;
}
`,
];
}
/**
* Subject for value changes that all inputs should implement
*/
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<T>();
/**
* Called when the element is connected to the DOM
* Sets up layout mode detection
*/
async connectedCallback() {
await super.connectedCallback();
this.detectLayoutMode();
}
/**
* Detects the appropriate layout mode based on parent context
*/
private detectLayoutMode() {
if (this.layoutMode !== 'auto') {
this.setAttribute('layout-mode', this.layoutMode);
return;
}
// Check if parent is a form with horizontal layout
const parentForm = this.closest('dees-form');
if (parentForm && parentForm.hasAttribute('horizontal-layout')) {
this.setAttribute('layout-mode', 'horizontal');
} else {
this.setAttribute('layout-mode', 'vertical');
}
}
/**
* Updates the layout mode attribute when property changes
*/
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('layoutMode')) {
this.detectLayoutMode();
}
if (changedProperties.has('labelPosition')) {
this.setAttribute('label-position', this.labelPosition);
}
}
/**
* Standard method for freezing input (disabling)
*/
public async freeze() {
this.disabled = true;
}
/**
* Standard method for unfreezing input (enabling)
*/
public async unfreeze() {
this.disabled = false;
}
/**
* Abstract method that child classes must implement to get their value
*/
public abstract getValue(): any;
/**
* Abstract method that child classes must implement to set their value
*/
public abstract setValue(value: any): void;
}

View File

@ -0,0 +1,267 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import type { DeesInputCheckbox } from './dees-input-checkbox.js';
import './dees-button.js';
export const demoFunc = () => html`
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
// Get all checkboxes for demo interactions
const checkboxes = elementArg.querySelectorAll('dees-input-checkbox');
// Example of programmatic interaction
const selectAllBtn = elementArg.querySelector('#select-all-btn');
const clearAllBtn = elementArg.querySelector('#clear-all-btn');
if (selectAllBtn && clearAllBtn) {
selectAllBtn.addEventListener('click', () => {
checkboxes.forEach((checkbox: DeesInputCheckbox) => {
if (!checkbox.disabled && checkbox.key?.startsWith('feature')) {
checkbox.value = true;
}
});
});
clearAllBtn.addEventListener('click', () => {
checkboxes.forEach((checkbox: DeesInputCheckbox) => {
if (!checkbox.disabled && checkbox.key?.startsWith('feature')) {
checkbox.value = false;
}
});
});
}
}}>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
background: #f8f9fa;
border-radius: 8px;
padding: 24px;
}
@media (prefers-color-scheme: dark) {
.demo-section {
background: #1a1a1a;
}
}
.demo-section h3 {
margin-top: 0;
margin-bottom: 16px;
color: #0069f2;
font-size: 18px;
}
.demo-section p {
margin-top: 0;
margin-bottom: 16px;
color: #666;
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.demo-section p {
color: #999;
}
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.feature-list {
background: #f0f0f0;
border-radius: 4px;
padding: 16px;
margin-bottom: 16px;
}
@media (prefers-color-scheme: dark) {
.feature-list {
background: #0a0a0a;
}
}
.button-group {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
`}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Basic Checkboxes</h3>
<p>Standard checkbox inputs for boolean selections</p>
<dees-input-checkbox
.label=${'I agree to the Terms and Conditions'}
.value=${true}
.key=${'terms'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Subscribe to newsletter'}
.value=${false}
.key=${'newsletter'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Enable notifications'}
.required=${true}
.key=${'notifications'}
></dees-input-checkbox>
</div>
<div class="demo-section">
<h3>Horizontal Layout</h3>
<p>Checkboxes arranged horizontally for compact forms</p>
<div class="horizontal-group">
<dees-input-checkbox
.label=${'Option A'}
.layoutMode=${'horizontal'}
.key=${'optionA'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Option B'}
.layoutMode=${'horizontal'}
.value=${true}
.key=${'optionB'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Option C'}
.layoutMode=${'horizontal'}
.key=${'optionC'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Option D'}
.layoutMode=${'horizontal'}
.value=${true}
.key=${'optionD'}
></dees-input-checkbox>
</div>
</div>
<div class="demo-section">
<h3>Feature Selection Example</h3>
<p>Common use case for feature toggles with batch operations</p>
<div class="button-group">
<dees-button id="select-all-btn" type="secondary">Select All</dees-button>
<dees-button id="clear-all-btn" type="secondary">Clear All</dees-button>
</div>
<div class="feature-list">
<div class="checkbox-group">
<dees-input-checkbox
.label=${'Dark Mode Support'}
.value=${true}
.key=${'feature1'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Email Notifications'}
.value=${true}
.key=${'feature2'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Two-Factor Authentication'}
.value=${false}
.key=${'feature3'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'API Access'}
.value=${true}
.key=${'feature4'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Advanced Analytics'}
.value=${false}
.key=${'feature5'}
></dees-input-checkbox>
</div>
</div>
</div>
<div class="demo-section">
<h3>States</h3>
<p>Different checkbox states and configurations</p>
<dees-input-checkbox
.label=${'Disabled Unchecked'}
.disabled=${true}
.key=${'disabled1'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Disabled Checked'}
.disabled=${true}
.value=${true}
.key=${'disabled2'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Required Checkbox'}
.required=${true}
.key=${'required'}
></dees-input-checkbox>
</div>
<div class="demo-section">
<h3>Real-world Examples</h3>
<p>Common checkbox patterns in applications</p>
<div class="checkbox-group">
<dees-input-checkbox
.label=${'Remember me on this device'}
.value=${true}
.key=${'rememberMe'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Make my profile public'}
.value=${false}
.key=${'publicProfile'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Allow others to find me by email'}
.value=${false}
.key=${'findByEmail'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Send me product updates and announcements'}
.value=${true}
.key=${'productUpdates'}
></dees-input-checkbox>
</div>
</div>
</div>
</dees-demowrapper>
`;

View File

@ -1,14 +1,13 @@
import {
customElement,
DeesElement,
type TemplateResult,
property,
html,
css,
cssManager,
type CSSResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-checkbox.demo.js';
declare global {
interface HTMLElementTagNameMap {
@ -17,51 +16,33 @@ declare global {
}
@customElement('dees-input-checkbox')
export class DeesInputCheckbox extends DeesElement {
export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
// STATIC
public static demo = () => html`<dees-input-checkbox></dees-input-checkbox>`;
public static demo = demoFunc;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
@property({
type: String,
reflect: true,
})
public key: string;
@property({
type: String,
})
public label: string = 'Label';
@property({
type: Boolean,
})
public value: boolean = false;
@property({
type: Boolean,
})
public required: boolean = false;
@property({
type: Boolean
})
public disabled: boolean = false;
constructor() {
super();
this.labelPosition = 'right'; // Checkboxes default to label on the right
}
public render(): TemplateResult {
return html`
${domtools.elementBasic.styles}
<style>
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
position: relative;
margin: 20px 0px;
cursor: default;
}
:host(:hover) {
@ -69,21 +50,12 @@ export class DeesInputCheckbox extends DeesElement {
}
.maincontainer {
display: grid;
grid-template-columns: 25px auto;
padding: 5px 0px;
color: ${this.goBright ? '#333' : '#ccc'};
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.maincontainer:hover {
${this.goBright ? '#000' : '#ccc'};
}
.label {
margin-left: 15px;
line-height: 25px;
font-size: 14px;
font-weight: normal;
color: ${cssManager.bdTheme('#000', '#fff')};
}
input:focus {
@ -94,12 +66,12 @@ export class DeesInputCheckbox extends DeesElement {
.checkbox {
transition: all 0.1s;
box-sizing: border-box;
border: 1px solid ${this.goBright ? '#CCC' : '#999'};
border: 1px solid ${cssManager.bdTheme('#CCC', '#999')};
border-radius: 2px;
height: 24px;
width: 24px;
display: inline-block;
background: ${this.goBright ? '#fafafa' : '#222'};
background: ${cssManager.bdTheme('#fafafa', '#222')};
}
.checkbox.selected {
@ -146,7 +118,12 @@ export class DeesInputCheckbox extends DeesElement {
img {
padding: 4px;
}
</style>
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
<div class="maincontainer" @click="${this.toggleSelected}">
<div class="checkbox ${this.value ? 'selected' : ''} ${this.disabled ? 'disabled' : ''}" tabindex="0">
${this.value
@ -158,7 +135,8 @@ export class DeesInputCheckbox extends DeesElement {
`
: html``}
</div>
<div class="label">${this.label}</div>
</div>
<dees-label .label=${this.label}></dees-label>
</div>
`;
}
@ -177,6 +155,14 @@ export class DeesInputCheckbox extends DeesElement {
this.changeSubject.next(this);
}
public getValue(): boolean {
return this.value;
}
public setValue(value: boolean): void {
this.value = value;
}
public focus(): void {
const checkboxDiv = this.shadowRoot.querySelector('.checkbox');
if (checkboxDiv) {

View File

@ -1,27 +1,200 @@
import { html } from '@design.estate/dees-element';
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
background: #f8f9fa;
border-radius: 8px;
padding: 24px;
position: relative;
}
@media (prefers-color-scheme: dark) {
.demo-section {
background: #1a1a1a;
}
}
.demo-section h3 {
margin-top: 0;
margin-bottom: 16px;
color: #0069f2;
font-size: 18px;
}
.demo-section p {
margin-top: 0;
margin-bottom: 16px;
color: #666;
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.demo-section p {
color: #999;
}
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.spacer {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
}
`}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Basic Dropdowns</h3>
<p>Standard dropdown with search functionality and various options</p>
<dees-input-dropdown
.label=${'Select Country'}
.options=${[
{option: 'option 1', key: 'option1'},
{option: 'option 2', key: 'option2'},
{option: 'option 3', key: 'option3'}
{ option: 'United States', key: 'us' },
{ option: 'Canada', key: 'ca' },
{ option: 'Germany', key: 'de' },
{ option: 'France', key: 'fr' },
{ option: 'United Kingdom', key: 'uk' },
{ option: 'Australia', key: 'au' },
{ option: 'Japan', key: 'jp' },
{ option: 'Brazil', key: 'br' }
]}
.selectedOption=${{ option: 'United States', key: 'us' }}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Select Role'}
.options=${[
{ option: 'Administrator', key: 'admin' },
{ option: 'Editor', key: 'editor' },
{ option: 'Viewer', key: 'viewer' },
{ option: 'Guest', key: 'guest' }
]}
></dees-input-dropdown>
</div>
<div class="demo-section">
<h3>Without Search</h3>
<p>Dropdown with search functionality disabled for simpler selection</p>
<dees-input-dropdown
.label=${'Priority Level'}
.enableSearch=${false}
.options=${[
{option: 'option 1', key: 'option1'},
{option: 'option 2', key: 'option2'},
{option: 'option 3', key: 'option3'}
{ option: 'High', key: 'high' },
{ option: 'Medium', key: 'medium' },
{ option: 'Low', key: 'low' }
]}
.selectedOption=${{ option: 'Medium', key: 'medium' }}
></dees-input-dropdown>
<div style="height: 300px"></div>
</div>
<div class="demo-section">
<h3>Horizontal Layout</h3>
<p>Multiple dropdowns in a horizontal layout for compact forms</p>
<div class="horizontal-group">
<dees-input-dropdown
.label=${'Department'}
.layoutMode=${'horizontal'}
.options=${[
{option: 'option 1', key: 'option1'},
{option: 'option 2', key: 'option2'},
{option: 'option 3', key: 'option3'}
{ option: 'Engineering', key: 'eng' },
{ option: 'Design', key: 'design' },
{ option: 'Marketing', key: 'marketing' },
{ option: 'Sales', key: 'sales' }
]}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Team Size'}
.layoutMode=${'horizontal'}
.enableSearch=${false}
.options=${[
{ option: '1-5', key: 'small' },
{ option: '6-20', key: 'medium' },
{ option: '21-50', key: 'large' },
{ option: '50+', key: 'xlarge' }
]}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Location'}
.layoutMode=${'horizontal'}
.options=${[
{ option: 'Remote', key: 'remote' },
{ option: 'On-site', key: 'onsite' },
{ option: 'Hybrid', key: 'hybrid' }
]}
></dees-input-dropdown>
</div>
</div>
<div class="demo-section">
<h3>States</h3>
<p>Different states and configurations</p>
<dees-input-dropdown
.label=${'Required Field'}
.required=${true}
.options=${[
{ option: 'Option A', key: 'a' },
{ option: 'Option B', key: 'b' },
{ option: 'Option C', key: 'c' }
]}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Disabled Dropdown'}
.disabled=${true}
.options=${[
{ option: 'Cannot Select', key: 'disabled' }
]}
.selectedOption=${{ option: 'Cannot Select', key: 'disabled' }}
></dees-input-dropdown>
</div>
<div class="spacer">
(Spacer to test dropdown positioning)
</div>
<div class="demo-section">
<h3>Bottom Positioning</h3>
<p>Dropdown that opens upward when near bottom of viewport</p>
<dees-input-dropdown
.label=${'Opens Upward'}
.options=${[
{ option: 'First Option', key: 'first' },
{ option: 'Second Option', key: 'second' },
{ option: 'Third Option', key: 'third' },
{ option: 'Fourth Option', key: 'fourth' },
{ option: 'Fifth Option', key: 'fifth' }
]}
></dees-input-dropdown>
</div>
</div>
</dees-demowrapper>
`

View File

@ -1,17 +1,16 @@
import {
customElement,
DeesElement,
type TemplateResult,
property,
state,
html,
css,
cssManager,
type CSSResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { demoFunc } from './dees-input-dropdown.demo.js';
import { DeesWindowLayer } from './dees-windowlayer.js';
import { DeesInputBase } from './dees-input-base.js';
declare global {
interface HTMLElementTagNameMap {
@ -20,20 +19,10 @@ declare global {
}
@customElement('dees-input-dropdown')
export class DeesInputDropdown extends DeesElement {
export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
public static demo = demoFunc;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
@property({
type: String,
reflect: true,
})
public label: string = 'Label';
@property()
public key: string;
@property()
public options: { option: string; key: string; payload?: any }[] = [];
@ -41,20 +30,21 @@ export class DeesInputDropdown extends DeesElement {
@property()
public selectedOption: { option: string; key: string; payload?: any } = null;
@property({
type: Boolean,
})
public required: boolean = false;
// Add value property for form compatibility
public get value() {
return this.selectedOption;
}
public set value(val: { option: string; key: string; payload?: any }) {
this.selectedOption = val;
}
@property({
type: Boolean,
})
public enableSearch: boolean = true;
@property({
type: Boolean,
})
public disabled: boolean = false;
@state()
public opensToTop: boolean = false;
@ -69,6 +59,7 @@ export class DeesInputDropdown extends DeesElement {
public isOpened = false;
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
* {
@ -78,19 +69,13 @@ export class DeesInputDropdown extends DeesElement {
:host {
font-family: Roboto;
position: relative;
display: block;
color: ${cssManager.bdTheme('#222', '#fff')};
margin-bottom: 24px;
}
.maincontainer {
display: block;
}
.label {
font-size: 14px;
margin-bottom: 8px;
}
.selectedBox {
user-select: none;
@ -205,8 +190,9 @@ export class DeesInputDropdown extends DeesElement {
public render(): TemplateResult {
return html`
<div class="input-wrapper">
<dees-label .label=${this.label}></dees-label>
<div class="maincontainer" @keydown="${this.isOpened ? this.handleKeyDown : undefined}">
${this.label ? html`<div class="label">${this.label}</div>` : html``}
<div class="selectionBox">
${this.enableSearch && !this.opensToTop
? html`
@ -249,6 +235,7 @@ export class DeesInputDropdown extends DeesElement {
${this.selectedOption?.option || 'Select...'}
</div>
</div>
</div>
`;
}
@ -372,4 +359,12 @@ export class DeesInputDropdown extends DeesElement {
event.preventDefault();
}
}
public getValue(): { option: string; key: string; payload?: any } {
return this.selectedOption;
}
public setValue(value: { option: string; key: string; payload?: any }): void {
this.selectedOption = value;
}
}

View File

@ -0,0 +1,138 @@
import { html, css, cssManager } from '@design.estate/dees-element';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.upload-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.upload-grid {
grid-template-columns: 1fr;
}
}
.upload-box {
padding: 16px;
background: ${cssManager.bdTheme('#fff', '#2a2a2a')};
border-radius: 4px;
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444')};
}
.upload-box h4 {
margin-top: 0;
margin-bottom: 16px;
color: ${cssManager.bdTheme('#333', '#fff')};
font-size: 16px;
}
.info-section {
margin-top: 32px;
padding: 16px;
background: ${cssManager.bdTheme('#fff3cd', '#332701')};
border: 1px solid ${cssManager.bdTheme('#ffeaa7', '#664400')};
border-radius: 4px;
color: ${cssManager.bdTheme('#856404', '#ffecb5')};
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'Basic File Upload'} .subtitle=${'Simple file upload with drag and drop support'}>
<dees-input-fileupload
.label=${'Attachments'}
.description=${'Upload files by clicking or dragging'}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Resume'}
.description=${'Upload your CV in PDF format'}
.buttonText=${'Choose Resume...'}
></dees-input-fileupload>
</dees-panel>
<dees-panel .title=${'Multiple Upload Areas'} .subtitle=${'Different upload zones for various file types'}>
<div class="upload-grid">
<div class="upload-box">
<h4>Profile Picture</h4>
<dees-input-fileupload
.label=${'Avatar'}
.description=${'JPG, PNG or GIF'}
.buttonText=${'Select Image...'}
></dees-input-fileupload>
</div>
<div class="upload-box">
<h4>Cover Image</h4>
<dees-input-fileupload
.label=${'Banner'}
.description=${'Recommended: 1200x400px'}
.buttonText=${'Select Banner...'}
></dees-input-fileupload>
</div>
</div>
</dees-panel>
<dees-panel .title=${'Required & Disabled States'} .subtitle=${'Different upload states for validation'}>
<dees-input-fileupload
.label=${'Identity Document'}
.description=${'Required for verification'}
.required=${true}
.buttonText=${'Upload Document...'}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'System Files'}
.description=${'File upload is disabled'}
.disabled=${true}
.value=${[]}
></dees-input-fileupload>
</dees-panel>
<dees-panel .title=${'Application Form'} .subtitle=${'Complete form with file upload integration'}>
<dees-form>
<dees-input-text .label=${'Full Name'} .required=${true}></dees-input-text>
<dees-input-text .label=${'Email'} .inputType=${'email'} .required=${true}></dees-input-text>
<dees-input-fileupload
.label=${'Resume'}
.description=${'Upload your CV (PDF preferred)'}
.required=${true}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Portfolio'}
.description=${'Optional: Upload work samples'}
></dees-input-fileupload>
<dees-input-text
.label=${'Cover Letter'}
.inputType=${'textarea'}
.description=${'Tell us why you would be a great fit'}
></dees-input-text>
</dees-form>
<div class="info-section">
<h4>Features:</h4>
<ul>
<li>Click to select files or drag & drop</li>
<li>Multiple file selection support</li>
<li>Visual feedback for drag operations</li>
<li>Right-click files to remove them</li>
<li>Integrates seamlessly with forms</li>
</ul>
</div>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -2,6 +2,8 @@ import * as colors from './00colors.js';
import * as plugins from './00plugins.js';
import { DeesContextmenu } from './dees-contextmenu.js';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-fileupload.demo.js';
import {
customElement,
@ -23,23 +25,9 @@ declare global {
}
@customElement('dees-input-fileupload')
export class DeesInputFileupload extends DeesElement {
public static demo = () =>
html`<dees-input-fileupload .label=${'Attachments'}></dees-input-fileupload>`;
export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
public static demo = demoFunc;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
@property({
type: String,
})
public label: string = null;
@property({
type: String,
reflect: true,
})
public key: string;
@property({
attribute: false,
@ -49,16 +37,6 @@ export class DeesInputFileupload extends DeesElement {
@property()
public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle';
@property({
type: Boolean,
})
public required: boolean = false;
@property({
type: Boolean,
})
public disabled: boolean = false;
@property({
type: String,
})
@ -69,13 +47,12 @@ export class DeesInputFileupload extends DeesElement {
}
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
position: relative;
display: grid;
margin: 10px 0px;
margin-bottom: 24px;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
@ -112,11 +89,6 @@ export class DeesInputFileupload extends DeesElement {
background: #00000080;
}
.label {
font-size: 14px;
margin-bottom: 8px;
}
.uploadButton {
position: relative;
padding: 8px;
@ -173,10 +145,11 @@ export class DeesInputFileupload extends DeesElement {
public render(): TemplateResult {
return html`
<div class="input-wrapper">
<dees-label .label=${this.label} .description=${this.description}></dees-label>
<div class="hidden">
<input type="file"></div>
<input type="file">
</div>
${this.label ? html`<div class="label">${this.label}</div>` : null}
<div class="maincontainer ${this.state === 'dragOver' ? 'dragOver' : ''}">
${this.value.map(
(fileArg) => html`
@ -205,6 +178,7 @@ export class DeesInputFileupload extends DeesElement {
${this.buttonText}
</div>
</div>
</div>
`;
}
@ -221,7 +195,8 @@ export class DeesInputFileupload extends DeesElement {
this.changeSubject.next(this);
}
public firstUpdated() {
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(_changedProperties);
const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]');
inputFile.addEventListener('change', (event: Event) => {
const target = event.target as HTMLInputElement;
@ -263,4 +238,12 @@ export class DeesInputFileupload extends DeesElement {
dropArea.addEventListener('dragover', handlerFunction, false);
dropArea.addEventListener('drop', handlerFunction, false);
}
public getValue(): File[] {
return this.value;
}
public setValue(value: File[]): void {
this.value = value;
}
}

View File

@ -1,3 +1,80 @@
import { html } from '@design.estate/dees-element';
import { html, css } from '@design.estate/dees-element';
export const demoFunc = () => html`<dees-input-iban .label=${'IBAN'}></dees-input-iban>`;
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.payment-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'Basic IBAN Input'} .subtitle=${'International Bank Account Number with automatic formatting'}>
<dees-input-iban
.label=${'Bank Account IBAN'}
.description=${'Enter your International Bank Account Number'}
></dees-input-iban>
<dees-input-iban
.label=${'Verified IBAN'}
.description=${'This IBAN has been verified'}
.value=${'DE89370400440532013000'}
></dees-input-iban>
</dees-panel>
<dees-panel .title=${'Payment Information'} .subtitle=${'IBAN input with horizontal layout for payment forms'}>
<div class="payment-group">
<dees-input-text
.label=${'Account Holder'}
.layoutMode=${'horizontal'}
.value=${'John Doe'}
></dees-input-text>
<dees-input-iban
.label=${'IBAN'}
.layoutMode=${'horizontal'}
.value=${'GB82WEST12345698765432'}
></dees-input-iban>
</div>
</dees-panel>
<dees-panel .title=${'Validation & States'} .subtitle=${'Required fields and disabled states'}>
<dees-input-iban
.label=${'Payment Account'}
.description=${'Required for processing payments'}
.required=${true}
></dees-input-iban>
<dees-input-iban
.label=${'Locked IBAN'}
.description=${'This IBAN cannot be changed'}
.value=${'FR1420041010050500013M02606'}
.disabled=${true}
></dees-input-iban>
</dees-panel>
<dees-panel .title=${'Bank Transfer Form'} .subtitle=${'Complete form example with IBAN validation'}>
<dees-form>
<dees-input-text .label=${'Recipient Name'} .required=${true}></dees-input-text>
<dees-input-iban .label=${'Recipient IBAN'} .required=${true}></dees-input-iban>
<dees-input-text .label=${'Transfer Reference'} .description=${'Optional reference for the transfer'}></dees-input-text>
<dees-input-text .label=${'Amount'} .inputType=${'number'} .required=${true}></dees-input-text>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -1,18 +1,19 @@
import {
customElement,
DeesElement,
type TemplateResult,
state,
html,
domtools,
property,
css,
cssManager,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { DeesInputBase } from './dees-input-base.js';
import * as ibantools from 'ibantools';
import { demoFunc } from './dees-input-iban.demo.js';
@customElement('dees-input-iban')
export class DeesInputIban extends DeesElement {
export class DeesInputIban extends DeesInputBase<DeesInputIban> {
// STATIC
public static demo = demoFunc;
@ -23,60 +24,44 @@ export class DeesInputIban extends DeesElement {
@state()
enteredIbanIsValid: boolean = false;
@property({
type: Boolean,
})
public disabled = false;
@property({
type: Boolean,
})
public required = false;
@property({
type: String,
})
public label = '';
@property({
type: String,
})
public key = '';
@property({
type: String,
})
public value = '';
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<DeesInputIban>();
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
/* IBAN input specific styles can go here */
`,
];
public render(): TemplateResult {
return html`
<style>
input[type='text'] {
line-height: 20px;
padding: 5px;
width: 250px;
}
</style>
<div class="input-wrapper">
<dees-label .label=${this.label || 'IBAN'} .description=${this.description}></dees-label>
<dees-input-text
.label=${'IBAN'}
.value=${this.value}
.disabled=${this.disabled}
.required=${this.required}
.placeholder=${'DE89 3704 0044 0532 0130 00'}
@input=${(eventArg: InputEvent) => {
this.validateIban(eventArg);
}}
></dees-input-text>
</div>
`;
}
public async firstUpdated() {
const deesInputText = this.shadowRoot.querySelector('dees-input-text');
deesInputText.disabled = this.disabled;
deesInputText.required = this.required;
deesInputText.changeSubject.subscribe(valueArg => {
this.value = valueArg.value;
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(_changedProperties);
const deesInputText = this.shadowRoot.querySelector('dees-input-text') as any;
if (deesInputText && deesInputText.changeSubject) {
deesInputText.changeSubject.subscribe(() => {
this.changeSubject.next(this);
})
});
}
}
public async validateIban(eventArg: InputEvent): Promise<void> {
@ -95,4 +80,13 @@ export class DeesInputIban extends DeesElement {
const deesInputText = this.shadowRoot.querySelector('dees-input-text');
deesInputText.validationText = `IBAN is valid: ${this.enteredIbanIsValid}`;
}
public getValue(): string {
return this.value;
}
public setValue(value: string): void {
this.value = value;
this.enteredString = ibantools.friendlyFormatIBAN(value) || '';
}
}

View File

@ -1,14 +1,128 @@
import { html } from '@design.estate/dees-element';
import { html, css } from '@design.estate/dees-element';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'Multi-Option Toggle'} .subtitle=${'Select from multiple options with a sliding indicator'}>
<dees-input-multitoggle
.options=${['option 1', 'option 2', 'a longer option with multiple words']}
.selectedOption=${'option 2'}
.label=${'Display Mode'}
.description=${'Choose how content is displayed'}
.options=${['List View', 'Grid View', 'Compact']}
.selectedOption=${'Grid View'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'T-Shirt Size'}
.description=${'Select your preferred size'}
.options=${['XS', 'S', 'M', 'L', 'XL', 'XXL']}
.selectedOption=${'M'}
></dees-input-multitoggle>
</dees-panel>
<dees-panel .title=${'Boolean Toggle'} .subtitle=${'Simple on/off switches with custom labels'}>
<dees-input-multitoggle
.label=${'Notifications'}
.description=${'Enable or disable push notifications'}
.type=${'boolean'}
.booleanTrueName=${'enabled'}
.booleanFalseName=${'disabled'}
.selectedOption=${'true'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'Theme Mode'}
.description=${'Switch between light and dark theme'}
.type=${'boolean'}
.booleanTrueName=${'Dark'}
.booleanFalseName=${'Light'}
.selectedOption=${'Dark'}
></dees-input-multitoggle>
</dees-panel>
<dees-panel .title=${'Settings Panel'} .subtitle=${'Configuration options in a horizontal layout'}>
<div class="settings-grid">
<dees-input-multitoggle
.label=${'Auto-Save'}
.layoutMode=${'horizontal'}
.type=${'boolean'}
.booleanTrueName=${'Enabled'}
.booleanFalseName=${'Disabled'}
.selectedOption=${'Enabled'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'Language'}
.layoutMode=${'horizontal'}
.options=${['English', 'German', 'French', 'Spanish']}
.selectedOption=${'English'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'Quality'}
.layoutMode=${'horizontal'}
.options=${['Low', 'Medium', 'High', 'Ultra']}
.selectedOption=${'High'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'Privacy'}
.layoutMode=${'horizontal'}
.type=${'boolean'}
.booleanTrueName=${'Private'}
.booleanFalseName=${'Public'}
.selectedOption=${'Private'}
></dees-input-multitoggle>
</div>
</dees-panel>
<dees-panel .title=${'States & Form Integration'} .subtitle=${'Disabled states and form usage'}>
<dees-input-multitoggle
.label=${'Account Type'}
.description=${'This setting is locked'}
.options=${['Free', 'Pro', 'Enterprise']}
.selectedOption=${'Enterprise'}
.disabled=${true}
></dees-input-multitoggle>
<dees-form>
<dees-input-text .label=${'Project Name'} .required=${true}></dees-input-text>
<dees-input-multitoggle
.label=${'Visibility'}
.type=${'boolean'}
.booleanTrueName=${'Public'}
.booleanFalseName=${'Private'}
.selectedOption=${'Private'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'License'}
.options=${['MIT', 'Apache 2.0', 'GPL v3', 'Proprietary']}
.selectedOption=${'MIT'}
></dees-input-multitoggle>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -1,14 +1,14 @@
import {
customElement,
DeesElement,
type TemplateResult,
state,
html,
domtools,
property,
css,
cssManager,
} from '@design.estate/dees-element';
import { DeesInputBase } from './dees-input-base.js';
import * as colors from './00colors.js'
const { demoFunc } = await import('./dees-input-multitoggle.demo.js');
@ -19,18 +19,9 @@ declare global {
}
@customElement('dees-input-multitoggle')
export class DeesInputMultitoggle extends DeesElement {
export class DeesInputMultitoggle extends DeesInputBase<DeesInputMultitoggle> {
public static demo = demoFunc;
@property({
type: String,
})
public label: string;
@property({
type: String,
})
public description: string;
@property()
type: 'boolean' | 'multi' | 'single' = 'multi';
@ -49,23 +40,38 @@ export class DeesInputMultitoggle extends DeesElement {
@property()
selectedOption: string = '';
@property()
@property({ type: Boolean })
boolValue: boolean = false;
// Add value property for form compatibility
public get value(): string | boolean {
if (this.type === 'boolean') {
return this.selectedOption === this.booleanTrueName;
}
return this.selectedOption;
}
public set value(val: string | boolean) {
if (this.type === 'boolean' && typeof val === 'boolean') {
this.selectedOption = val ? this.booleanTrueName : this.booleanFalseName;
} else {
this.selectedOption = val as string;
}
// Defer indicator update to next frame if component not yet updated
if (this.hasUpdated) {
this.setIndicator();
}
}
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
color: ${cssManager.bdTheme('#333', '#ccc')};
user-select: none;
margin: 8px 0px 24px 0px;
}
.label {
font-size: 14px;
margin-bottom: 8px;
}
.selections {
position: relative;
@ -76,11 +82,11 @@ export class DeesInputMultitoggle extends DeesElement {
width: min-content;
border-radius: 20px;
height: 32px;
border-top: 1px solid #ffffff10;
border-top: 1px solid ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.1)')};
}
.option {
color: #ccc;
color: ${cssManager.bdTheme('#666', '#999')};
position: relative;
padding: 0px 16px;
line-height: 32px;
@ -93,11 +99,11 @@ export class DeesInputMultitoggle extends DeesElement {
}
.option:hover {
color: #fff;
color: ${cssManager.bdTheme('#333', '#fff')};
}
.option.selected {
color: #fff;
color: ${cssManager.bdTheme('#fff', '#fff')};
}
.indicator {
@ -107,14 +113,20 @@ export class DeesInputMultitoggle extends DeesElement {
left: 4px;
top: 3px;
border-radius: 16px;
background: #0050b9;
min-width: 36px;
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
min-width: 24px;
transition: all 0.1s ease-in-out;
}
.indicator.no-transition {
transition: none;
}
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
<dees-label .label=${this.label} .description=${this.description}></dees-label>
<div class="mainbox">
<div class="selections">
@ -127,14 +139,29 @@ export class DeesInputMultitoggle extends DeesElement {
)}
</div>
</div>
</div>
`;
}
public async firstUpdated() {
public async connectedCallback() {
await super.connectedCallback();
// Initialize boolean options early
if (this.type === 'boolean' && this.options.length === 0) {
this.options = [this.booleanTrueName || 'true', this.booleanFalseName || 'false'];
}
}
public async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(_changedProperties);
// Update boolean options if they changed
if (this.type === 'boolean') {
this.options = [this.booleanTrueName || 'true', this.booleanFalseName || 'false'];
}
// Wait for the next frame to ensure DOM is fully rendered
await this.updateComplete;
requestAnimationFrame(() => {
this.setIndicator();
});
}
public async handleSelection(optionArg: string) {
@ -142,18 +169,57 @@ export class DeesInputMultitoggle extends DeesElement {
this.setIndicator();
}
private indicatorInitialized = false;
public async setIndicator() {
const indicator: HTMLDivElement = this.shadowRoot.querySelector('.indicator');
const selectedIndex = this.options.indexOf(this.selectedOption);
// If no valid selection, hide indicator
if (selectedIndex === -1 || !indicator) {
if (indicator) {
indicator.style.opacity = '0';
}
return;
}
const option: HTMLDivElement = this.shadowRoot.querySelector(
`.option:nth-child(${this.options.indexOf(this.selectedOption) + 2})`
`.option:nth-child(${selectedIndex + 2})`
);
if (indicator && option) {
// Only disable transition for the very first positioning
if (!this.indicatorInitialized) {
indicator.classList.add('no-transition');
this.indicatorInitialized = true;
// Remove the no-transition class after a brief delay
setTimeout(() => {
indicator.classList.remove('no-transition');
}, 50);
}
indicator.style.width = `${option.clientWidth - 8}px`;
indicator.style.left = `${option.offsetLeft + 4}px`;
indicator.style.opacity = '1';
}
setTimeout(() => {
indicator.style.transition = 'all 0.1s';
}, 100);
}
public getValue(): string | boolean {
if (this.type === 'boolean') {
return this.selectedOption === this.booleanTrueName;
}
return this.selectedOption;
}
public setValue(value: string | boolean): void {
if (this.type === 'boolean' && typeof value === 'boolean') {
this.selectedOption = value ? (this.booleanTrueName || 'true') : (this.booleanFalseName || 'false');
} else {
this.selectedOption = value as string;
}
if (this.hasUpdated) {
this.setIndicator();
}
}
}

View File

@ -1,3 +1,80 @@
import { html } from '@design.estate/dees-element';
import { html, css } from '@design.estate/dees-element';
export const demoFunc = () => html`<dees-input-phone .label=${'Phone Number'}></dees-input-phone>`;
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'Basic Phone Input'} .subtitle=${'Automatic formatting for phone numbers'}>
<dees-input-phone
.label=${'Phone Number'}
.description=${'Enter your phone number with country code'}
.value=${'5551234567'}
></dees-input-phone>
<dees-input-phone
.label=${'Contact Phone'}
.description=${'Required for account verification'}
.required=${true}
.placeholder=${'+1 (555) 000-0000'}
></dees-input-phone>
</dees-panel>
<dees-panel .title=${'Horizontal Layout'} .subtitle=${'Phone inputs arranged horizontally'}>
<div class="horizontal-group">
<dees-input-phone
.label=${'Mobile'}
.layoutMode=${'horizontal'}
.value=${'4155551234'}
></dees-input-phone>
<dees-input-phone
.label=${'Office'}
.layoutMode=${'horizontal'}
.placeholder=${'+1 (800) 555-0000'}
></dees-input-phone>
</div>
</dees-panel>
<dees-panel .title=${'International Numbers'} .subtitle=${'Supports formatting for numbers with country codes'}>
<dees-input-phone
.label=${'International Contact'}
.description=${'Automatically formats international numbers'}
.value=${'441234567890'}
></dees-input-phone>
<dees-input-phone
.label=${'Emergency Contact'}
.value=${'911'}
.disabled=${true}
></dees-input-phone>
</dees-panel>
<dees-panel .title=${'Form Integration'} .subtitle=${'Phone input as part of a contact form'}>
<dees-form>
<dees-input-text .label=${'Full Name'} .required=${true}></dees-input-text>
<dees-input-phone .label=${'Phone Number'} .required=${true}></dees-input-phone>
<dees-input-text .label=${'Email'} .inputType=${'email'}></dees-input-text>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -1,14 +1,14 @@
import {
customElement,
DeesElement,
type TemplateResult,
property,
state,
html,
css,
unsafeCSS,
cssManager,
type CSSResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-phone.demo.js';
declare global {
@ -18,12 +18,116 @@ declare global {
}
@customElement('dees-input-phone')
export class DeesInputPhone extends DeesElement {
export class DeesInputPhone extends DeesInputBase<DeesInputPhone> {
// STATIC
public static demo = demoFunc;
// INSTANCE
public render() {
return html`<div>Phone Input</div>`;
@state()
protected formattedPhone: string = '';
@property({ type: String })
public value: string = '';
@property({ type: String })
public placeholder: string = '+1 (555) 123-4567';
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
/* Phone input specific styles can go here */
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
<dees-label .label=${this.label} .description=${this.description}></dees-label>
<dees-input-text
.value=${this.formattedPhone}
.disabled=${this.disabled}
.required=${this.required}
.placeholder=${this.placeholder}
@input=${(event: InputEvent) => this.handlePhoneInput(event)}
></dees-input-text>
</div>
`;
}
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(_changedProperties);
// Initialize formatted phone from value
if (this.value) {
this.formattedPhone = this.formatPhoneNumber(this.value);
}
// Subscribe to the inner input's changes
const innerInput = this.shadowRoot.querySelector('dees-input-text') as any;
if (innerInput && innerInput.changeSubject) {
innerInput.changeSubject.subscribe(() => {
this.changeSubject.next(this);
});
}
}
private handlePhoneInput(event: InputEvent) {
const input = event.target as HTMLInputElement;
const cleanedValue = this.cleanPhoneNumber(input.value);
const formatted = this.formatPhoneNumber(cleanedValue);
// Update the input with formatted value
if (input.value !== formatted) {
const cursorPosition = input.selectionStart || 0;
input.value = formatted;
// Try to maintain cursor position intelligently
const newCursorPos = this.calculateCursorPosition(cleanedValue, formatted, cursorPosition);
input.setSelectionRange(newCursorPos, newCursorPos);
}
this.formattedPhone = formatted;
this.value = cleanedValue;
this.changeSubject.next(this);
}
private cleanPhoneNumber(value: string): string {
// Remove all non-numeric characters
return value.replace(/\D/g, '');
}
private formatPhoneNumber(value: string): string {
// Basic US phone number formatting
// This can be enhanced to support international formats
const cleaned = this.cleanPhoneNumber(value);
if (cleaned.length === 0) return '';
if (cleaned.length <= 3) return cleaned;
if (cleaned.length <= 6) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3)}`;
if (cleaned.length <= 10) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
// For numbers longer than 10 digits, format as international
return `+${cleaned.slice(0, cleaned.length - 10)} (${cleaned.slice(-10, -7)}) ${cleaned.slice(-7, -4)}-${cleaned.slice(-4)}`;
}
private calculateCursorPosition(cleaned: string, formatted: string, oldPos: number): number {
// Simple cursor position calculation
// Count how many formatting characters are before the cursor
let formattingChars = 0;
for (let i = 0; i < oldPos && i < formatted.length; i++) {
if (!/\d/.test(formatted[i])) {
formattingChars++;
}
}
return Math.min(oldPos + formattingChars, formatted.length);
}
public getValue(): string {
return this.value;
}
public setValue(value: string): void {
this.value = value;
this.formattedPhone = this.formatPhoneNumber(value);
}
}

View File

@ -0,0 +1,127 @@
import { html, css, cssManager } from '@design.estate/dees-element';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.shopping-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.product-card {
padding: 16px;
background: ${cssManager.bdTheme('#fff', '#2a2a2a')};
border-radius: 4px;
box-shadow: 0 2px 4px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
}
.product-name {
font-weight: 600;
margin-bottom: 8px;
}
.product-price {
color: #1976d2;
margin-bottom: 16px;
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'Basic Quantity Selector'} .subtitle=${'Simple quantity input with increment/decrement buttons'}>
<dees-input-quantityselector
.label=${'Quantity'}
.description=${'Select the desired quantity'}
.value=${1}
></dees-input-quantityselector>
<dees-input-quantityselector
.label=${'Items in Cart'}
.description=${'Adjust the quantity of items'}
.value=${3}
></dees-input-quantityselector>
</dees-panel>
<dees-panel .title=${'Shopping Cart'} .subtitle=${'Product cards with quantity selectors'}>
<div class="shopping-grid">
<div class="product-card">
<div class="product-name">Premium Headphones</div>
<div class="product-price">$199.99</div>
<dees-input-quantityselector
.label=${'Quantity'}
.layoutMode=${'horizontal'}
.value=${1}
></dees-input-quantityselector>
</div>
<div class="product-card">
<div class="product-name">Wireless Mouse</div>
<div class="product-price">$49.99</div>
<dees-input-quantityselector
.label=${'Quantity'}
.layoutMode=${'horizontal'}
.value=${2}
></dees-input-quantityselector>
</div>
<div class="product-card">
<div class="product-name">USB-C Cable</div>
<div class="product-price">$19.99</div>
<dees-input-quantityselector
.label=${'Quantity'}
.layoutMode=${'horizontal'}
.value=${1}
></dees-input-quantityselector>
</div>
</div>
</dees-panel>
<dees-panel .title=${'Required & Disabled States'} .subtitle=${'Different states for validation and restrictions'}>
<dees-input-quantityselector
.label=${'Number of Licenses'}
.description=${'Select how many licenses you need'}
.required=${true}
.value=${1}
></dees-input-quantityselector>
<dees-input-quantityselector
.label=${'Fixed Quantity'}
.description=${'This quantity cannot be changed'}
.disabled=${true}
.value=${5}
></dees-input-quantityselector>
</dees-panel>
<dees-panel .title=${'Order Form'} .subtitle=${'Complete order form with quantity selection'}>
<dees-form>
<dees-input-text .label=${'Customer Name'} .required=${true}></dees-input-text>
<dees-input-dropdown
.label=${'Product'}
.options=${['Basic Plan', 'Pro Plan', 'Enterprise Plan']}
.required=${true}
></dees-input-dropdown>
<dees-input-quantityselector
.label=${'Quantity'}
.description=${'Number of licenses'}
.value=${1}
></dees-input-quantityselector>
<dees-input-text
.label=${'Special Instructions'}
.inputType=${'textarea'}
></dees-input-text>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -1,5 +1,7 @@
import { customElement, property, html, type TemplateResult, DeesElement, type CSSResult, } from '@design.estate/dees-element';
import { customElement, property, html, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-quantityselector.demo.js';
declare global {
interface HTMLElementTagNameMap {
@ -8,67 +10,50 @@ declare global {
}
@customElement('dees-input-quantityselector')
export class DeesInputQuantitySelector extends DeesElement {
public static demo = () => html`<dees-input-quantityselector></dees-input-quantityselector>`;
export class DeesInputQuantitySelector extends DeesInputBase<DeesInputQuantitySelector> {
public static demo = demoFunc;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
@property()
public label: string = 'Label';
@property({
type: String,
reflect: true,
})
public key: string;
@property({
type: Number
})
public value: number = 1;
@property({
type: Boolean,
})
public required: boolean = false;
@property({
type: Boolean
})
public disabled: boolean = false;
constructor() {
super();
}
public render(): TemplateResult {
return html`
${domtools.elementBasic.styles}
<style>
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
width: 110px;
width: auto;
user-select: none;
}
.maincontainer {
.quantity-container {
transition: all 0.1s;
font-size: 14px;
display: grid;
grid-template-columns: 33% 34% 33%;
text-align: center;
background:none;
background: ${cssManager.bdTheme('#fafafa', '#222222')};
line-height: 40px;
padding: 0px;
min-width: 100px;
color: ${this.goBright ? '#666' : '#CCC'};
border: ${this.goBright ? '1px solid #333' : '1px solid #CCC'};
min-width: 110px;
color: ${cssManager.bdTheme('#666', '#CCC')};
border: 1px solid ${cssManager.bdTheme('#CCC', '#444')};
border-radius: 4px;
}
.mainContainer:hover {
color: ${this.goBright ? '#333' : '#fff'};
border: ${this.goBright ? '1px solid #333' : '1px solid #fff'};
.quantity-container.disabled {
opacity: 0.5;
pointer-events: none;
}
.quantity-container:hover {
color: ${cssManager.bdTheme('#333', '#fff')};
border-color: ${cssManager.bdTheme('#999', '#666')};
}
.minus {
@ -91,28 +76,41 @@ export class DeesInputQuantitySelector extends DeesElement {
text-align: center;
}
`,
];
</style>
<div class="maincontainer">
public render(): TemplateResult {
return html`
<div class="input-wrapper">
<dees-label .label=${this.label}></dees-label>
<div class="quantity-container ${this.disabled ? 'disabled' : ''}">
<div class="selector minus" @click="${() => {this.decrease();}}">-</div>
<div class="quantity">${this.value}</div>
<div class="selector plus" @click="${() => {this.increase();}}">+</div>
</div>
</div>
`;
}
public increase() {
if (!this.disabled) {
this.value++;
this.changeSubject.next(this);
}
}
public decrease() {
if (this.value > 0) {
if (!this.disabled && this.value > 0) {
this.value--;
} else {
// nothing to do here
}
this.changeSubject.next(this);
}
}
public getValue(): number {
return this.value;
}
public setValue(value: number): void {
this.value = value;
}
}

View File

@ -0,0 +1,267 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import type { DeesInputRadio } from './dees-input-radio.js';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
background: #f8f9fa;
border-radius: 8px;
padding: 24px;
}
@media (prefers-color-scheme: dark) {
.demo-section {
background: #1a1a1a;
}
}
.demo-section h3 {
margin-top: 0;
margin-bottom: 16px;
color: #0069f2;
font-size: 18px;
}
.demo-section p {
margin-top: 0;
margin-bottom: 16px;
color: #666;
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.demo-section p {
color: #999;
}
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: #f0f0f0;
border-radius: 4px;
margin-bottom: 16px;
}
@media (prefers-color-scheme: dark) {
.radio-group {
background: #0a0a0a;
}
}
.radio-group-title {
font-weight: 500;
margin-bottom: 8px;
color: #333;
}
@media (prefers-color-scheme: dark) {
.radio-group-title {
color: #ccc;
}
}
.grid-layout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
`}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Basic Radio Groups</h3>
<p>Radio buttons for single-choice selections</p>
<div class="radio-group">
<div class="radio-group-title">Select your subscription plan:</div>
<dees-input-radio
.label=${'Basic Plan - $9/month'}
.value=${true}
.key=${'plan-basic'}
.name=${'plan'}
></dees-input-radio>
<dees-input-radio
.label=${'Pro Plan - $29/month'}
.key=${'plan-pro'}
.name=${'plan'}
></dees-input-radio>
<dees-input-radio
.label=${'Enterprise Plan - $99/month'}
.key=${'plan-enterprise'}
.name=${'plan'}
></dees-input-radio>
</div>
<div class="radio-group">
<div class="radio-group-title">Task Priority:</div>
<dees-input-radio
.label=${'High Priority'}
.key=${'priority-high'}
.name=${'priority'}
></dees-input-radio>
<dees-input-radio
.label=${'Medium Priority'}
.value=${true}
.key=${'priority-medium'}
.name=${'priority'}
></dees-input-radio>
<dees-input-radio
.label=${'Low Priority'}
.key=${'priority-low'}
.name=${'priority'}
></dees-input-radio>
</div>
</div>
<div class="demo-section">
<h3>Horizontal Layout</h3>
<p>Radio buttons arranged horizontally for yes/no questions</p>
<div class="radio-group" style="flex-direction: row;">
<div style="margin-right: 16px;">Do you agree?</div>
<dees-input-radio
.label=${'Yes'}
.layoutMode=${'horizontal'}
.value=${true}
.key=${'agree-yes'}
.name=${'agreement'}
></dees-input-radio>
<dees-input-radio
.label=${'No'}
.layoutMode=${'horizontal'}
.key=${'agree-no'}
.name=${'agreement'}
></dees-input-radio>
<dees-input-radio
.label=${'Maybe'}
.layoutMode=${'horizontal'}
.key=${'agree-maybe'}
.name=${'agreement'}
></dees-input-radio>
</div>
<div class="radio-group" style="flex-direction: row;">
<div style="margin-right: 16px;">Experience Level:</div>
<dees-input-radio
.label=${'Beginner'}
.layoutMode=${'horizontal'}
.key=${'exp-beginner'}
.name=${'experience'}
></dees-input-radio>
<dees-input-radio
.label=${'Intermediate'}
.layoutMode=${'horizontal'}
.value=${true}
.key=${'exp-intermediate'}
.name=${'experience'}
></dees-input-radio>
<dees-input-radio
.label=${'Expert'}
.layoutMode=${'horizontal'}
.key=${'exp-expert'}
.name=${'experience'}
></dees-input-radio>
</div>
</div>
<div class="demo-section">
<h3>Survey Example</h3>
<p>Multiple radio groups in a survey format</p>
<div class="grid-layout">
<div class="radio-group">
<div class="radio-group-title">How satisfied are you?</div>
<dees-input-radio .label=${'Very Satisfied'} .key=${'sat-very'} .name=${'satisfaction'}></dees-input-radio>
<dees-input-radio .label=${'Satisfied'} .value=${true} .key=${'sat-normal'} .name=${'satisfaction'}></dees-input-radio>
<dees-input-radio .label=${'Neutral'} .key=${'sat-neutral'} .name=${'satisfaction'}></dees-input-radio>
<dees-input-radio .label=${'Dissatisfied'} .key=${'sat-dis'} .name=${'satisfaction'}></dees-input-radio>
<dees-input-radio .label=${'Very Dissatisfied'} .key=${'sat-verydis'} .name=${'satisfaction'}></dees-input-radio>
</div>
<div class="radio-group">
<div class="radio-group-title">Would you recommend us?</div>
<dees-input-radio .label=${'Definitely'} .key=${'rec-def'} .name=${'recommend'}></dees-input-radio>
<dees-input-radio .label=${'Probably'} .value=${true} .key=${'rec-prob'} .name=${'recommend'}></dees-input-radio>
<dees-input-radio .label=${'Not Sure'} .key=${'rec-unsure'} .name=${'recommend'}></dees-input-radio>
<dees-input-radio .label=${'Probably Not'} .key=${'rec-probnot'} .name=${'recommend'}></dees-input-radio>
<dees-input-radio .label=${'Definitely Not'} .key=${'rec-defnot'} .name=${'recommend'}></dees-input-radio>
</div>
</div>
</div>
<div class="demo-section">
<h3>States</h3>
<p>Different radio button states</p>
<div class="radio-group">
<dees-input-radio
.label=${'Normal Radio'}
.key=${'state-normal'}
.name=${'states'}
></dees-input-radio>
<dees-input-radio
.label=${'Selected Radio'}
.value=${true}
.key=${'state-selected'}
.name=${'states'}
></dees-input-radio>
<dees-input-radio
.label=${'Disabled Unchecked'}
.disabled=${true}
.key=${'state-disabled1'}
.name=${'states2'}
></dees-input-radio>
<dees-input-radio
.label=${'Disabled Checked'}
.disabled=${true}
.value=${true}
.key=${'state-disabled2'}
.name=${'states2'}
></dees-input-radio>
</div>
</div>
<div class="demo-section">
<h3>Settings Example</h3>
<p>Common radio button patterns in settings</p>
<div class="radio-group">
<div class="radio-group-title">Theme Preference:</div>
<dees-input-radio .label=${'Light Theme'} .key=${'theme-light'} .name=${'theme'}></dees-input-radio>
<dees-input-radio .label=${'Dark Theme'} .value=${true} .key=${'theme-dark'} .name=${'theme'}></dees-input-radio>
<dees-input-radio .label=${'System Default'} .key=${'theme-system'} .name=${'theme'}></dees-input-radio>
</div>
<div class="radio-group">
<div class="radio-group-title">Notification Frequency:</div>
<dees-input-radio .label=${'All Notifications'} .key=${'notif-all'} .name=${'notifications'}></dees-input-radio>
<dees-input-radio .label=${'Important Only'} .value=${true} .key=${'notif-important'} .name=${'notifications'}></dees-input-radio>
<dees-input-radio .label=${'None'} .key=${'notif-none'} .name=${'notifications'}></dees-input-radio>
</div>
</div>
</div>
</dees-demowrapper>
`;

View File

@ -1,5 +1,6 @@
import {customElement, DeesElement, type TemplateResult, property, html, type CSSResult,} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import {customElement, type TemplateResult, property, html, css, cssManager} from '@design.estate/dees-element';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-radio.demo.js';
declare global {
interface HTMLElementTagNameMap {
@ -8,55 +9,36 @@ declare global {
}
@customElement('dees-input-radio')
export class DeesInputRadio extends DeesElement {
public static demo = () => html`<dees-input-radio></dees-input-radio>`;
export class DeesInputRadio extends DeesInputBase<DeesInputRadio> {
public static demo = demoFunc;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
@property({
type: String,
reflect: true,
})
public key: string;
@property()
public label: string = 'Label';
@property()
public value: boolean = false;
@property({
type: Boolean,
})
public required: boolean = false;
@property({
type: Boolean
})
public disabled: boolean = false;
@property({ type: String })
public name: string = '';
constructor() {
super();
this.labelPosition = 'right'; // Radio buttons default to label on the right
}
public render(): TemplateResult {
return html `
<style>
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
position: relative;
margin: 20px 0px;
}
.maincontainer {
transition: all 0.3s;
display: grid;
grid-template-columns: 25px auto;
padding: 5px 0px;
color: #ccc;
}
@ -65,14 +47,6 @@ export class DeesInputRadio extends DeesElement {
color: #fff;
}
.label {
margin-left: 15px;
line-height: 25px;
font-size: 14px;
font-weight: normal;
}
input:focus {
outline: none;
border-bottom: 1px solid #e4002b;
@ -106,22 +80,56 @@ export class DeesInputRadio extends DeesElement {
height: 10px;
border-radius: 10px;
}
</style>
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
<div class="maincontainer" @click="${this.toggleSelected}">
<div class="checkbox ${this.value ? 'selected' : ''}">
${this.value ? html`<div class="innercircle"></div>`: html``}
</div>
<div class="label">${this.label}</div>
</div>
<dees-label .label=${this.label}></dees-label>
</div>
`;
}
public async toggleSelected () {
this.value = !this.value;
// Radio buttons can only be selected, not deselected by clicking
if (this.value) {
return;
}
// If this radio has a name, find and deselect other radios in the same group
if (this.name) {
// Try to find a form container first, then fall back to document
const container = this.closest('dees-form') ||
this.closest('dees-demowrapper') ||
this.closest('.radio-group')?.parentElement ||
document;
const allRadios = container.querySelectorAll(`dees-input-radio[name="${this.name}"]`);
allRadios.forEach((radio: DeesInputRadio) => {
if (radio !== this && radio.value) {
radio.value = false;
}
});
}
this.value = true;
this.dispatchEvent(new CustomEvent('newValue', {
detail: this.value,
bubbles: true
}));
this.changeSubject.next(this);
}
public getValue(): boolean {
return this.value;
}
public setValue(value: boolean): void {
this.value = value;
}
}

View File

@ -0,0 +1,199 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
background: #f8f9fa;
border-radius: 8px;
padding: 24px;
}
@media (prefers-color-scheme: dark) {
.demo-section {
background: #1a1a1a;
}
}
.demo-section h3 {
margin-top: 0;
margin-bottom: 16px;
color: #0069f2;
font-size: 18px;
}
.demo-section p {
margin-top: 0;
margin-bottom: 16px;
color: #666;
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.demo-section p {
color: #999;
}
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.grid-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 768px) {
.grid-layout {
grid-template-columns: 1fr;
}
}
`}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Basic Text Inputs</h3>
<p>Standard text inputs with labels and descriptions</p>
<dees-input-text
.label=${'Username'}
.value=${'johndoe'}
.key=${'username'}
></dees-input-text>
<dees-input-text
.label=${'Email Address'}
.value=${'john@example.com'}
.description=${'We will never share your email with anyone'}
.key=${'email'}
></dees-input-text>
<dees-input-text
.label=${'Password'}
.isPasswordBool=${true}
.value=${'secret123'}
.key=${'password'}
></dees-input-text>
</div>
<div class="demo-section">
<h3>Horizontal Layout</h3>
<p>Multiple inputs arranged horizontally for compact forms</p>
<div class="horizontal-group">
<dees-input-text
.label=${'First Name'}
.value=${'John'}
.layoutMode=${'horizontal'}
.key=${'firstName'}
></dees-input-text>
<dees-input-text
.label=${'Last Name'}
.value=${'Doe'}
.layoutMode=${'horizontal'}
.key=${'lastName'}
></dees-input-text>
<dees-input-text
.label=${'Age'}
.value=${'28'}
.layoutMode=${'horizontal'}
.key=${'age'}
></dees-input-text>
</div>
</div>
<div class="demo-section">
<h3>Label Positions</h3>
<p>Different label positioning options for various layouts</p>
<dees-input-text
.label=${'Label on Top (Default)'}
.value=${'Standard layout'}
.labelPosition=${'top'}
></dees-input-text>
<dees-input-text
.label=${'Label on Left'}
.value=${'Inline label'}
.labelPosition=${'left'}
></dees-input-text>
<div class="grid-layout">
<dees-input-text
.label=${'City'}
.value=${'New York'}
.labelPosition=${'left'}
></dees-input-text>
<dees-input-text
.label=${'ZIP Code'}
.value=${'10001'}
.labelPosition=${'left'}
></dees-input-text>
</div>
</div>
<div class="demo-section">
<h3>Validation & States</h3>
<p>Different validation states and input configurations</p>
<dees-input-text
.label=${'Required Field'}
.required=${true}
.key=${'requiredField'}
></dees-input-text>
<dees-input-text
.label=${'Disabled Field'}
.value=${'Cannot edit this'}
.disabled=${true}
></dees-input-text>
<dees-input-text
.label=${'Field with Error'}
.value=${'invalid@'}
.validationText=${'Please enter a valid email address'}
.validationState=${'invalid'}
></dees-input-text>
</div>
<div class="demo-section">
<h3>Advanced Features</h3>
<p>Password visibility toggle and other advanced features</p>
<dees-input-text
.label=${'Password with Toggle'}
.isPasswordBool=${true}
.value=${'mySecurePassword123'}
.description=${'Click the eye icon to show/hide password'}
></dees-input-text>
<dees-input-text
.label=${'API Key'}
.isPasswordBool=${true}
.value=${'sk-1234567890abcdef'}
.description=${'Keep this key secure and never share it'}
></dees-input-text>
</div>
</div>
</dees-demowrapper>
`;

View File

@ -1,16 +1,15 @@
import * as colors from './00colors.js';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-text.demo.js';
import {
customElement,
DeesElement,
type TemplateResult,
property,
html,
cssManager,
css,
type CSSResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
declare global {
interface HTMLElementTagNameMap {
@ -19,47 +18,16 @@ declare global {
}
@customElement('dees-input-text')
export class DeesInputText extends DeesElement {
public static demo = () => html`
<dees-input-text .label=${'this is a label'} .value=${'test'}></dees-input-text>
<dees-input-text .isPasswordBool=${true}></dees-input-text>
`;
export class DeesInputText extends DeesInputBase {
public static demo = demoFunc;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<DeesInputText>();
@property({
type: String,
})
public label: string;
@property({
type: String,
})
public description: string;
@property({
type: String,
reflect: true,
})
public key: string;
@property({
type: String,
reflect: true,
})
public value: string = '';
@property({
type: Boolean,
})
public required: boolean = false;
@property({
type: Boolean,
})
public disabled: boolean = false;
@property({
type: Boolean,
reflect: true,
@ -87,6 +55,7 @@ export class DeesInputText extends DeesElement {
validationFunction: (value: string) => boolean;
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
* {
@ -95,9 +64,6 @@ export class DeesInputText extends DeesElement {
:host {
position: relative;
display: grid;
margin: 8px 0px;
margin-bottom: 24px;
z-index: auto;
}
@ -134,7 +100,8 @@ export class DeesInputText extends DeesElement {
input:focus {
outline: none;
border-bottom: 1px solid ${cssManager.bdTheme( colors.bright.blueActive, colors.dark.blueActive)};
border-bottom: 1px solid
${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
cursor: text;
}
@ -151,6 +118,7 @@ export class DeesInputText extends DeesElement {
padding: 4px 0px;
width: 40px;
z-index: 3;
text-align: center;
}
.showPassword:hover {
@ -180,12 +148,14 @@ export class DeesInputText extends DeesElement {
letter-spacing: ${this.isPasswordBool ? '1px' : 'normal'};
color: ${this.goBright ? '#333' : '#ccc'};
}
${this.validationText ? css`
${this.validationText
? css`
.validationContainer {
height: 22px;
opacity: 1;
}
` : css`
`
: css`
.validationContainer {
height: 4px;
padding: 2px !important;
@ -193,17 +163,16 @@ export class DeesInputText extends DeesElement {
}
`}
</style>
<div class="maincontainer">
<div class="input-wrapper">
<dees-label .label=${this.label} .description=${this.description}></dees-label>
<div class="maincontainer">
<input
type="${this.isPasswordBool && !this.showPasswordBool ? 'password' : 'text'}"
.value=${this.value}
@input="${this.updateValue}"
.disabled=${this.disabled}
/>
<div class="validationContainer">
${this.validationText}
</div>
<div class="validationContainer">${this.validationText}</div>
${this.isPasswordBool
? html`
<div class="showPassword" @click=${this.togglePasswordView}>
@ -212,14 +181,12 @@ export class DeesInputText extends DeesElement {
`
: html``}
</div>
</div>
`;
}
firstUpdated() {
const input = this.shadowRoot.querySelector('input');
input.addEventListener('input', (eventArg: InputEvent) => {
});
// Input event handling is already done in updateValue method
}
public async updateValue(eventArg: Event) {
@ -228,16 +195,15 @@ export class DeesInputText extends DeesElement {
this.changeSubject.next(this);
}
public async freeze() {
this.disabled = true;
public getValue(): string {
return this.value;
}
public async unfreeze() {
this.disabled = false;
public setValue(value: string): void {
this.value = value;
}
public async togglePasswordView() {
const domtools = await this.domtoolsPromise;
this.showPasswordBool = !this.showPasswordBool;
console.log(`this.showPasswordBool is: ${this.showPasswordBool}`);
}

View File

@ -1,15 +1,118 @@
import { html } from '@design.estate/dees-element';
import { html, css } from '@design.estate/dees-element';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
.demoContainer {
max-width: 600px;
margin: auto;
padding: 40px;
background: #000;
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.horizontal-group {
display: flex;
gap: 24px;
align-items: flex-start;
}
.info-box {
margin-top: 16px;
padding: 12px;
background: #e3f2fd;
border-radius: 4px;
font-size: 14px;
color: #1976d2;
}
@media (prefers-color-scheme: dark) {
.info-box {
background: #1e3a5f;
color: #90caf9;
}
}
`}
</style>
<div class="demoContainer">
<dees-input-typelist></dees-input-typelist>
<div class="demo-container">
<dees-panel .title=${'Basic Type List'} .subtitle=${'Add and remove items from a list'}>
<dees-input-typelist
.label=${'Tags'}
.description=${'Add tags by typing and pressing Enter'}
.value=${['javascript', 'typescript', 'web-components']}
></dees-input-typelist>
<dees-input-typelist
.label=${'Team Members'}
.description=${'Add email addresses of team members'}
.value=${['alice@example.com', 'bob@example.com']}
></dees-input-typelist>
</dees-panel>
<dees-panel .title=${'Skills & Keywords'} .subtitle=${'Manage lists of skills and keywords'}>
<dees-input-typelist
.label=${'Your Skills'}
.description=${'List your professional skills'}
.value=${['HTML', 'CSS', 'JavaScript', 'Node.js', 'React']}
></dees-input-typelist>
<div class="horizontal-group">
<dees-input-typelist
.label=${'Categories'}
.layoutMode=${'horizontal'}
.value=${['Technology', 'Design', 'Business']}
></dees-input-typelist>
<dees-input-typelist
.label=${'Keywords'}
.layoutMode=${'horizontal'}
.value=${['innovation', 'startup', 'growth']}
></dees-input-typelist>
</div>
</dees-panel>
<dees-panel .title=${'Required & Disabled States'} .subtitle=${'Different input states for validation'}>
<dees-input-typelist
.label=${'Project Dependencies'}
.description=${'List all required npm packages'}
.required=${true}
.value=${['@design.estate/dees-element', '@design.estate/dees-domtools']}
></dees-input-typelist>
<dees-input-typelist
.label=${'System Tags'}
.description=${'These tags are managed by the system'}
.disabled=${true}
.value=${['system', 'protected', 'readonly']}
></dees-input-typelist>
</dees-panel>
<dees-panel .title=${'Article Publishing Form'} .subtitle=${'Complete form with tag management'}>
<dees-form>
<dees-input-text .label=${'Article Title'} .required=${true}></dees-input-text>
<dees-input-text
.label=${'Summary'}
.inputType=${'textarea'}
.description=${'Brief description of the article'}
></dees-input-text>
<dees-input-typelist
.label=${'Tags'}
.description=${'Add relevant tags for better discoverability'}
.value=${['tutorial', 'web-development']}
></dees-input-typelist>
<dees-input-typelist
.label=${'Co-Authors'}
.description=${'Add email addresses of co-authors'}
></dees-input-typelist>
</dees-form>
<div class="info-box">
<strong>Tip:</strong> Type a value and press Enter to add it to the list. Click on any item to remove it.
</div>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -1,44 +1,37 @@
import {
customElement,
DeesElement,
type TemplateResult,
state,
html,
domtools,
property,
css,
cssManager,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { DeesInputBase } from './dees-input-base.js';
const { demoFunc } = await import('./dees-input-typelist.demo.js');
@customElement('dees-input-typelist')
export class DeesInputTypelist extends DeesElement {
export class DeesInputTypelist extends DeesInputBase<DeesInputTypelist> {
public static demo = demoFunc;
// INSTANCE
@property({
type: String,
})
public label: string;
@property({ type: Array })
public value: string[] = [];
@state()
private inputValue: string = '';
constructor() {
super();
}
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
color: ${cssManager.bdTheme('#333', '#fff')};
margin: 8px 0px 24px 0px;
}
.label {
font-size: 14px;
margin-bottom: 8px;
}
.mainbox {
border-radius: 3px;
@ -79,20 +72,89 @@ export class DeesInputTypelist extends DeesElement {
input:focus {
height: 32px;
}
.tag {
display: inline-block;
background: ${cssManager.bdTheme('#e0e0e0', '#444')};
color: ${cssManager.bdTheme('#333', '#fff')};
padding: 4px 8px;
border-radius: 3px;
margin: 2px;
font-size: 12px;
}
.tag .remove {
margin-left: 6px;
cursor: pointer;
opacity: 0.6;
}
.tag .remove:hover {
opacity: 1;
}
`,
];
public render(): TemplateResult {
return html`
<div class="label">${this.label}</div>
<div class="input-wrapper">
<dees-label .label=${this.label} .description=${this.description}></dees-label>
<div class="mainbox">
<div class="tags" @click=${() => {
this.shadowRoot.querySelector('input').focus();
}}>
<div class="notags">No tags yet</div>
${this.value.length === 0
? html`<div class="notags">No tags yet</div>`
: this.value.map(
(tag) => html`
<span class="tag">
${tag}
<span class="remove" @click=${(e: Event) => {
e.stopPropagation();
this.removeTag(tag);
}}>×</span>
</span>
`
)}
</div>
<input
type="text"
placeholder="Type, press Enter to add it..."
.value=${this.inputValue}
@input=${(e: InputEvent) => {
this.inputValue = (e.target as HTMLInputElement).value;
}}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' && this.inputValue.trim()) {
e.preventDefault();
this.addTag(this.inputValue.trim());
}
}}
.disabled=${this.disabled}
/>
</div>
<input type="text" placeholder="Type, press Enter to add it..." />
</div>
`;
}
private addTag(tag: string) {
if (!this.value.includes(tag)) {
this.value = [...this.value, tag];
this.inputValue = '';
this.changeSubject.next(this);
}
}
private removeTag(tag: string) {
this.value = this.value.filter((t) => t !== tag);
this.changeSubject.next(this);
}
public getValue(): string[] {
return this.value;
}
public setValue(value: string[]): void {
this.value = value;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
// Re-export the modular component from the wysiwyg directory
export { DeesInputWysiwyg } from './wysiwyg/dees-input-wysiwyg.js';

View File

@ -93,12 +93,12 @@ export class DeesModal extends DeesElement {
opacity: 0;
width: 480px;
min-height: 120px;
background: #111;
background: ${cssManager.bdTheme('#ffffff', '#111')};
border-radius: 8px;
border: 1px solid #222;
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
transition: all 0.2s;
overflow: hidden;
box-shadow: 0px 2px 5px #00000080;
box-shadow: ${cssManager.bdTheme('0px 2px 10px rgba(0, 0, 0, 0.1)', '0px 2px 5px rgba(0, 0, 0, 0.5)')};
}
.modal.show {
@ -118,7 +118,7 @@ export class DeesModal extends DeesElement {
text-align: center;
font-weight: 600;
font-size: 12px;
border-bottom: 1px solid #222;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
}
.modal .content {
@ -127,7 +127,7 @@ export class DeesModal extends DeesElement {
.modal .bottomButtons {
display: flex;
flex-direction: row;
border-top: 1px solid #222;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
justify-content: flex-end;
}
@ -138,8 +138,10 @@ export class DeesModal extends DeesElement {
line-height: 16px;
text-align: center;
font-size: 14px;
cursor: default;
cursor: pointer;
user-select: none;
transition: all 0.2s;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
}
.modal .bottomButtons .bottomButton:first-child {
@ -151,13 +153,26 @@ export class DeesModal extends DeesElement {
.modal .bottomButtons .bottomButton:hover {
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
color: #ffffff;
}
.modal .bottomButtons .bottomButton:active {
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
color: #ffffff;
}
.modal .bottomButtons .bottomButton:last-child {
border-right: none;
}
.modal .bottomButtons .bottomButton.primary {
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
color: #ffffff;
}
.modal .bottomButtons .bottomButton.primary:hover {
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
}
.modal .bottomButtons .bottomButton.primary:active {
background: ${cssManager.bdTheme(colors.bright.blueMuted, colors.dark.blueMuted)};
}
`,
];
@ -174,8 +189,8 @@ export class DeesModal extends DeesElement {
<div class="content">${this.content}</div>
<div class="bottomButtons">
${this.menuOptions.map(
(actionArg) => html`
<div class="bottomButton" @click=${() => {
(actionArg, index) => html`
<div class="bottomButton ${index === this.menuOptions.length - 1 ? 'primary' : ''} ${actionArg.name === 'OK' ? 'ok' : ''}" @click=${() => {
actionArg.action(this);
}}>${actionArg.name}</div>
`

View File

@ -0,0 +1,81 @@
import { html, css, cssManager } from '@design.estate/dees-element';
export const demoFunc = () => html`
<style>
${css`
.demo-background {
padding: 24px;
background: ${cssManager.bdTheme('#f0f0f0', '#0a0a0a')};
min-height: 100vh;
}
.demo-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.grid-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.grid-layout {
grid-template-columns: 1fr;
}
}
`}
</style>
<div class="demo-background">
<div class="demo-container">
<dees-panel .title=${'Panel Component'}>
<p>The panel component automatically follows the theme and provides consistent styling for grouped content.</p>
<p>It's perfect for creating sections in your application with proper spacing and borders.</p>
</dees-panel>
<dees-panel .title=${'Panel with Subtitle'} .subtitle=${'Additional context information'}>
<p>Panels can have both a title and subtitle to provide more context.</p>
<p>The subtitle appears in a smaller, muted text below the title.</p>
</dees-panel>
<div class="grid-layout">
<dees-panel .title=${'Feature 1'}>
<p>Grid layouts work great with panels for creating dashboards and feature sections.</p>
<dees-button>Action</dees-button>
</dees-panel>
<dees-panel .title=${'Feature 2'}>
<p>Each panel maintains consistent spacing and styling.</p>
<dees-button>Another Action</dees-button>
</dees-panel>
</div>
<dees-panel .title=${'Complex Content'}>
<h4>Nested Elements</h4>
<p>Panels can contain any type of content:</p>
<ul>
<li>Text and paragraphs</li>
<li>Lists and tables</li>
<li>Form inputs</li>
<li>Other components</li>
</ul>
<dees-input-text .label=${'Example Input'} .description=${'Input inside a panel'}></dees-input-text>
<div style="margin-top: 16px;">
<dees-button>Submit</dees-button>
</div>
</dees-panel>
<dees-panel>
<p>Panels work great even without a title for simple content grouping.</p>
<p>They provide visual separation and consistent padding.</p>
</dees-panel>
</div>
</div>
`;

View File

@ -0,0 +1,77 @@
import {
customElement,
DeesElement,
html,
css,
cssManager,
property,
type TemplateResult,
} from '@design.estate/dees-element';
import { demoFunc } from './dees-panel.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-panel': DeesPanel;
}
}
@customElement('dees-panel')
export class DeesPanel extends DeesElement {
public static demo = demoFunc;
@property({ type: String })
public title: string = '';
@property({ type: String })
public subtitle: string = '';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
background: ${cssManager.bdTheme('#ffffff', '#1a1a1a')};
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
border: 1px solid ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.1)')};
}
.title {
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 500;
color: ${cssManager.bdTheme('#0069f2', '#0099ff')};
}
.subtitle {
margin: -12px 0 16px 0;
font-size: 14px;
color: ${cssManager.bdTheme('#666', '#999')};
}
.content {
color: ${cssManager.bdTheme('#333', '#ccc')};
}
/* Remove margins from first and last children */
.content ::slotted(*:first-child) {
margin-top: 0;
}
.content ::slotted(*:last-child) {
margin-bottom: 0;
}
`,
];
public render(): TemplateResult {
return html`
${this.title ? html`<h3 class="title">${this.title}</h3>` : ''}
${this.subtitle ? html`<p class="subtitle">${this.subtitle}</p>` : ''}
<div class="content">
<slot></slot>
</div>
`;
}
}

View File

@ -1,21 +1,293 @@
import { html } from '@design.estate/dees-element';
import { html, DeesElement, customElement, css, cssManager } from '@design.estate/dees-element';
import type { IView } from './dees-simple-appdash.js';
import './dees-form.js';
import './dees-input-text.js';
import './dees-input-checkbox.js';
import './dees-input-dropdown.js';
import './dees-input-radio.js';
import './dees-form-submit.js';
import './dees-statsgrid.js';
import type { IStatsTile } from './dees-statsgrid.js';
// Create demo view components
@customElement('demo-view-dashboard')
class DemoViewDashboard extends DeesElement {
static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
padding: 40px;
}
h1 {
margin: 0 0 20px 0;
color: ${cssManager.bdTheme('#000', '#fff')};
}
dees-statsgrid {
margin-top: 20px;
}
`
];
private statsTiles: IStatsTile[] = [
{
id: 'users',
title: 'Active Users',
value: 1234,
type: 'number',
icon: 'faUsers',
description: '+15% from last week',
color: '#22c55e'
},
{
id: 'pageviews',
title: 'Page Views',
value: 56700,
type: 'number',
icon: 'faEye',
description: '56.7k total views',
color: '#3b82f6'
},
{
id: 'uptime',
title: 'System Uptime',
value: 89,
unit: '%',
type: 'gauge',
icon: 'faServer',
description: 'Last 30 days',
color: '#10b981',
gaugeOptions: {
min: 0,
max: 100,
thresholds: [
{ value: 80, color: '#ef4444' },
{ value: 90, color: '#f59e0b' },
{ value: 100, color: '#10b981' }
]
}
},
{
id: 'response',
title: 'Avg Response Time',
value: 3.2,
unit: 's',
type: 'number',
icon: 'faClock',
description: '-0.5s improvement',
color: '#f59e0b'
},
{
id: 'revenue',
title: 'Monthly Revenue',
value: 48520,
unit: '$',
type: 'trend',
icon: 'faDollarSign',
description: '+8.2% growth',
color: '#22c55e',
trendData: [35000, 38000, 37500, 41000, 39800, 42000, 44100, 43200, 45600, 47100, 46800, 48520]
},
{
id: 'traffic',
title: 'Traffic Trend',
value: 1680,
type: 'trend',
icon: 'faChartLine',
description: 'Last 7 days',
color: '#3b82f6',
trendData: [1200, 1350, 1100, 1450, 1600, 1550, 1680]
}
];
render() {
return html`
<h1>Dashboard</h1>
<p>Welcome to your application dashboard. Here's an overview of your metrics:</p>
<dees-statsgrid
.tiles=${this.statsTiles}
@tile-action=${(e: CustomEvent) => {
console.log('Tile action:', e.detail);
}}
></dees-statsgrid>
`;
}
}
@customElement('demo-view-analytics')
class DemoViewAnalytics extends DeesElement {
static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
padding: 40px;
}
h1 {
margin: 0 0 20px 0;
color: ${cssManager.bdTheme('#000', '#fff')};
}
`
];
render() {
return html`
<h1>Analytics</h1>
<p>This is the analytics view. You can add charts and metrics here.</p>
`;
}
}
@customElement('demo-view-settings')
class DemoViewSettings extends DeesElement {
static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
padding: 40px;
}
h1 {
margin: 0 0 20px 0;
color: ${cssManager.bdTheme('#000', '#fff')};
}
.settings-section {
margin-top: 30px;
}
.settings-section h2 {
font-size: 18px;
margin: 0 0 15px 0;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.horizontal-form-section {
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
padding: 20px;
border-radius: 8px;
margin: 15px 0;
}
`
];
render() {
return html`
<h1>Settings</h1>
<p>Configure your application settings below:</p>
<div class="settings-section">
<h2>General Settings</h2>
<dees-form>
<dees-input-text key="appName" label="Application Name" value="My App"></dees-input-text>
<dees-input-text key="apiEndpoint" label="API Endpoint" value="https://api.example.com"></dees-input-text>
<dees-input-dropdown
key="environment"
label="Environment"
.options=${[
{ option: 'Development', key: 'dev' },
{ option: 'Staging', key: 'staging' },
{ option: 'Production', key: 'prod' }
]}
.selectedOption=${{ option: 'Production', key: 'prod' }}
></dees-input-dropdown>
<dees-input-checkbox key="enableNotifications" label="Enable Notifications" value="true"></dees-input-checkbox>
<dees-input-checkbox key="enableAnalytics" label="Enable Analytics" value="false"></dees-input-checkbox>
<dees-form-submit>Save General Settings</dees-form-submit>
</dees-form>
</div>
<div class="settings-section">
<h2>Display Preferences</h2>
<div class="horizontal-form-section">
<p style="margin-top: 0; margin-bottom: 16px;">Quick display settings using horizontal layout:</p>
<dees-form horizontal-layout>
<dees-input-dropdown
key="theme"
label="Theme"
.enableSearch=${false}
.options=${[
{ option: 'Light', key: 'light' },
{ option: 'Dark', key: 'dark' },
{ option: 'Auto', key: 'auto' }
]}
.selectedOption=${{ option: 'Dark', key: 'dark' }}
></dees-input-dropdown>
<dees-input-dropdown
key="language"
label="Language"
.enableSearch=${false}
.options=${[
{ option: 'English', key: 'en' },
{ option: 'German', key: 'de' },
{ option: 'Spanish', key: 'es' },
{ option: 'French', key: 'fr' }
]}
.selectedOption=${{ option: 'English', key: 'en' }}
></dees-input-dropdown>
<dees-input-checkbox key="compactMode" label="Compact Mode"></dees-input-checkbox>
</dees-form>
</div>
</div>
<div class="settings-section">
<h2>Notification Settings</h2>
<dees-form>
<div style="margin-bottom: 16px;">
<div style="font-weight: 500; margin-bottom: 8px;">Email Frequency:</div>
<dees-input-radio label="Real-time" value="true" key="email-realtime"></dees-input-radio>
<dees-input-radio label="Daily Digest" key="email-daily"></dees-input-radio>
<dees-input-radio label="Weekly Summary" key="email-weekly"></dees-input-radio>
<dees-input-radio label="Never" key="email-never"></dees-input-radio>
</div>
<dees-input-checkbox key="pushNotifications" label="Enable Push Notifications" value="true"></dees-input-checkbox>
<dees-input-checkbox key="soundAlerts" label="Play Sound for Alerts" value="true"></dees-input-checkbox>
<dees-form-submit>Update Notifications</dees-form-submit>
</dees-form>
</div>
`;
}
}
export const demoFunc = () => html`
<style>
body {
margin: 0;
padding: 0;
}
.demo-container {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
</style>
<div class="demo-container">
<dees-simple-appdash
name="My Application"
terminalSetupCommand="echo 'Welcome to the terminal!'"
.viewTabs=${[
{
name: 'View 1',
element: null,
name: 'Dashboard',
iconName: 'home',
element: DemoViewDashboard,
},
{
name: 'View 2',
element: null,
name: 'Analytics',
iconName: 'lineChart',
element: DemoViewAnalytics,
},
{
name: 'View 3',
element: null,
name: 'Settings',
iconName: 'settings',
element: DemoViewSettings,
}
] as IView[]}
>Hello there</dees-simple-appdash>
@logout=${() => {
console.log('Logout event triggered');
alert('Logout clicked!');
}}
@view-select=${(e: CustomEvent) => {
console.log('View selected:', e.detail.view.name);
}}
></dees-simple-appdash>
</div>
`;

View File

@ -1,5 +1,4 @@
import { demoFunc } from './dees-simple-appdash.demo.js';
import * as colors from './00colors.js';
import {
customElement,
@ -14,7 +13,8 @@ import {
state,
domtools,
} from '@design.estate/dees-element';
import { DeesTerminal } from './dees-terminal.js';
import './dees-icon.js';
import type { DeesTerminal } from './dees-terminal.js';
declare global {
interface HTMLElementTagNameMap {
@ -24,6 +24,7 @@ declare global {
export interface IView {
name: string;
iconName?: string;
element: DeesElement['constructor']['prototype'];
}
@ -34,13 +35,17 @@ export class DeesSimpleAppDash extends DeesElement {
// INSTANCE
@property()
public name = 'Dees Simple Login';
public name: string = 'Application Dashboard';
@property()
@property({ type: Array })
public viewTabs: IView[] = [];
@property()
public terminalSetupCommand: string = `pnpm install @serve.zone/cli && clear && servezone info`;
@property({ type: String })
public terminalSetupCommand: string = `echo "Terminal ready"`;
@state()
private selectedView: IView;
public static styles = [
cssManager.defaultStyles,
@ -69,54 +74,110 @@ export class DeesSimpleAppDash extends DeesElement {
top: 0px;
left: 0px;
height: calc(100% - 24px);
width: 200px;
background: ${cssManager.bdTheme('#eeeeeb', '#000')};
border-right: 1px solid ${cssManager.bdTheme('#ccc', '#ffffff20')};
font-size: 14px;
line-height: 32px;
width: 240px;
background: ${cssManager.bdTheme('#fafafa', '#000')};
border-right: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
font-size: 12px;
font-family: 'Geist Sans', sans-serif;
padding: 0px 16px;
z-index: 2;
box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.8);
display: grid;
grid-template-rows: min-content auto min-content;
grid-template-rows: auto min-content;
overflow: hidden;
}
.appbar .viewTabs {
margin-left: -8px;
margin-right: -8px;
.sidebar-header {
padding: 16px 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
display: flex;
flex-direction: column;
align-items: top;
}
.viewTab {
padding: 0px 8px;
}
.viewTab:hover {
background: ${cssManager.bdTheme('#ccc', '#ffffff10')};
color: ${cssManager.bdTheme('#000', '#fff')};
}
.viewTab:active {
background: ${cssManager.bdTheme('#aaa', '#ffffff20')};
color: ${cssManager.bdTheme('#000', '#fff')};
align-items: center;
gap: 8px;
}
.appName {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#000', '#fff')};
white-space: nowrap;
color: ${cssManager.bdTheme('#666', '#999')};
overflow: hidden;
text-overflow: ellipsis;
}
.viewTabs-container {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.viewTabs {
display: flex;
flex-direction: column;
}
.viewTab {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: default;
transition: background 0.1s;
color: ${cssManager.bdTheme('#333', '#ccc')};
user-select: none;
position: relative;
}
.viewTab:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
}
.viewTab:active {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
}
.viewTab.selected {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
color: ${cssManager.bdTheme('#000', '#fff')};
font-weight: 500;
}
.viewTab.selected::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: ${cssManager.bdTheme('#26a69a', '#26a69a')};
}
.viewTab dees-icon {
font-size: 14px;
opacity: 0.7;
}
.appActions {
display: flex;
padding: 12px;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.appActions .action {
.action {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 4px;
cursor: default;
transition: background 0.1s;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.appActions .action:hover {
color: ${cssManager.bdTheme('#000', '#fff')};
.action:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
}
.action dees-icon {
font-size: 14px;
opacity: 0.7;
}
.appcontent {
@ -124,11 +185,11 @@ export class DeesSimpleAppDash extends DeesElement {
position: absolute;
top: 0px;
right: 0px;
height: calc(100vh - 24px);
height: calc(100% - 24px);
bottom: 24px;
width: calc(100vw - 200px);
width: calc(100% - 240px);
overflow: auto;
background: ${cssManager.bdTheme('#eeeeeb', '#000')};
background: ${cssManager.bdTheme('#f5f5f5', '#000')};
overscroll-behavior: contain;
}
@ -138,23 +199,36 @@ export class DeesSimpleAppDash extends DeesElement {
bottom: 0px;
left: 0px;
width: 100%;
border-top: 1px solid #44444480;
border-top: 1px solid ${cssManager.bdTheme('#00000020', '#ffffff20')};
height: 24px;
background: ${cssManager.bdTheme(colors.bright.blueMuted, colors.dark.blueMuted)};
background: ${cssManager.bdTheme('#2196f3', '#1565c0')};
z-index: 2;
display: flex;
justify-content: flex-end;
align-items: center;
flex-direction: row;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
font-size: 12px;
}
.control {
width: min-content;
display: flex;
align-items: center;
gap: 4px;
margin-right: 16px;
font-size: 12px;
white-space: nowrap;
cursor: default;
opacity: 0.8;
transition: opacity 0.2s;
}
.control:hover {
opacity: 1;
}
.control dees-icon {
font-size: 14px;
}
`,
];
@ -162,31 +236,49 @@ export class DeesSimpleAppDash extends DeesElement {
return html`
<div class="maincontainer">
<div class="appbar">
<div>
<div class="sidebar-header">
<dees-icon .icon="lucide:grid3x3" style="font-size: 18px;"></dees-icon>
<div class="appName">${this.name}</div>
</div>
<div class="viewTabs-container">
<div class="viewTabs">
${this.viewTabs.map(
(view) => html`
<div class="viewTab" @click=${() => {
this.loadView(view);
}}>${view.name}</div>
<div
class="viewTab ${this.selectedView === view ? 'selected' : ''}"
@click=${() => this.loadView(view)}
>
${view.iconName ? html`
<dees-icon .icon="${`lucide:${view.iconName}`}"></dees-icon>
` : ''}
<span style="flex: 1;">${view.name}</span>
</div>
`
)}
</div>
</div>
</div>
<div class="appActions">
<div class="action" @click=${() => {
this.dispatchEvent(new CustomEvent('logout'));
}}>Logout</div>
this.dispatchEvent(new CustomEvent('logout', { bubbles: true, composed: true }));
}}>
<dees-icon .icon="lucide:logOut"></dees-icon>
<span>Logout</span>
</div>
</div>
</div>
<div class="appcontent">
<!-- Content goes here -->
</div>
<div class="controlbar">
<div class="control">
<dees-icon .iconFA=${'networkWired'}></dees-icon>
<dees-icon .icon="lucide:wifi"></dees-icon>
<span>Connected</span>
</div>
<div class="control" @click=${this.launchTerminal}>
<dees-icon .iconFA=${'terminal'}></dees-icon>
<dees-icon .icon="lucide:terminal"></dees-icon>
<span>Terminal</span>
</div>
</div>
</div>
@ -196,31 +288,59 @@ export class DeesSimpleAppDash extends DeesElement {
public async firstUpdated(_changedProperties): Promise<void> {
const domtools = await this.domtoolsPromise;
super.firstUpdated(_changedProperties);
if (this.viewTabs && this.viewTabs.length > 0) {
await this.loadView(this.viewTabs[0]);
}
}
public currentTerminal: DeesTerminal;
public async launchTerminal() {
const domtools = await this.domtoolsPromise;
if (this.currentTerminal) {
// If terminal already exists, remove it
await this.closeTerminal();
return;
}
const maincontainer = this.shadowRoot.querySelector('.maincontainer');
const { DeesTerminal } = await import('./dees-terminal.js');
const terminal = new DeesTerminal();
terminal.setupCommand = this.terminalSetupCommand;
this.currentTerminal = terminal;
maincontainer.appendChild(terminal);
terminal.style.position = 'absolute';
terminal.style.zIndex = '1';
terminal.style.zIndex = '10';
terminal.style.top = '0px';
terminal.style.left = '200px';
terminal.style.left = '240px';
terminal.style.right = '0px';
terminal.style.bottom = '24px';
terminal.style.opacity = '0';
terminal.style.transform = 'translateY(20px)';
terminal.style.transition = 'all 0.2s';
await domtools.plugins.smartdelay.delayFor(0);
terminal.style.background = '#000';
terminal.style.boxShadow = '0 4px 20px rgba(0, 0, 0, 0.3)';
// Add close button to terminal
terminal.addEventListener('close', () => this.closeTerminal());
await domtools.convenience.smartdelay.delayFor(0);
terminal.style.opacity = '1';
terminal.style.transform = 'translateY(0px)';
return terminal;
}
private async closeTerminal() {
const domtools = await this.domtoolsPromise;
if (this.currentTerminal) {
this.currentTerminal.style.opacity = '0';
this.currentTerminal.style.transform = 'translateY(20px)';
await domtools.convenience.smartdelay.delayFor(200);
this.currentTerminal.remove();
this.currentTerminal = null;
}
}
private currentView: DeesElement;
public async loadView(viewArg: IView) {
const appcontent = this.shadowRoot.querySelector('.appcontent');
@ -230,5 +350,13 @@ export class DeesSimpleAppDash extends DeesElement {
}
appcontent.appendChild(view);
this.currentView = view;
this.selectedView = viewArg;
// Emit view-select event
this.dispatchEvent(new CustomEvent('view-select', {
detail: { view: viewArg },
bubbles: true,
composed: true
}));
}
}

View File

@ -1,3 +1,37 @@
import { html } from '@design.estate/dees-element';
export const demoFunc = () => html` <dees-simple-login name="someapp"> Hello there </dees-simple-login> `;
export const demoFunc = () => html`
<style>
body {
margin: 0;
padding: 0;
}
.demo-container {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
</style>
<div class="demo-container">
<dees-simple-login
name="My Application"
@login=${(e: CustomEvent) => {
console.log('Login event received:', e.detail);
const loginData = e.detail?.data || e.detail;
if (loginData?.username && loginData?.password) {
alert(`Login attempted with:\nUsername: ${loginData.username}\nPassword: ${loginData.password}`);
// Here you would typically validate credentials and show the slotted content
} else {
console.error('Invalid login data structure:', e.detail);
}
}}
>
<div style="padding: 40px; text-align: center;">
<h1>Welcome!</h1>
<p>This is the slotted content that appears after login.</p>
</div>
</dees-simple-login>
</div>
`;

View File

@ -8,9 +8,6 @@ import {
type TemplateResult,
cssManager,
css,
unsafeCSS,
type CSSResult,
state,
} from '@design.estate/dees-element';
declare global {
@ -26,51 +23,77 @@ export class DeesSimpleLogin extends DeesElement {
// INSTANCE
@property()
public name = 'Dees Simple Login';
public name: string = 'Application';
public static styles = [
cssManager.defaultStyles,
css`
:host {
color: ${cssManager.bdTheme('#333', '#fff')};
color: ${cssManager.bdTheme('#333', '#ccc')};
user-select: none;
display: block;
width: 100%;
height: 100%;
font-family: 'Geist Sans', sans-serif;
}
.loginContainer {
position: absolute;
display: flex;
justify-content: center; /* aligns horizontally */
align-items: center; /* aligns vertically */
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
top: 0px;
left: 0px;
top: 0;
left: 0;
background: ${cssManager.bdTheme('#f5f5f5', '#000')};
}
.slotContainer {
position: absolute;
width: 100%;
height: 100%;
top: 0px;
left: 0px;
top: 0;
left: 0;
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
}
.login {
min-width: 320px;
min-height: 100px;
background: ${cssManager.bdTheme('#eeeeeb', '#111')};
box-shadow: ${cssManager.bdTheme('0px 1px 4px rgba(0,0,0,0.3)', 'none')};
background: ${cssManager.bdTheme('#ffffff', '#111')};
box-shadow: ${cssManager.bdTheme(
'0 4px 12px rgba(0, 0, 0, 0.15)',
'0 4px 12px rgba(0, 0, 0, 0.3)'
)};
border-radius: 8px;
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
padding: 24px;
transition: opacity 0.3s, transform 0.3s;
}
.header {
text-align: center;
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
color: ${cssManager.bdTheme('#000', '#fff')};
}
.slotContainer {
opacity:0;
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
.login dees-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.login dees-input-text {
width: 100%;
}
.login dees-form-submit {
margin-top: 4px;
width: 100%;
}
`,
];
@ -79,11 +102,11 @@ export class DeesSimpleLogin extends DeesElement {
return html`
<div class="loginContainer">
<div class="login">
<dees-form>
<div class="header">Login to ${this.name}</div>
<dees-input-text key="username" label="username" required></dees-input-text>
<dees-input-text key="password" label="password" isPasswordBool required></dees-input-text>
<dees-form-submit disabled>login</dees-form-submit>
<dees-form>
<dees-input-text key="username" label="Username" required></dees-input-text>
<dees-input-text key="password" label="Password" isPasswordBool required></dees-input-text>
<dees-form-submit>Login</dees-form-submit>
</dees-form>
</div>
</div>
@ -93,18 +116,20 @@ export class DeesSimpleLogin extends DeesElement {
`;
}
public async firstUpdated(_changedProperties): Promise<void> {
const domtools = await this.domtoolsPromise;
public async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
super.firstUpdated(_changedProperties);
const form = this.shadowRoot.querySelector('dees-form');
await form.readyDeferred.promise;
const username = this.shadowRoot.querySelector('dees-input-text[label="username"]');
const password = this.shadowRoot.querySelector('dees-input-text[label="password"]');
const submit = this.shadowRoot.querySelector('dees-form-submit');
const form = this.shadowRoot.querySelector('dees-form') as any;
if (form) {
form.addEventListener('formData', (event: CustomEvent) => {
this.dispatchEvent(new CustomEvent('login', { detail: event.detail }));
this.dispatchEvent(new CustomEvent('login', {
detail: event.detail,
bubbles: true,
composed: true
}));
});
}
}
/**
* allows switching to slotted content
@ -123,8 +148,5 @@ export class DeesSimpleLogin extends DeesElement {
slotContainerDiv.style.transform = 'translateY(0px)';
await domtools.convenience.smartdelay.delayFor(300);
slotContainerDiv.style.pointerEvents = 'all';
}
}

View File

@ -1,5 +1,262 @@
import { html } from '@design.estate/dees-element';
import { html, css, cssManager } from '@design.estate/dees-element';
import { DeesToast } from './dees-toast.js';
import './dees-button.js';
export const demoFunc = async () => {
return html`<dees-toast></dees-toast>`;
return html`
<style>
.demo-container {
padding: 32px;
min-height: 100vh;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
}
.demo-section {
margin-bottom: 48px;
}
.demo-title {
font-size: 24px;
font-weight: 600;
margin-bottom: 16px;
color: ${cssManager.bdTheme('#333', '#fff')};
}
.demo-description {
font-size: 14px;
color: ${cssManager.bdTheme('#666', '#aaa')};
margin-bottom: 24px;
}
.button-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.theme-toggle {
position: fixed;
top: 16px;
right: 16px;
z-index: 100;
}
</style>
<div class="demo-container">
<dees-button class="theme-toggle" @clicked=${() => {
document.body.classList.toggle('bright');
}}>Toggle Theme</dees-button>
<div class="demo-section">
<h2 class="demo-title">Toast Types</h2>
<p class="demo-description">
Different toast types for various notification scenarios. Click any button to show a toast.
</p>
<div class="button-grid">
<dees-button @clicked=${() => {
DeesToast.info('This is an informational message');
}}>Info Toast</dees-button>
<dees-button type="highlighted" @clicked=${() => {
DeesToast.success('Operation completed successfully!');
}}>Success Toast</dees-button>
<dees-button @clicked=${() => {
DeesToast.warning('Please review before proceeding');
}}>Warning Toast</dees-button>
<dees-button @clicked=${() => {
DeesToast.error('An error occurred while processing');
}}>Error Toast</dees-button>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Toast Positions</h2>
<p class="demo-description">
Toasts can appear in different positions on the screen.
</p>
<div class="button-grid">
<dees-button @clicked=${() => {
DeesToast.show({
message: 'Top Right Position',
type: 'info',
position: 'top-right'
});
}}>Top Right</dees-button>
<dees-button @clicked=${() => {
DeesToast.show({
message: 'Top Left Position',
type: 'info',
position: 'top-left'
});
}}>Top Left</dees-button>
<dees-button @clicked=${() => {
DeesToast.show({
message: 'Bottom Right Position',
type: 'info',
position: 'bottom-right'
});
}}>Bottom Right</dees-button>
<dees-button @clicked=${() => {
DeesToast.show({
message: 'Bottom Left Position',
type: 'info',
position: 'bottom-left'
});
}}>Bottom Left</dees-button>
<dees-button @clicked=${() => {
DeesToast.show({
message: 'Top Center Position',
type: 'info',
position: 'top-center'
});
}}>Top Center</dees-button>
<dees-button @clicked=${() => {
DeesToast.show({
message: 'Bottom Center Position',
type: 'info',
position: 'bottom-center'
});
}}>Bottom Center</dees-button>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Duration Options</h2>
<p class="demo-description">
Control how long toasts stay visible. Duration in milliseconds.
</p>
<div class="button-grid">
<dees-button @clicked=${() => {
DeesToast.show({
message: 'Quick toast (1 second)',
type: 'info',
duration: 1000
});
}}>1 Second</dees-button>
<dees-button @clicked=${() => {
DeesToast.show({
message: 'Standard toast (3 seconds)',
type: 'info',
duration: 3000
});
}}>3 Seconds (Default)</dees-button>
<dees-button @clicked=${() => {
DeesToast.show({
message: 'Long toast (5 seconds)',
type: 'info',
duration: 5000
});
}}>5 Seconds</dees-button>
<dees-button @clicked=${() => {
DeesToast.show({
message: 'Manual dismiss only (click to close)',
type: 'warning',
duration: 0
});
}}>No Auto-Dismiss</dees-button>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Multiple Toasts</h2>
<p class="demo-description">
Multiple toasts stack automatically. They maintain their order and animate smoothly.
</p>
<div class="button-grid">
<dees-button @clicked=${() => {
DeesToast.info('First notification');
setTimeout(() => DeesToast.success('Second notification'), 200);
setTimeout(() => DeesToast.warning('Third notification'), 400);
setTimeout(() => DeesToast.error('Fourth notification'), 600);
}}>Show Multiple</dees-button>
<dees-button @clicked=${() => {
for (let i = 1; i <= 5; i++) {
setTimeout(() => {
DeesToast.show({
message: `Notification #${i}`,
type: i % 2 === 0 ? 'success' : 'info',
duration: 2000 + (i * 500)
});
}, i * 100);
}
}}>Rapid Fire</dees-button>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Real-World Examples</h2>
<p class="demo-description">
Common use cases for toast notifications in applications.
</p>
<div class="button-grid">
<dees-button @clicked=${async () => {
const toast = await DeesToast.show({
message: 'Saving changes...',
type: 'info',
duration: 0
});
// Simulate save operation
setTimeout(() => {
toast.dismiss();
DeesToast.success('Changes saved successfully!');
}, 2000);
}}>Save Operation</dees-button>
<dees-button @clicked=${() => {
DeesToast.error('Failed to connect to server. Please check your internet connection.');
}}>Network Error</dees-button>
<dees-button @clicked=${() => {
DeesToast.warning('Your session will expire in 5 minutes');
}}>Session Warning</dees-button>
<dees-button @clicked=${() => {
DeesToast.success('File uploaded successfully!');
}}>Upload Complete</dees-button>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Programmatic Control</h2>
<p class="demo-description">
Advanced control over toast behavior.
</p>
<div class="button-grid">
<dees-button @clicked=${async () => {
const toast = await DeesToast.show({
message: 'This toast can be dismissed programmatically',
type: 'info',
duration: 0
});
setTimeout(() => {
toast.dismiss();
DeesToast.success('Toast dismissed after 2 seconds');
}, 2000);
}}>Programmatic Dismiss</dees-button>
<dees-button @clicked=${() => {
// Using the convenience methods
DeesToast.info('Info message', 2000);
setTimeout(() => DeesToast.success('Success message', 2000), 500);
setTimeout(() => DeesToast.warning('Warning message', 2000), 1000);
setTimeout(() => DeesToast.error('Error message', 2000), 1500);
}}>Convenience Methods</dees-button>
</div>
</div>
</div>
`;
};

View File

@ -1,4 +1,4 @@
import { customElement, DeesElement, type TemplateResult, html, type CSSResult, } from '@design.estate/dees-element';
import { customElement, DeesElement, type TemplateResult, html, css, property, cssManager } from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { demoFunc } from './dees-toast.demo.js';
@ -9,20 +9,317 @@ declare global {
}
}
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';
export interface IToastOptions {
message: string;
type?: ToastType;
duration?: number;
position?: ToastPosition;
}
@customElement('dees-toast')
export class DeesToast extends DeesElement {
// STATIC
public static demo = demoFunc;
private static toastContainers = new Map<ToastPosition, HTMLDivElement>();
private static getOrCreateContainer(position: ToastPosition): HTMLDivElement {
if (!this.toastContainers.has(position)) {
const container = document.createElement('div');
container.className = `toast-container toast-container-${position}`;
container.style.cssText = `
position: fixed;
z-index: 10000;
pointer-events: none;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
`;
// Position the container
switch (position) {
case 'top-right':
container.style.top = '0';
container.style.right = '0';
break;
case 'top-left':
container.style.top = '0';
container.style.left = '0';
break;
case 'bottom-right':
container.style.bottom = '0';
container.style.right = '0';
break;
case 'bottom-left':
container.style.bottom = '0';
container.style.left = '0';
break;
case 'top-center':
container.style.top = '0';
container.style.left = '50%';
container.style.transform = 'translateX(-50%)';
break;
case 'bottom-center':
container.style.bottom = '0';
container.style.left = '50%';
container.style.transform = 'translateX(-50%)';
break;
}
document.body.appendChild(container);
this.toastContainers.set(position, container);
}
return this.toastContainers.get(position)!;
}
public static async show(options: IToastOptions | string) {
const opts: IToastOptions = typeof options === 'string'
? { message: options }
: options;
const toast = new DeesToast();
toast.message = opts.message;
toast.type = opts.type || 'info';
toast.duration = opts.duration || 3000;
const container = this.getOrCreateContainer(opts.position || 'top-right');
container.appendChild(toast);
// Trigger animation
await toast.updateComplete;
requestAnimationFrame(() => {
toast.isVisible = true;
});
// Auto dismiss
if (toast.duration > 0) {
setTimeout(() => {
toast.dismiss();
}, toast.duration);
}
return toast;
}
// Convenience methods
public static info(message: string, duration?: number) {
return this.show({ message, type: 'info', duration });
}
public static success(message: string, duration?: number) {
return this.show({ message, type: 'success', duration });
}
public static warning(message: string, duration?: number) {
return this.show({ message, type: 'warning', duration });
}
public static error(message: string, duration?: number) {
return this.show({ message, type: 'error', duration });
}
// INSTANCE
@property({ type: String })
public message: string = '';
@property({ type: String })
public type: ToastType = 'info';
@property({ type: Number })
public duration: number = 3000;
@property({ type: Boolean, reflect: true })
public isVisible: boolean = false;
constructor() {
super();
domtools.elementBasic.setup();
}
public render(): TemplateResult {
return html`
${domtools.elementBasic.styles}
<style></style>
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
pointer-events: auto;
font-family: 'Geist Sans', sans-serif;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
:host([isvisible]) {
opacity: 1;
transform: translateY(0);
}
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-radius: 8px;
background: ${cssManager.bdTheme('#fff', '#222')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
box-shadow: 0 4px 12px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
min-width: 300px;
max-width: 500px;
cursor: pointer;
}
.toast:hover {
transform: scale(1.02);
}
.icon {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.icon svg {
width: 100%;
height: 100%;
}
.message {
flex: 1;
font-size: 14px;
line-height: 1.5;
color: ${cssManager.bdTheme('#333', '#fff')};
}
.close {
flex-shrink: 0;
width: 16px;
height: 16px;
opacity: 0.5;
cursor: pointer;
transition: opacity 0.2s;
}
.close:hover {
opacity: 1;
}
.close svg {
width: 100%;
height: 100%;
fill: currentColor;
}
/* Type-specific styles */
:host([type="info"]) .icon {
color: #0084ff;
}
:host([type="success"]) .icon {
color: #22c55e;
}
:host([type="warning"]) .icon {
color: #f59e0b;
}
:host([type="error"]) .icon {
color: #ef4444;
}
/* Progress bar */
.progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: currentColor;
opacity: 0.2;
border-radius: 0 0 8px 8px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: currentColor;
opacity: 0.8;
transform-origin: left;
animation: progress linear forwards;
}
@keyframes progress {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
`
];
public render(): TemplateResult {
const icons = {
info: html`<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z" clip-rule="evenodd"/>
</svg>`,
success: html`<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>`,
warning: html`<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>`,
error: html`<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>`
};
return html`
<div class="toast" @click=${this.dismiss}>
<div class="icon">
${icons[this.type]}
</div>
<div class="message">${this.message}</div>
<div class="close">
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>
</div>
${this.duration > 0 ? html`
<div class="progress">
<div class="progress-bar" style="animation-duration: ${this.duration}ms"></div>
</div>
` : ''}
</div>
`;
}
public async dismiss() {
this.isVisible = false;
await new Promise(resolve => setTimeout(resolve, 300));
this.remove();
// Clean up empty containers
const container = this.parentElement;
if (container && container.children.length === 0) {
container.remove();
for (const [position, cont] of DeesToast.toastContainers.entries()) {
if (cont === container) {
DeesToast.toastContainers.delete(position);
break;
}
}
}
}
public firstUpdated() {
// Set the type attribute for CSS
this.setAttribute('type', this.type);
}
}

View File

@ -4,8 +4,12 @@ export * from './dees-appui-base.js';
export * from './dees-appui-maincontent.js';
export * from './dees-appui-mainmenu.js';
export * from './dees-appui-mainselector.js';
export * from './dees-appui-profiledropdown.js';
export * from './dees-appui-tabs.js';
export * from './dees-appui-view.js';
export * from './dees-badge.js';
export * from './dees-button-exit.js';
export * from './dees-button-group.js';
export * from './dees-button.js';
export * from './dees-chart-area.js';
export * from './dees-chart-log.js';
@ -27,6 +31,7 @@ export * from './dees-input-fileupload.js';
export * from './dees-input-iban.js';
export * from './dees-input-typelist.js';
export * from './dees-input-phone.js';
export * from './dees-input-wysiwyg.js';
export * from './dees-progressbar.js';
export * from './dees-input-quantityselector.js';
export * from './dees-input-radio.js';
@ -35,6 +40,7 @@ export * from './dees-label.js';
export * from './dees-mobilenavigation.js';
export * from './dees-modal.js';
export * from './dees-input-multitoggle.js';
export * from './dees-panel.js';
export * from './dees-pdf.js';
export * from './dees-searchbar.js';
export * from './dees-simple-appdash.js';

View File

@ -0,0 +1,34 @@
import * as plugins from '../00plugins.js';
/**
* Divider menu item
*/
export interface IAppBarMenuDivider {
divider: true;
}
/**
* Regular menu item
*/
export interface IAppBarMenuItemRegular extends plugins.tsclass.website.IMenuItem {
id?: string;
shortcut?: string; // e.g., "Cmd+S" or "Ctrl+S"
submenu?: IAppBarMenuItem[];
disabled?: boolean;
checked?: boolean; // For checkbox menu items
radioGroup?: string; // For radio button menu items
}
/**
* Extended menu item interface for app bar menus
* Can be either a regular menu item or a divider
*/
export type IAppBarMenuItem = IAppBarMenuItemRegular | IAppBarMenuDivider;
/**
* Interface for the menu bar configuration
*/
export interface IMenuBar {
menuItems: IAppBarMenuItem[];
onMenuSelect?: (item: IAppBarMenuItem) => void;
}

View File

@ -1,2 +1,3 @@
export * from './tab.js';
export * from './selectionoption.js';
export * from './appbarmenuitem.js';

View File

@ -1,4 +1,5 @@
export interface ISelectionOption {
key: string;
iconName?: string;
action: () => void;
}

View File

@ -0,0 +1,78 @@
# WYSIWYG Block Migration Status
## Overview
This document tracks the progress of migrating all WYSIWYG blocks to the new block handler architecture.
## Migration Progress
### ✅ Phase 1: Architecture Foundation
- Created block handler base classes and interfaces
- Created block registry system
- Created common block styles and utilities
### ✅ Phase 2: Divider Block
- Simple non-editable block as proof of concept
- See `phase2-summary.md` for details
### ✅ Phase 3: Paragraph Block
- First text block with full editing capabilities
- Established patterns for text selection, cursor tracking, and content splitting
- See commit history for implementation details
### ✅ Phase 4: Heading Blocks
- All three heading levels (h1, h2, h3) using unified handler
- See `phase4-summary.md` for details
### 🔄 Phase 5: Other Text Blocks (In Progress)
- [ ] Quote block
- [ ] Code block
- [ ] List block
### 📋 Phase 6: Media Blocks (Planned)
- [ ] Image block
- [ ] YouTube block
- [ ] Attachment block
### 📋 Phase 7: Content Blocks (Planned)
- [ ] Markdown block
- [ ] HTML block
## Block Handler Status
| Block Type | Handler Created | Registered | Tested | Notes |
|------------|----------------|------------|---------|-------|
| divider | ✅ | ✅ | ✅ | Complete |
| paragraph | ✅ | ✅ | ✅ | Complete |
| heading-1 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
| heading-2 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
| heading-3 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
| quote | ❌ | ❌ | ❌ | |
| code | ❌ | ❌ | ❌ | |
| list | ❌ | ❌ | ❌ | |
| image | ❌ | ❌ | ❌ | |
| youtube | ❌ | ❌ | ❌ | |
| markdown | ❌ | ❌ | ❌ | |
| html | ❌ | ❌ | ❌ | |
| attachment | ❌ | ❌ | ❌ | |
## Files Modified During Migration
### Core Architecture Files
- `blocks/block.base.ts` - Base handler interface and class
- `blocks/block.registry.ts` - Registry for handlers
- `blocks/block.styles.ts` - Common styles
- `blocks/index.ts` - Main exports
- `wysiwyg.blockregistration.ts` - Registration of all handlers
### Handler Files Created
- `blocks/content/divider.block.ts`
- `blocks/text/paragraph.block.ts`
- `blocks/text/heading.block.ts`
### Main Component Updates
- `dees-wysiwyg-block.ts` - Updated to use registry pattern
## Next Steps
1. Continue with quote block migration
2. Follow established patterns from paragraph/heading handlers
3. Test thoroughly after each migration

View File

@ -0,0 +1,294 @@
# Critical WYSIWYG Knowledge - DO NOT LOSE
This document captures all the hard-won knowledge from our WYSIWYG editor development. These patterns and solutions took significant effort to discover and MUST be preserved during refactoring.
## 1. Static Rendering to Prevent Focus Loss
### Problem
When using Lit's reactive rendering, every state change would cause a re-render, which would:
- Lose cursor position
- Lose focus state
- Interrupt typing
- Break IME (Input Method Editor) support
### Solution
We render blocks **statically** and manage updates imperatively:
```typescript
// In dees-wysiwyg-block.ts
render(): TemplateResult {
if (!this.block) return html``;
// Render empty container - content set in firstUpdated
return html`<div class="wysiwyg-block-container"></div>`;
}
firstUpdated(): void {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container');
if (container && this.block) {
container.innerHTML = this.renderBlockContent();
}
}
```
### Critical Pattern
- NEVER use reactive properties that would trigger re-renders during typing
- Use `shouldUpdate()` to prevent unnecessary renders
- Manage content updates imperatively through event handlers
## 2. Shadow DOM Selection Handling
### Problem
The Web Selection API doesn't work across Shadow DOM boundaries by default. This broke:
- Text selection
- Cursor position tracking
- Formatting detection
- Content splitting for Enter key
### Solution
Use the `getComposedRanges` API with all relevant shadow roots:
```typescript
// From paragraph.block.ts
const wysiwygBlock = element.closest('dees-wysiwyg-block');
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = (wysiwygBlock as any)?.shadowRoot;
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
```
### Critical Pattern
- ALWAYS collect all shadow roots in the hierarchy
- Use `WysiwygSelection` utility methods that handle shadow DOM
- Never use raw `window.getSelection()` without shadow root context
## 3. Cursor Position Tracking
### Problem
Cursor position would be lost during various operations, making it impossible to:
- Split content at the right position for Enter key
- Restore cursor after operations
- Track position for formatting
### Solution
Track cursor position through multiple events and maintain `lastKnownCursorPosition`:
```typescript
// Track on every relevant event
private lastKnownCursorPosition: number = 0;
// In event handlers:
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Fallback when selection not available:
if (!selectionInfo && this.lastKnownCursorPosition !== null) {
// Use last known position
}
```
### Critical Events to Track
- `input` - After text changes
- `keydown` - Before key press
- `keyup` - After key press
- `mouseup` - After mouse selection
- `click` - With setTimeout(0) for browser to set cursor
## 4. Content Splitting for Enter Key
### Problem
Splitting content at cursor position while preserving HTML formatting was complex due to:
- Need to preserve formatting tags
- Shadow DOM complications
- Cursor position accuracy
### Solution
Use Range API to split content while preserving HTML:
```typescript
getSplitContent(): { before: string; after: string } | null {
// Create ranges for before and after cursor
const beforeRange = document.createRange();
const afterRange = document.createRange();
beforeRange.setStart(element, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(element, element.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
return { before: beforeHtml, after: afterHtml };
}
```
## 5. Focus Management
### Problem
Focus would be lost or not properly set due to:
- Timing issues with DOM updates
- Shadow DOM complications
- Browser inconsistencies
### Solution
Use defensive focus management with fallbacks:
```typescript
focus(element: HTMLElement): void {
const block = element.querySelector('.block');
if (!block) return;
// Ensure focusable
if (!block.hasAttribute('contenteditable')) {
block.setAttribute('contenteditable', 'true');
}
block.focus();
// Fallback with microtask if focus failed
if (document.activeElement !== block && element.shadowRoot?.activeElement !== block) {
Promise.resolve().then(() => {
block.focus();
});
}
}
```
## 6. Selection Event Handling for Formatting
### Problem
Need to show formatting menu when text is selected, but selection events don't bubble across Shadow DOM.
### Solution
Dispatch custom events with selection information:
```typescript
// Listen for selection changes
document.addEventListener('selectionchange', () => {
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (selectedText !== this.lastSelectedText) {
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch custom event
this.dispatchSelectionEvent(element, {
text: selectedText,
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
});
// Custom event bubbles through Shadow DOM
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
```
## 7. IME (Input Method Editor) Support
### Problem
Composition events for non-Latin input methods would break without proper handling.
### Solution
Track composition state and handle events:
```typescript
// In dees-input-wysiwyg.ts
public isComposing: boolean = false;
// In block handlers
element.addEventListener('compositionstart', () => {
handlers.onCompositionStart(); // Sets isComposing = true
});
element.addEventListener('compositionend', () => {
handlers.onCompositionEnd(); // Sets isComposing = false
});
// Don't process certain operations during composition
if (this.isComposing) return;
```
## 8. Programmatic Rendering
### Problem
Lit's declarative rendering would cause focus loss and performance issues with many blocks.
### Solution
Render blocks programmatically:
```typescript
public renderBlocksProgrammatically() {
if (!this.editorContentRef) return;
// Clear existing blocks
this.editorContentRef.innerHTML = '';
// Create and append block elements
this.blocks.forEach(block => {
const blockWrapper = this.createBlockElement(block);
this.editorContentRef.appendChild(blockWrapper);
});
}
```
## 9. Block Handler Architecture Requirements
When creating new block handlers, they MUST:
1. **Implement all cursor/selection methods** even if not applicable
2. **Use Shadow DOM-aware selection utilities**
3. **Track cursor position through events**
4. **Handle focus with fallbacks**
5. **Preserve HTML content when getting/setting**
6. **Dispatch selection events for formatting**
7. **Support IME composition events**
8. **Clean up event listeners on disconnect**
## 10. Testing Considerations
### webhelpers.fixture() Issue
The test helper `webhelpers.fixture()` triggers property changes during initialization that can cause null reference errors. Always:
1. Check for null/undefined before accessing nested properties
2. Set required properties in specific order when testing
3. Consider manual element creation for complex test scenarios
## Summary
These patterns represent hours of debugging and problem-solving. When refactoring:
1. **NEVER** remove static rendering approach
2. **ALWAYS** use Shadow DOM-aware selection utilities
3. **MAINTAIN** cursor position tracking through all events
4. **PRESERVE** the complex content splitting logic
5. **KEEP** all focus management fallbacks
6. **ENSURE** selection events bubble through Shadow DOM
7. **SUPPORT** IME composition events
8. **TEST** thoroughly with actual typing, not just unit tests
Any changes that break these patterns will result in a degraded user experience that took significant effort to achieve.

View File

@ -0,0 +1,49 @@
import type { IBlock } from '../wysiwyg.types.js';
export interface IBlockContext {
shadowRoot: ShadowRoot;
component: any; // Reference to the wysiwyg-block component
}
export interface IBlockHandler {
type: string;
render(block: IBlock, isSelected: boolean): string;
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void;
getStyles(): string;
getPlaceholder?(): string;
// Optional methods for editable blocks - now with context
getContent?(element: HTMLElement, context?: IBlockContext): string;
setContent?(element: HTMLElement, content: string, context?: IBlockContext): void;
getCursorPosition?(element: HTMLElement, context?: IBlockContext): number | null;
setCursorToStart?(element: HTMLElement, context?: IBlockContext): void;
setCursorToEnd?(element: HTMLElement, context?: IBlockContext): void;
focus?(element: HTMLElement, context?: IBlockContext): void;
focusWithCursor?(element: HTMLElement, position: 'start' | 'end' | number, context?: IBlockContext): void;
getSplitContent?(element: HTMLElement, context?: IBlockContext): { before: string; after: string } | null;
}
export interface IBlockEventHandlers {
onInput: (e: InputEvent) => void;
onKeyDown: (e: KeyboardEvent) => void;
onFocus: () => void;
onBlur: () => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onMouseUp?: (e: MouseEvent) => void;
}
export abstract class BaseBlockHandler implements IBlockHandler {
abstract type: string;
abstract render(block: IBlock, isSelected: boolean): string;
// Default implementation for common setup
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
// Common setup logic
}
// Common styles can be defined here
getStyles(): string {
return '';
}
}

View File

@ -0,0 +1,17 @@
import type { IBlockHandler } from './block.base.js';
export class BlockRegistry {
private static handlers = new Map<string, IBlockHandler>();
static register(type: string, handler: IBlockHandler): void {
this.handlers.set(type, handler);
}
static getHandler(type: string): IBlockHandler | undefined {
return this.handlers.get(type);
}
static getAllTypes(): string[] {
return Array.from(this.handlers.keys());
}
}

View File

@ -0,0 +1,64 @@
/**
* Common styles shared across all block types
*/
export const commonBlockStyles = `
/* Common block spacing and layout */
/* TODO: Extract common spacing from existing blocks */
/* Common focus states */
/* TODO: Extract common focus styles */
/* Common selected states */
/* TODO: Extract common selection styles */
/* Common hover states */
/* TODO: Extract common hover styles */
/* Common transition effects */
/* TODO: Extract common transitions */
/* Common placeholder styles */
/* TODO: Extract common placeholder styles */
/* Common error states */
/* TODO: Extract common error styles */
/* Common loading states */
/* TODO: Extract common loading styles */
`;
/**
* Helper function to generate consistent block classes
*/
export const getBlockClasses = (
type: string,
isSelected: boolean,
additionalClasses: string[] = []
): string => {
const classes = ['block', type];
if (isSelected) {
classes.push('selected');
}
classes.push(...additionalClasses);
return classes.join(' ');
};
/**
* Helper function to generate consistent data attributes
*/
export const getBlockDataAttributes = (
blockId: string,
blockType: string,
additionalAttributes: Record<string, string> = {}
): string => {
const attributes = {
'data-block-id': blockId,
'data-block-type': blockType,
...additionalAttributes
};
return Object.entries(attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
};

View File

@ -0,0 +1,80 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
export class DividerBlockHandler extends BaseBlockHandler {
type = 'divider';
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
return `
<div class="block divider${selectedClass}" data-block-id="${block.id}" data-block-type="${block.type}" tabindex="0">
<hr>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const dividerBlock = element.querySelector('.block.divider') as HTMLDivElement;
if (!dividerBlock) return;
// Handle click to select
dividerBlock.addEventListener('click', (e) => {
e.stopPropagation();
// Focus will trigger the selection
dividerBlock.focus();
// Ensure focus handler is called immediately
handlers.onFocus?.();
});
// Handle focus/blur
dividerBlock.addEventListener('focus', () => {
handlers.onFocus?.();
});
dividerBlock.addEventListener('blur', () => {
handlers.onBlur?.();
});
// Handle keyboard events
dividerBlock.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
// Let the keyboard handler in the parent component handle the deletion
handlers.onKeyDown?.(e);
} else {
// Handle navigation keys
handlers.onKeyDown?.(e);
}
});
}
getStyles(): string {
return `
.block.divider {
padding: 8px 0;
margin: 16px 0;
cursor: pointer;
position: relative;
border-radius: 4px;
transition: all 0.15s ease;
}
.block.divider:focus {
outline: none;
}
.block.divider.selected {
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.05)', 'rgba(77, 148, 255, 0.08)')};
box-shadow: inset 0 0 0 2px ${cssManager.bdTheme('rgba(0, 102, 204, 0.2)', 'rgba(77, 148, 255, 0.2)')};
}
.block.divider hr {
border: none;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
margin: 0;
pointer-events: none;
}
`;
}
}

View File

@ -0,0 +1,46 @@
/**
* Main exports for the blocks module
*/
// Core interfaces and base classes
export {
type IBlockHandler,
type IBlockEventHandlers,
BaseBlockHandler
} from './block.base.js';
// Block registry for registration and retrieval
export { BlockRegistry } from './block.registry.js';
// Common styles and helpers
export {
commonBlockStyles,
getBlockClasses,
getBlockDataAttributes
} from './block.styles.js';
// Text block handlers
export { ParagraphBlockHandler } from './text/paragraph.block.js';
export { HeadingBlockHandler } from './text/heading.block.js';
// TODO: Export when implemented
// export { QuoteBlockHandler } from './text/quote.block.js';
// export { CodeBlockHandler } from './text/code.block.js';
// export { ListBlockHandler } from './text/list.block.js';
// Media block handlers
// TODO: Export when implemented
// export { ImageBlockHandler } from './media/image.block.js';
// export { YoutubeBlockHandler } from './media/youtube.block.js';
// export { AttachmentBlockHandler } from './media/attachment.block.js';
// Content block handlers
export { DividerBlockHandler } from './content/divider.block.js';
// TODO: Export when implemented
// export { MarkdownBlockHandler } from './content/markdown.block.js';
// export { HtmlBlockHandler } from './content/html.block.js';
// Utilities
// TODO: Export when implemented
// export * from './utils/file.utils.js';
// export * from './utils/media.utils.js';
// export * from './utils/markdown.utils.js';

View File

@ -0,0 +1,411 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class CodeBlockHandler extends BaseBlockHandler {
type = 'code';
// Track cursor position
private lastKnownCursorPosition: number = 0;
render(block: IBlock, isSelected: boolean): string {
const language = block.metadata?.language || 'plain text';
const selectedClass = isSelected ? ' selected' : '';
console.log('CodeBlockHandler.render:', { blockId: block.id, isSelected, content: block.content, language });
return `
<div class="code-block-container">
<div class="code-language">${language}</div>
<div
class="block code${selectedClass}"
contenteditable="true"
data-block-id="${block.id}"
data-block-type="${block.type}"
spellcheck="false"
></div>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) {
console.error('CodeBlockHandler.setup: No code block element found');
return;
}
console.log('CodeBlockHandler.setup: Setting up code block', { blockId: block.id });
// Set initial content if needed - use textContent for code blocks
if (block.content && !codeBlock.textContent) {
codeBlock.textContent = block.content;
}
// Input handler
codeBlock.addEventListener('input', (e) => {
console.log('CodeBlockHandler: Input event', { blockId: block.id });
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Keydown handler
codeBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Special handling for Tab key in code blocks
if (e.key === 'Tab') {
e.preventDefault();
// Insert two spaces for tab
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(' ');
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
// Trigger input event
handlers.onInput(new InputEvent('input'));
}
return;
}
handlers.onKeyDown(e);
});
// Focus handler
codeBlock.addEventListener('focus', () => {
console.log('CodeBlockHandler: Focus event', { blockId: block.id });
handlers.onFocus();
});
// Blur handler
codeBlock.addEventListener('blur', () => {
console.log('CodeBlockHandler: Blur event', { blockId: block.id });
handlers.onBlur();
});
// Composition handlers for IME support
codeBlock.addEventListener('compositionstart', () => {
console.log('CodeBlockHandler: Composition start', { blockId: block.id });
handlers.onCompositionStart();
});
codeBlock.addEventListener('compositionend', () => {
console.log('CodeBlockHandler: Composition end', { blockId: block.id });
handlers.onCompositionEnd();
});
// Mouse up handler
codeBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
codeBlock.addEventListener('click', (e: MouseEvent) => {
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler for cursor tracking
codeBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Paste handler - handle as plain text
codeBlock.addEventListener('paste', (e) => {
e.preventDefault();
const text = e.clipboardData?.getData('text/plain');
if (text) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(text);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
// Trigger input event
handlers.onInput(new InputEvent('input'));
}
}
});
}
getStyles(): string {
return `
/* Code block specific styles */
.code-block-container {
position: relative;
margin: 20px 0;
}
.block.code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
padding: 16px 20px;
padding-top: 32px;
border-radius: 6px;
white-space: pre-wrap;
color: ${cssManager.bdTheme('#24292e', '#e1e4e8')};
line-height: 1.5;
overflow-x: auto;
margin: 0;
}
.code-language {
position: absolute;
top: 0;
right: 0;
background: ${cssManager.bdTheme('#e1e4e8', '#333333')};
color: ${cssManager.bdTheme('#586069', '#8b949e')};
padding: 4px 12px;
font-size: 12px;
border-radius: 0 6px 0 6px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
text-transform: lowercase;
z-index: 1;
}
`;
}
getPlaceholder(): string {
return '';
}
// Helper methods for code functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
// Get the actual code element
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) {
console.log('CodeBlockHandler.getCursorPosition: No code element found');
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
return null;
}
if (!WysiwygSelection.containsAcrossShadowDOM(codeBlock, selectionInfo.startContainer)) {
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(codeBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
return position;
}
getContent(element: HTMLElement, context?: any): string {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return '';
// For code blocks, get textContent to avoid HTML formatting
const content = codeBlock.textContent || '';
console.log('CodeBlockHandler.getContent:', content);
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === codeBlock ||
element.shadowRoot?.activeElement === codeBlock;
// Use textContent for code blocks
codeBlock.textContent = content;
// Restore focus if we had it
if (hadFocus) {
codeBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (codeBlock) {
WysiwygBlocks.setCursorToStart(codeBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (codeBlock) {
WysiwygBlocks.setCursorToEnd(codeBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return;
// Ensure the element is focusable
if (!codeBlock.hasAttribute('contenteditable')) {
codeBlock.setAttribute('contenteditable', 'true');
}
codeBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== codeBlock && element.shadowRoot?.activeElement !== codeBlock) {
Promise.resolve().then(() => {
codeBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return;
// Ensure element is focusable first
if (!codeBlock.hasAttribute('contenteditable')) {
codeBlock.setAttribute('contenteditable', 'true');
}
// Focus the element
codeBlock.focus();
// Set cursor position after focus is established
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end') {
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(codeBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === codeBlock || element.shadowRoot?.activeElement === codeBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === codeBlock || element.shadowRoot?.activeElement === codeBlock) {
setCursor();
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = codeBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(codeBlock, selectionInfo.startContainer)) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = codeBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position
const cursorPos = this.getCursorPosition(element, context);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
return {
before: '',
after: codeBlock.textContent || ''
};
}
// For code blocks, split based on text content only
const fullText = codeBlock.textContent || '';
return {
before: fullText.substring(0, cursorPos),
after: fullText.substring(cursorPos)
};
}
}

View File

@ -0,0 +1,610 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class HeadingBlockHandler extends BaseBlockHandler {
type: string;
private level: 1 | 2 | 3;
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
constructor(type: 'heading-1' | 'heading-2' | 'heading-3') {
super();
this.type = type;
this.level = parseInt(type.split('-')[1]) as 1 | 2 | 3;
}
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const placeholder = this.getPlaceholder();
console.log('HeadingBlockHandler.render:', { blockId: block.id, isSelected, content: block.content, level: this.level });
return `
<div
class="block heading-${this.level}${selectedClass}"
contenteditable="true"
data-placeholder="${placeholder}"
data-block-id="${block.id}"
data-block-type="${block.type}"
></div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) {
console.error('HeadingBlockHandler.setup: No heading block element found');
return;
}
console.log('HeadingBlockHandler.setup: Setting up heading block', { blockId: block.id, level: this.level });
// Set initial content if needed
if (block.content && !headingBlock.innerHTML) {
headingBlock.innerHTML = block.content;
}
// Input handler with cursor tracking
headingBlock.addEventListener('input', (e) => {
console.log('HeadingBlockHandler: Input event', { blockId: block.id });
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('HeadingBlockHandler: Updated cursor position after input', { pos });
}
});
// Keydown handler with cursor tracking
headingBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('HeadingBlockHandler: Cursor position before keydown', { pos, key: e.key });
}
handlers.onKeyDown(e);
});
// Focus handler
headingBlock.addEventListener('focus', () => {
console.log('HeadingBlockHandler: Focus event', { blockId: block.id });
handlers.onFocus();
});
// Blur handler
headingBlock.addEventListener('blur', () => {
console.log('HeadingBlockHandler: Blur event', { blockId: block.id });
handlers.onBlur();
});
// Composition handlers for IME support
headingBlock.addEventListener('compositionstart', () => {
console.log('HeadingBlockHandler: Composition start', { blockId: block.id });
handlers.onCompositionStart();
});
headingBlock.addEventListener('compositionend', () => {
console.log('HeadingBlockHandler: Composition end', { blockId: block.id });
handlers.onCompositionEnd();
});
// Mouse up handler
headingBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('HeadingBlockHandler: Cursor position after mouseup', { pos });
}
// Selection will be handled by selectionchange event
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
headingBlock.addEventListener('click', (e: MouseEvent) => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('HeadingBlockHandler: Cursor position after click', { pos });
}
}, 0);
});
// Keyup handler for additional cursor tracking
headingBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('HeadingBlockHandler: Cursor position after keyup', { pos, key: e.key });
}
});
// Set up selection change handler
this.setupSelectionHandler(element, headingBlock, block);
}
private setupSelectionHandler(element: HTMLElement, headingBlock: HTMLDivElement, block: IBlock): void {
// Add selection change handler
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
// Clear selection if no text
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root - in setup, we need to traverse
const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
// Get selection info using our Shadow DOM-aware utility
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Check if selection is within this block
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
console.log('HeadingBlockHandler: Text selected', {
text: selectedText,
blockId: block.id
});
// Create range and get rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch event
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
this.selectionHandler = checkSelection;
// Clean up on disconnect (will be called by dees-wysiwyg-block)
const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
// Return styles for all heading levels
return `
.block.heading-1 {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
margin: 24px 0 8px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.heading-2 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
margin: 20px 0 6px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.heading-3 {
font-size: 20px;
font-weight: 600;
line-height: 1.4;
margin: 16px 0 4px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
`;
}
getPlaceholder(): string {
switch(this.level) {
case 1:
return 'Heading 1';
case 2:
return 'Heading 2';
case 3:
return 'Heading 3';
default:
return 'Heading';
}
}
/**
* Helper to get the last text node in an element
*/
private getLastTextNode(element: Node): Text | null {
if (element.nodeType === Node.TEXT_NODE) {
return element as Text;
}
for (let i = element.childNodes.length - 1; i >= 0; i--) {
const lastText = this.getLastTextNode(element.childNodes[i]);
if (lastText) return lastText;
}
return null;
}
// Helper methods for heading functionality (mostly the same as paragraph)
getCursorPosition(element: HTMLElement, context?: any): number | null {
// Get the actual heading element
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) {
console.log('HeadingBlockHandler.getCursorPosition: No heading element found');
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('HeadingBlockHandler.getCursorPosition: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('HeadingBlockHandler.getCursorPosition: No selection found');
return null;
}
console.log('HeadingBlockHandler.getCursorPosition: Range info:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
collapsed: selectionInfo.collapsed,
startContainerText: selectionInfo.startContainer.textContent
});
if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
console.log('HeadingBlockHandler.getCursorPosition: Range not in element');
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(headingBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
console.log('HeadingBlockHandler.getCursorPosition: Calculated position:', {
position,
preCaretText: preCaretRange.toString(),
elementText: headingBlock.textContent,
elementTextLength: headingBlock.textContent?.length
});
return position;
}
getContent(element: HTMLElement, context?: any): string {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return '';
// For headings, get the innerHTML which includes formatting tags
const content = headingBlock.innerHTML || '';
console.log('HeadingBlockHandler.getContent:', content);
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === headingBlock ||
element.shadowRoot?.activeElement === headingBlock;
headingBlock.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
headingBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (headingBlock) {
WysiwygBlocks.setCursorToStart(headingBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (headingBlock) {
WysiwygBlocks.setCursorToEnd(headingBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return;
// Ensure the element is focusable
if (!headingBlock.hasAttribute('contenteditable')) {
headingBlock.setAttribute('contenteditable', 'true');
}
headingBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== headingBlock && element.shadowRoot?.activeElement !== headingBlock) {
Promise.resolve().then(() => {
headingBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) return;
// Ensure element is focusable first
if (!headingBlock.hasAttribute('contenteditable')) {
headingBlock.setAttribute('contenteditable', 'true');
}
// For 'end' position, we need to set up selection before focus to prevent browser default
if (position === 'end' && headingBlock.textContent && headingBlock.textContent.length > 0) {
// Set up the selection first
const sel = window.getSelection();
if (sel) {
const range = document.createRange();
const lastNode = this.getLastTextNode(headingBlock) || headingBlock;
if (lastNode.nodeType === Node.TEXT_NODE) {
range.setStart(lastNode, lastNode.textContent?.length || 0);
range.setEnd(lastNode, lastNode.textContent?.length || 0);
} else {
range.selectNodeContents(lastNode);
range.collapse(false);
}
sel.removeAllRanges();
sel.addRange(range);
}
}
// Now focus the element
headingBlock.focus();
// Set cursor position after focus is established (for non-end positions)
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end' && (!headingBlock.textContent || headingBlock.textContent.length === 0)) {
// Only call setCursorToEnd for empty blocks
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(headingBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
setCursor();
} else {
// Try again with a small delay - sometimes focus needs more time
setTimeout(() => {
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
setCursor();
}
}, 10);
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
console.log('HeadingBlockHandler.getSplitContent: Starting...');
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
if (!headingBlock) {
console.log('HeadingBlockHandler.getSplitContent: No heading element found');
return null;
}
console.log('HeadingBlockHandler.getSplitContent: Element info:', {
innerHTML: headingBlock.innerHTML,
textContent: headingBlock.textContent,
textLength: headingBlock.textContent?.length
});
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('HeadingBlockHandler.getSplitContent: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('HeadingBlockHandler.getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = headingBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
console.log('HeadingBlockHandler.getSplitContent: Splitting with last known position:', {
pos,
fullTextLength: fullText.length,
before: fullText.substring(0, pos),
after: fullText.substring(pos)
});
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
console.log('HeadingBlockHandler.getSplitContent: Selection range:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
startContainerInElement: headingBlock.contains(selectionInfo.startContainer)
});
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
console.log('HeadingBlockHandler.getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = headingBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position first
const cursorPos = this.getCursorPosition(element, context);
console.log('HeadingBlockHandler.getSplitContent: Cursor position for HTML split:', cursorPos);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
console.log('HeadingBlockHandler.getSplitContent: Cursor at start or null, moving all content');
return {
before: '',
after: headingBlock.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange();
const afterRange = document.createRange();
// Before range: from start of element to cursor
beforeRange.setStart(headingBlock, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(headingBlock, headingBlock.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
console.log('HeadingBlockHandler.getSplitContent: Final split result:', {
cursorPos,
beforeHtml,
beforeLength: beforeHtml.length,
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
afterHtml,
afterLength: afterHtml.length,
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
});
return {
before: beforeHtml,
after: afterHtml
};
}
}

View File

@ -0,0 +1,458 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class ListBlockHandler extends BaseBlockHandler {
type = 'list';
// Track cursor position and list state
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const listType = block.metadata?.listType || 'unordered';
const listTag = listType === 'ordered' ? 'ol' : 'ul';
console.log('ListBlockHandler.render:', { blockId: block.id, isSelected, content: block.content, listType });
// Render list content
const listContent = this.renderListContent(block.content, block.metadata);
return `
<div
class="block list${selectedClass}"
contenteditable="true"
data-block-id="${block.id}"
data-block-type="${block.type}"
>${listContent}</div>
`;
}
private renderListContent(content: string | undefined, metadata: any): string {
if (!content) return '<ul><li></li></ul>';
const listType = metadata?.listType || 'unordered';
const listTag = listType === 'ordered' ? 'ol' : 'ul';
// Split content by newlines to create list items
const lines = content.split('\n').filter(line => line.trim());
if (lines.length === 0) {
return `<${listTag}><li></li></${listTag}>`;
}
const listItems = lines.map(line => `<li>${line}</li>`).join('');
return `<${listTag}>${listItems}</${listTag}>`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) {
console.error('ListBlockHandler.setup: No list block element found');
return;
}
console.log('ListBlockHandler.setup: Setting up list block', { blockId: block.id });
// Set initial content if needed
if (block.content && !listBlock.innerHTML) {
listBlock.innerHTML = this.renderListContent(block.content, block.metadata);
}
// Input handler
listBlock.addEventListener('input', (e) => {
console.log('ListBlockHandler: Input event', { blockId: block.id });
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Keydown handler
listBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Special handling for Enter key in lists
if (e.key === 'Enter' && !e.shiftKey) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const currentLi = range.startContainer.parentElement?.closest('li');
if (currentLi && currentLi.textContent === '') {
// Empty list item - exit list mode
e.preventDefault();
handlers.onKeyDown(e);
return;
}
// Otherwise, let browser create new list item naturally
}
}
handlers.onKeyDown(e);
});
// Focus handler
listBlock.addEventListener('focus', () => {
console.log('ListBlockHandler: Focus event', { blockId: block.id });
handlers.onFocus();
});
// Blur handler
listBlock.addEventListener('blur', () => {
console.log('ListBlockHandler: Blur event', { blockId: block.id });
handlers.onBlur();
});
// Composition handlers for IME support
listBlock.addEventListener('compositionstart', () => {
console.log('ListBlockHandler: Composition start', { blockId: block.id });
handlers.onCompositionStart();
});
listBlock.addEventListener('compositionend', () => {
console.log('ListBlockHandler: Composition end', { blockId: block.id });
handlers.onCompositionEnd();
});
// Mouse up handler
listBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onMouseUp?.(e);
});
// Click handler
listBlock.addEventListener('click', (e: MouseEvent) => {
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler
listBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set up selection handler
this.setupSelectionHandler(element, listBlock, block);
}
private setupSelectionHandler(element: HTMLElement, listBlock: HTMLDivElement, block: IBlock): void {
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root
const wysiwygBlock = (listBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
document.addEventListener('selectionchange', checkSelection);
this.selectionHandler = checkSelection;
// Cleanup on disconnect
const wysiwygBlock = (listBlock.getRootNode() as ShadowRoot).host as any;
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
return `
/* List specific styles */
.block.list {
padding: 0;
}
.block.list ul,
.block.list ol {
margin: 0;
padding-left: 24px;
}
.block.list li {
margin: 4px 0;
line-height: 1.6;
}
.block.list li:last-child {
margin-bottom: 0;
}
`;
}
getPlaceholder(): string {
return '';
}
// Helper methods for list functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return null;
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return null;
if (!WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.startContainer)) {
return null;
}
// For lists, calculate position based on text content
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(listBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
return preCaretRange.toString().length;
}
getContent(element: HTMLElement, context?: any): string {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return '';
// Extract text content from list items
const listItems = listBlock.querySelectorAll('li');
const content = Array.from(listItems)
.map(li => li.textContent || '')
.join('\n');
console.log('ListBlockHandler.getContent:', content);
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
const hadFocus = document.activeElement === listBlock ||
element.shadowRoot?.activeElement === listBlock;
// Get current metadata to preserve list type
const listElement = listBlock.querySelector('ul, ol');
const isOrdered = listElement?.tagName === 'OL';
// Update content
listBlock.innerHTML = this.renderListContent(content, { listType: isOrdered ? 'ordered' : 'unordered' });
if (hadFocus) {
listBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
const firstLi = listBlock.querySelector('li');
if (firstLi) {
const textNode = this.getFirstTextNode(firstLi);
if (textNode) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
const lastLi = listBlock.querySelector('li:last-child');
if (lastLi) {
const textNode = this.getLastTextNode(lastLi);
if (textNode) {
const range = document.createRange();
const selection = window.getSelection();
const textLength = textNode.textContent?.length || 0;
range.setStart(textNode, textLength);
range.setEnd(textNode, textLength);
selection?.removeAllRanges();
selection?.addRange(range);
}
}
}
private getFirstTextNode(element: Node): Text | null {
if (element.nodeType === Node.TEXT_NODE) {
return element as Text;
}
for (let i = 0; i < element.childNodes.length; i++) {
const firstText = this.getFirstTextNode(element.childNodes[i]);
if (firstText) return firstText;
}
return null;
}
private getLastTextNode(element: Node): Text | null {
if (element.nodeType === Node.TEXT_NODE) {
return element as Text;
}
for (let i = element.childNodes.length - 1; i >= 0; i--) {
const lastText = this.getLastTextNode(element.childNodes[i]);
if (lastText) return lastText;
}
return null;
}
focus(element: HTMLElement, context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
if (!listBlock.hasAttribute('contenteditable')) {
listBlock.setAttribute('contenteditable', 'true');
}
listBlock.focus();
if (document.activeElement !== listBlock && element.shadowRoot?.activeElement !== listBlock) {
Promise.resolve().then(() => {
listBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return;
if (!listBlock.hasAttribute('contenteditable')) {
listBlock.setAttribute('contenteditable', 'true');
}
listBlock.focus();
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end') {
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// For numeric positions in lists, we need custom logic
// This is complex due to list structure, so default to end
this.setCursorToEnd(element, context);
}
};
if (document.activeElement === listBlock || element.shadowRoot?.activeElement === listBlock) {
setCursor();
} else {
Promise.resolve().then(() => {
if (document.activeElement === listBlock || element.shadowRoot?.activeElement === listBlock) {
setCursor();
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
if (!listBlock) return null;
// For lists, we don't split content - instead let the keyboard handler
// create a new paragraph block when Enter is pressed on empty list item
return null;
}
}

View File

@ -0,0 +1,582 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class ParagraphBlockHandler extends BaseBlockHandler {
type = 'paragraph';
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const placeholder = this.getPlaceholder();
console.log('ParagraphBlockHandler.render:', { blockId: block.id, isSelected, content: block.content });
return `
<div
class="block paragraph${selectedClass}"
contenteditable="true"
data-placeholder="${placeholder}"
data-block-id="${block.id}"
data-block-type="${block.type}"
></div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) {
console.error('ParagraphBlockHandler.setup: No paragraph block element found');
return;
}
console.log('ParagraphBlockHandler.setup: Setting up paragraph block', { blockId: block.id });
// Set initial content if needed
if (block.content && !paragraphBlock.innerHTML) {
paragraphBlock.innerHTML = block.content;
}
// Input handler with cursor tracking
paragraphBlock.addEventListener('input', (e) => {
console.log('ParagraphBlockHandler: Input event', { blockId: block.id });
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('ParagraphBlockHandler: Updated cursor position after input', { pos });
}
});
// Keydown handler with cursor tracking
paragraphBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('ParagraphBlockHandler: Cursor position before keydown', { pos, key: e.key });
}
handlers.onKeyDown(e);
});
// Focus handler
paragraphBlock.addEventListener('focus', () => {
console.log('ParagraphBlockHandler: Focus event', { blockId: block.id });
handlers.onFocus();
});
// Blur handler
paragraphBlock.addEventListener('blur', () => {
console.log('ParagraphBlockHandler: Blur event', { blockId: block.id });
handlers.onBlur();
});
// Composition handlers for IME support
paragraphBlock.addEventListener('compositionstart', () => {
console.log('ParagraphBlockHandler: Composition start', { blockId: block.id });
handlers.onCompositionStart();
});
paragraphBlock.addEventListener('compositionend', () => {
console.log('ParagraphBlockHandler: Composition end', { blockId: block.id });
handlers.onCompositionEnd();
});
// Mouse up handler
paragraphBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('ParagraphBlockHandler: Cursor position after mouseup', { pos });
}
// Selection will be handled by selectionchange event
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
paragraphBlock.addEventListener('click', (e: MouseEvent) => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('ParagraphBlockHandler: Cursor position after click', { pos });
}
}, 0);
});
// Keyup handler for additional cursor tracking
paragraphBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('ParagraphBlockHandler: Cursor position after keyup', { pos, key: e.key });
}
});
// Set up selection change handler
this.setupSelectionHandler(element, paragraphBlock, block);
}
private setupSelectionHandler(element: HTMLElement, paragraphBlock: HTMLDivElement, block: IBlock): void {
// Add selection change handler
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
// Clear selection if no text
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root - traverse from shadow root
const wysiwygBlock = (paragraphBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
// Get selection info using our Shadow DOM-aware utility
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Check if selection is within this block
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
console.log('ParagraphBlockHandler: Text selected', {
text: selectedText,
blockId: block.id
});
// Create range and get rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch event
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
this.selectionHandler = checkSelection;
// Clean up on disconnect (will be called by dees-wysiwyg-block)
const wysiwygBlock = element.closest('dees-wysiwyg-block');
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
return `
/* Paragraph specific styles */
.block.paragraph {
font-size: 16px;
line-height: 1.6;
font-weight: 400;
}
`;
}
getPlaceholder(): string {
return "Type '/' for commands...";
}
/**
* Helper to get the last text node in an element
*/
private getLastTextNode(element: Node): Text | null {
if (element.nodeType === Node.TEXT_NODE) {
return element as Text;
}
for (let i = element.childNodes.length - 1; i >= 0; i--) {
const lastText = this.getLastTextNode(element.childNodes[i]);
if (lastText) return lastText;
}
return null;
}
// Helper methods for paragraph functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
console.log('ParagraphBlockHandler.getCursorPosition: Called with element:', element, 'context:', context);
// Get the actual paragraph element
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) {
console.log('ParagraphBlockHandler.getCursorPosition: No paragraph element found');
console.log('Element innerHTML:', element.innerHTML);
console.log('Element tagName:', element.tagName);
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('ParagraphBlockHandler.getCursorPosition: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length,
element: element,
paragraphBlock: paragraphBlock
});
if (!selectionInfo) {
console.log('ParagraphBlockHandler.getCursorPosition: No selection found');
return null;
}
console.log('ParagraphBlockHandler.getCursorPosition: Range info:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
collapsed: selectionInfo.collapsed,
startContainerText: selectionInfo.startContainer.textContent
});
if (!WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer)) {
console.log('ParagraphBlockHandler.getCursorPosition: Range not in element');
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(paragraphBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
console.log('ParagraphBlockHandler.getCursorPosition: Calculated position:', {
position,
preCaretText: preCaretRange.toString(),
elementText: paragraphBlock.textContent,
elementTextLength: paragraphBlock.textContent?.length
});
return position;
}
getContent(element: HTMLElement, context?: any): string {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return '';
// For paragraphs, get the innerHTML which includes formatting tags
const content = paragraphBlock.innerHTML || '';
console.log('ParagraphBlockHandler.getContent:', content);
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === paragraphBlock ||
element.shadowRoot?.activeElement === paragraphBlock;
paragraphBlock.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
paragraphBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (paragraphBlock) {
WysiwygBlocks.setCursorToStart(paragraphBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (paragraphBlock) {
WysiwygBlocks.setCursorToEnd(paragraphBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return;
// Ensure the element is focusable
if (!paragraphBlock.hasAttribute('contenteditable')) {
paragraphBlock.setAttribute('contenteditable', 'true');
}
paragraphBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== paragraphBlock && element.shadowRoot?.activeElement !== paragraphBlock) {
Promise.resolve().then(() => {
paragraphBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) return;
// Ensure element is focusable first
if (!paragraphBlock.hasAttribute('contenteditable')) {
paragraphBlock.setAttribute('contenteditable', 'true');
}
// For 'end' position, we need to set up selection before focus to prevent browser default
if (position === 'end' && paragraphBlock.textContent && paragraphBlock.textContent.length > 0) {
// Set up the selection first
const sel = window.getSelection();
if (sel) {
const range = document.createRange();
const lastNode = this.getLastTextNode(paragraphBlock) || paragraphBlock;
if (lastNode.nodeType === Node.TEXT_NODE) {
range.setStart(lastNode, lastNode.textContent?.length || 0);
range.setEnd(lastNode, lastNode.textContent?.length || 0);
} else {
range.selectNodeContents(lastNode);
range.collapse(false);
}
sel.removeAllRanges();
sel.addRange(range);
}
}
// Now focus the element
paragraphBlock.focus();
// Set cursor position after focus is established (for non-end positions)
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end' && (!paragraphBlock.textContent || paragraphBlock.textContent.length === 0)) {
// Only call setCursorToEnd for empty blocks
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(paragraphBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
setCursor();
} else {
// Try again with a small delay - sometimes focus needs more time
setTimeout(() => {
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
setCursor();
}
}, 10);
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
console.log('ParagraphBlockHandler.getSplitContent: Starting...');
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
if (!paragraphBlock) {
console.log('ParagraphBlockHandler.getSplitContent: No paragraph element found');
return null;
}
console.log('ParagraphBlockHandler.getSplitContent: Element info:', {
innerHTML: paragraphBlock.innerHTML,
textContent: paragraphBlock.textContent,
textLength: paragraphBlock.textContent?.length
});
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('ParagraphBlockHandler.getSplitContent: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('ParagraphBlockHandler.getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = paragraphBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
console.log('ParagraphBlockHandler.getSplitContent: Splitting with last known position:', {
pos,
fullTextLength: fullText.length,
before: fullText.substring(0, pos),
after: fullText.substring(pos)
});
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
console.log('ParagraphBlockHandler.getSplitContent: Selection range:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
startContainerInElement: paragraphBlock.contains(selectionInfo.startContainer)
});
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer)) {
console.log('ParagraphBlockHandler.getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = paragraphBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position first
const cursorPos = this.getCursorPosition(element, context);
console.log('ParagraphBlockHandler.getSplitContent: Cursor position for HTML split:', cursorPos);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
console.log('ParagraphBlockHandler.getSplitContent: Cursor at start or null, moving all content');
return {
before: '',
after: paragraphBlock.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange();
const afterRange = document.createRange();
// Before range: from start of element to cursor
beforeRange.setStart(paragraphBlock, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(paragraphBlock, paragraphBlock.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
console.log('ParagraphBlockHandler.getSplitContent: Final split result:', {
cursorPos,
beforeHtml,
beforeLength: beforeHtml.length,
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
afterHtml,
afterLength: afterHtml.length,
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
});
return {
before: beforeHtml,
after: afterHtml
};
}
}

View File

@ -0,0 +1,541 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
export class QuoteBlockHandler extends BaseBlockHandler {
type = 'quote';
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const placeholder = this.getPlaceholder();
console.log('QuoteBlockHandler.render:', { blockId: block.id, isSelected, content: block.content });
return `
<div
class="block quote${selectedClass}"
contenteditable="true"
data-placeholder="${placeholder}"
data-block-id="${block.id}"
data-block-type="${block.type}"
></div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
console.error('QuoteBlockHandler.setup: No quote block element found');
return;
}
console.log('QuoteBlockHandler.setup: Setting up quote block', { blockId: block.id });
// Set initial content if needed
if (block.content && !quoteBlock.innerHTML) {
quoteBlock.innerHTML = block.content;
}
// Input handler with cursor tracking
quoteBlock.addEventListener('input', (e) => {
console.log('QuoteBlockHandler: Input event', { blockId: block.id });
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('QuoteBlockHandler: Updated cursor position after input', { pos });
}
});
// Keydown handler with cursor tracking
quoteBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('QuoteBlockHandler: Cursor position before keydown', { pos, key: e.key });
}
handlers.onKeyDown(e);
});
// Focus handler
quoteBlock.addEventListener('focus', () => {
console.log('QuoteBlockHandler: Focus event', { blockId: block.id });
handlers.onFocus();
});
// Blur handler
quoteBlock.addEventListener('blur', () => {
console.log('QuoteBlockHandler: Blur event', { blockId: block.id });
handlers.onBlur();
});
// Composition handlers for IME support
quoteBlock.addEventListener('compositionstart', () => {
console.log('QuoteBlockHandler: Composition start', { blockId: block.id });
handlers.onCompositionStart();
});
quoteBlock.addEventListener('compositionend', () => {
console.log('QuoteBlockHandler: Composition end', { blockId: block.id });
handlers.onCompositionEnd();
});
// Mouse up handler
quoteBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('QuoteBlockHandler: Cursor position after mouseup', { pos });
}
// Selection will be handled by selectionchange event
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
quoteBlock.addEventListener('click', (e: MouseEvent) => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('QuoteBlockHandler: Cursor position after click', { pos });
}
}, 0);
});
// Keyup handler for additional cursor tracking
quoteBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('QuoteBlockHandler: Cursor position after keyup', { pos, key: e.key });
}
});
// Set up selection change handler
this.setupSelectionHandler(element, quoteBlock, block);
}
private setupSelectionHandler(element: HTMLElement, quoteBlock: HTMLDivElement, block: IBlock): void {
// Add selection change handler
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
// Clear selection if no text
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
return;
}
// Get parent wysiwyg component's shadow root - traverse from shadow root
const wysiwygBlock = (quoteBlock.getRootNode() as ShadowRoot).host as any;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = wysiwygBlock?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
// Get selection info using our Shadow DOM-aware utility
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Check if selection is within this block
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
console.log('QuoteBlockHandler: Text selected', {
text: selectedText,
blockId: block.id
});
// Create range and get rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch event
this.dispatchSelectionEvent(element, {
text: selectedText.trim(),
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchSelectionEvent(element, {
text: '',
blockId: block.id,
hasSelection: false
});
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
this.selectionHandler = checkSelection;
// Clean up on disconnect (will be called by dees-wysiwyg-block)
const wysiwygBlock = (quoteBlock.getRootNode() as ShadowRoot).host as any;
if (wysiwygBlock) {
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
(wysiwygBlock as any).disconnectedCallback = async function() {
if (this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
if (originalDisconnectedCallback) {
await originalDisconnectedCallback.call(wysiwygBlock);
}
}.bind(this);
}
}
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
element.dispatchEvent(event);
}
getStyles(): string {
return `
/* Quote specific styles */
.block.quote {
border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')};
padding-left: 20px;
color: ${cssManager.bdTheme('#555', '#b0b0b0')};
font-style: italic;
line-height: 1.6;
margin: 16px 0;
}
`;
}
getPlaceholder(): string {
return 'Add a quote...';
}
// Helper methods for quote functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
console.log('QuoteBlockHandler.getCursorPosition: Called with element:', element, 'context:', context);
// Get the actual quote element
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
console.log('QuoteBlockHandler.getCursorPosition: No quote element found');
console.log('Element innerHTML:', element.innerHTML);
console.log('Element tagName:', element.tagName);
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('QuoteBlockHandler.getCursorPosition: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length,
element: element,
quoteBlock: quoteBlock
});
if (!selectionInfo) {
console.log('QuoteBlockHandler.getCursorPosition: No selection found');
return null;
}
console.log('QuoteBlockHandler.getCursorPosition: Range info:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
collapsed: selectionInfo.collapsed,
startContainerText: selectionInfo.startContainer.textContent
});
if (!WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer)) {
console.log('QuoteBlockHandler.getCursorPosition: Range not in element');
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(quoteBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
console.log('QuoteBlockHandler.getCursorPosition: Calculated position:', {
position,
preCaretText: preCaretRange.toString(),
elementText: quoteBlock.textContent,
elementTextLength: quoteBlock.textContent?.length
});
return position;
}
getContent(element: HTMLElement, context?: any): string {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return '';
// For quotes, get the innerHTML which includes formatting tags
const content = quoteBlock.innerHTML || '';
console.log('QuoteBlockHandler.getContent:', content);
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === quoteBlock ||
element.shadowRoot?.activeElement === quoteBlock;
quoteBlock.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
quoteBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (quoteBlock) {
WysiwygBlocks.setCursorToStart(quoteBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (quoteBlock) {
WysiwygBlocks.setCursorToEnd(quoteBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Ensure the element is focusable
if (!quoteBlock.hasAttribute('contenteditable')) {
quoteBlock.setAttribute('contenteditable', 'true');
}
quoteBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== quoteBlock && element.shadowRoot?.activeElement !== quoteBlock) {
Promise.resolve().then(() => {
quoteBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Ensure element is focusable first
if (!quoteBlock.hasAttribute('contenteditable')) {
quoteBlock.setAttribute('contenteditable', 'true');
}
// Focus the element
quoteBlock.focus();
// Set cursor position after focus is established
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end') {
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(quoteBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
setCursor();
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
console.log('QuoteBlockHandler.getSplitContent: Starting...');
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
console.log('QuoteBlockHandler.getSplitContent: No quote element found');
return null;
}
console.log('QuoteBlockHandler.getSplitContent: Element info:', {
innerHTML: quoteBlock.innerHTML,
textContent: quoteBlock.textContent,
textLength: quoteBlock.textContent?.length
});
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('QuoteBlockHandler.getSplitContent: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('QuoteBlockHandler.getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = quoteBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
console.log('QuoteBlockHandler.getSplitContent: Splitting with last known position:', {
pos,
fullTextLength: fullText.length,
before: fullText.substring(0, pos),
after: fullText.substring(pos)
});
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
console.log('QuoteBlockHandler.getSplitContent: Selection range:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
startContainerInElement: quoteBlock.contains(selectionInfo.startContainer)
});
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer)) {
console.log('QuoteBlockHandler.getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = quoteBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position first
const cursorPos = this.getCursorPosition(element, context);
console.log('QuoteBlockHandler.getSplitContent: Cursor position for HTML split:', cursorPos);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
console.log('QuoteBlockHandler.getSplitContent: Cursor at start or null, moving all content');
return {
before: '',
after: quoteBlock.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange();
const afterRange = document.createRange();
// Before range: from start of element to cursor
beforeRange.setStart(quoteBlock, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(quoteBlock, quoteBlock.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
console.log('QuoteBlockHandler.getSplitContent: Final split result:', {
cursorPos,
beforeHtml,
beforeLength: beforeHtml.length,
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
afterHtml,
afterLength: afterHtml.length,
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
});
return {
before: beforeHtml,
after: afterHtml
};
}
}

View File

@ -0,0 +1,203 @@
import {
customElement,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
state,
} from '@design.estate/dees-element';
import { WysiwygFormatting } from './wysiwyg.formatting.js';
declare global {
interface HTMLElementTagNameMap {
'dees-formatting-menu': DeesFormattingMenu;
}
}
@customElement('dees-formatting-menu')
export class DeesFormattingMenu extends DeesElement {
private static instance: DeesFormattingMenu;
public static getInstance(): DeesFormattingMenu {
if (!DeesFormattingMenu.instance) {
DeesFormattingMenu.instance = new DeesFormattingMenu();
document.body.appendChild(DeesFormattingMenu.instance);
}
return DeesFormattingMenu.instance;
}
@state()
public visible: boolean = false;
@state()
private position: { x: number; y: number } = { x: 0, y: 0 };
private callback: ((command: string) => void | Promise<void>) | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: fixed;
z-index: 10000;
pointer-events: none;
}
.formatting-menu {
position: absolute;
background: ${cssManager.bdTheme('#ffffff', '#262626')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
border-radius: 6px;
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.15);
padding: 4px;
display: flex;
gap: 2px;
pointer-events: auto;
user-select: none;
animation: fadeInScale 0.15s ease-out;
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.95) translateY(5px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.format-button {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
font-weight: 600;
font-size: 14px;
position: relative;
}
.format-button:hover {
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
.format-button:active {
transform: scale(0.95);
}
.format-button.bold {
font-weight: 700;
}
.format-button.italic {
font-style: italic;
}
.format-button.underline {
text-decoration: underline;
}
.format-button .code-icon {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 12px;
}
`,
];
render(): TemplateResult {
if (!this.visible) return html``;
return html`
<div
class="formatting-menu"
style="left: ${this.position.x}px; top: ${this.position.y}px;"
tabindex="-1"
data-menu-type="formatting"
>
${WysiwygFormatting.formatButtons.map(button => html`
<button
class="format-button ${button.command}"
data-command="${button.command}"
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
>
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
</button>
`)}
</div>
`;
}
private applyFormat(command: string): void {
if (this.callback) {
this.callback(command);
}
// Don't hide menu after applying format (except for link)
if (command === 'link') {
this.hide();
}
}
public show(position: { x: number; y: number }, callback: (command: string) => void | Promise<void>): void {
console.log('FormattingMenu.show called:', { position, visible: this.visible });
this.position = position;
this.callback = callback;
this.visible = true;
console.log('FormattingMenu.show - visible set to:', this.visible);
}
public hide(): void {
this.visible = false;
this.callback = null;
}
public updatePosition(position: { x: number; y: number }): void {
this.position = position;
}
public firstUpdated(): void {
// Set up event delegation for the menu
this.shadowRoot?.addEventListener('mousedown', (e: MouseEvent) => {
const menu = this.shadowRoot?.querySelector('.formatting-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent focus loss
e.preventDefault();
e.stopPropagation();
}
});
this.shadowRoot?.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const button = target.closest('.format-button') as HTMLElement;
if (button) {
e.preventDefault();
e.stopPropagation();
const command = button.getAttribute('data-command');
if (command) {
this.applyFormat(command);
}
}
});
this.shadowRoot?.addEventListener('focus', (e: FocusEvent) => {
const menu = this.shadowRoot?.querySelector('.formatting-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent menu from taking focus
e.preventDefault();
e.stopPropagation();
}
}, true); // Use capture phase
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,242 @@
import {
customElement,
property,
html,
DeesElement,
type TemplateResult,
cssManager,
css,
state,
} from '@design.estate/dees-element';
import { type ISlashMenuItem } from './wysiwyg.types.js';
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
declare global {
interface HTMLElementTagNameMap {
'dees-slash-menu': DeesSlashMenu;
}
}
@customElement('dees-slash-menu')
export class DeesSlashMenu extends DeesElement {
private static instance: DeesSlashMenu;
public static getInstance(): DeesSlashMenu {
if (!DeesSlashMenu.instance) {
DeesSlashMenu.instance = new DeesSlashMenu();
document.body.appendChild(DeesSlashMenu.instance);
}
return DeesSlashMenu.instance;
}
@state()
public visible: boolean = false;
@state()
private position: { x: number; y: number } = { x: 0, y: 0 };
@state()
private filter: string = '';
@state()
private selectedIndex: number = 0;
private callback: ((type: string) => void) | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: fixed;
z-index: 10000;
pointer-events: none;
}
.slash-menu {
position: absolute;
background: ${cssManager.bdTheme('#ffffff', '#262626')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: 4px;
min-width: 220px;
max-height: 300px;
overflow-y: auto;
pointer-events: auto;
user-select: none;
animation: fadeInScale 0.15s ease-out;
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.slash-menu-item {
padding: 10px 12px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 12px;
border-radius: 4px;
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
font-size: 14px;
}
.slash-menu-item:hover,
.slash-menu-item.selected {
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.slash-menu-item .icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: ${cssManager.bdTheme('#666', '#999')};
font-weight: 600;
}
.slash-menu-item:hover .icon,
.slash-menu-item.selected .icon {
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
`,
];
render(): TemplateResult {
if (!this.visible) return html``;
const menuItems = this.getFilteredMenuItems();
return html`
<div
class="slash-menu"
style="left: ${this.position.x}px; top: ${this.position.y}px;"
tabindex="-1"
data-menu-type="slash"
>
${menuItems.map((item, index) => html`
<div
class="slash-menu-item ${index === this.selectedIndex ? 'selected' : ''}"
data-item-type="${item.type}"
data-item-index="${index}"
>
<span class="icon">${item.icon}</span>
<span>${item.label}</span>
</div>
`)}
</div>
`;
}
private getFilteredMenuItems(): ISlashMenuItem[] {
const allItems = WysiwygShortcuts.getSlashMenuItems();
return allItems.filter(item =>
this.filter === '' ||
item.label.toLowerCase().includes(this.filter.toLowerCase())
);
}
private selectItem(type: string): void {
if (this.callback) {
this.callback(type);
}
this.hide();
}
public show(position: { x: number; y: number }, callback: (type: string) => void): void {
this.position = position;
this.callback = callback;
this.filter = '';
this.selectedIndex = 0;
this.visible = true;
}
public hide(): void {
this.visible = false;
this.callback = null;
this.filter = '';
this.selectedIndex = 0;
}
public updateFilter(filter: string): void {
this.filter = filter;
this.selectedIndex = 0;
}
public navigate(direction: 'up' | 'down'): void {
const items = this.getFilteredMenuItems();
if (direction === 'down') {
this.selectedIndex = (this.selectedIndex + 1) % items.length;
} else {
this.selectedIndex = this.selectedIndex === 0
? items.length - 1
: this.selectedIndex - 1;
}
}
public selectCurrent(): void {
const items = this.getFilteredMenuItems();
if (items[this.selectedIndex]) {
this.selectItem(items[this.selectedIndex].type);
}
}
public firstUpdated(): void {
// Set up event delegation
this.shadowRoot?.addEventListener('mousedown', (e: MouseEvent) => {
const menu = this.shadowRoot?.querySelector('.slash-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent focus loss
e.preventDefault();
e.stopPropagation();
}
});
this.shadowRoot?.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const menuItem = target.closest('.slash-menu-item') as HTMLElement;
if (menuItem) {
e.preventDefault();
e.stopPropagation();
const itemType = menuItem.getAttribute('data-item-type');
if (itemType) {
this.selectItem(itemType);
}
}
});
this.shadowRoot?.addEventListener('mouseenter', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const menuItem = target.closest('.slash-menu-item') as HTMLElement;
if (menuItem) {
const index = parseInt(menuItem.getAttribute('data-item-index') || '0', 10);
this.selectedIndex = index;
}
}, true); // Use capture phase
this.shadowRoot?.addEventListener('focus', (e: FocusEvent) => {
const menu = this.shadowRoot?.querySelector('.slash-menu');
if (menu && menu.contains(e.target as Node)) {
// Prevent menu from taking focus
e.preventDefault();
e.stopPropagation();
}
}, true); // Use capture phase
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
export * from './wysiwyg.types.js';
export * from './wysiwyg.interfaces.js';
export * from './wysiwyg.constants.js';
export * from './wysiwyg.styles.js';
export * from './wysiwyg.converters.js';
export * from './wysiwyg.shortcuts.js';
export * from './wysiwyg.blocks.js';
export * from './wysiwyg.formatting.js';
export * from './wysiwyg.selection.js';
export * from './wysiwyg.blockoperations.js';
export * from './wysiwyg.inputhandler.js';
export * from './wysiwyg.keyboardhandler.js';
export * from './wysiwyg.dragdrophandler.js';
export * from './wysiwyg.modalmanager.js';
export * from './wysiwyg.history.js';
export * from './dees-wysiwyg-block.js';
export * from './dees-slash-menu.js';
export * from './dees-formatting-menu.js';

View File

@ -0,0 +1,7 @@
* We don't use lit html logic, no event binding, no nothing, but only use static`` here to handle dom operations ourselves
* We try to have separated concerns in different classes
* We try to have clean concise and managable code
* lets log whats happening, so if something goes wrong, we understand whats happening.
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
* Make sure to hand over correct shodowroots.

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