Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
2f17dea480 | |||
ce33aff843 | |||
09eea844d7 | |||
956edf0d63 | |||
1db74177b3 | |||
1c25554c38 | |||
7d1e06701b | |||
aae4427281 | |||
911c51d078 | |||
2c12c22666 | |||
60a811fd18 | |||
9a9aea56da | |||
49ad998b2c | |||
5066681b3a | |||
ee22879c00 |
43
changelog.md
43
changelog.md
@ -1,5 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-06-29 - 1.10.10 - improve(dees-dashboardgrid, dees-input-wysiwyg)
|
||||
Enhanced dashboard grid component with advanced spacing and layout features inspired by gridstack.js
|
||||
|
||||
Dashboard Grid improvements:
|
||||
- Improved margin system supporting uniform or individual margins (top, right, bottom, left)
|
||||
- Added collision detection to prevent widget overlap during drag operations
|
||||
- Implemented auto-positioning for new widgets to find first available space
|
||||
- Added compact() method to eliminate gaps and compress layout vertically or horizontally
|
||||
- Enhanced resize constraints with minW, maxW, minH, maxH support
|
||||
- Added optional grid lines visualization for better layout understanding
|
||||
- Improved resize handles with better visibility and hover states
|
||||
- Added RTL (right-to-left) layout support
|
||||
- Implemented cellHeightUnit option supporting 'px', 'em', 'rem', or 'auto' (square cells)
|
||||
- Added configurable animation with enableAnimation property
|
||||
- Enhanced demo with interactive controls for testing all features
|
||||
- Better calculation of widget positions accounting for margins between cells
|
||||
- Added findAvailablePosition() for intelligent widget placement
|
||||
- Improved drag and resize calculations for pixel-perfect positioning
|
||||
|
||||
WYSIWYG editor drag and drop fixes:
|
||||
- Fixed drop indicator positioning to properly account for block margins
|
||||
- Added defensive checks in drag event handlers to prevent potential crashes
|
||||
- Improved updateBlockPositions with null checks and error handling
|
||||
- Updated drop indicator calculation to use simplified margin approach
|
||||
- Fixed drop indicator height to match the exact space occupied by dragged blocks
|
||||
- Improved drop indicator positioning algorithm to accurately show where blocks will land
|
||||
- Simplified visual block position calculations accounting for CSS transforms
|
||||
- Enhanced margin calculation to use correct values based on block type (16px for paragraphs, 24px for headings, 20px for code/quotes)
|
||||
- Fixed index calculation issue when dragging blocks downward by adjusting target index for excluded dragged block
|
||||
|
||||
## 2025-06-28 - 1.10.9 - feat(dees-dashboardgrid)
|
||||
Add new dashboard grid component with drag-and-drop and resize capabilities
|
||||
|
||||
- Created dees-dashboardgrid component for building flexible dashboard layouts
|
||||
- Features drag-and-drop functionality for rearranging widgets
|
||||
- Includes resize handles for adjusting widget dimensions
|
||||
- Supports configurable grid properties (columns, cell height, gap)
|
||||
- Provides widget locking and editable mode controls
|
||||
- Styled with shadcn design principles
|
||||
- No external dependencies - built with native browser APIs
|
||||
- Emits events for widget movements and resizes
|
||||
- Includes comprehensive demo with sample dashboard widgets
|
||||
|
||||
## 2025-06-27 - 1.10.8 - feat(ui-components)
|
||||
Update multiple components with shadcn-aligned styling and improved animations
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "1.10.9",
|
||||
"version": "1.10.10",
|
||||
"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",
|
||||
|
@ -1,513 +0,0 @@
|
||||
# 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
|
283
readme.md
283
readme.md
@ -1,5 +1,8 @@
|
||||
# @design.estate/dees-catalog
|
||||
An extensive library for building modern web applications with dynamic components using Web Components, JavaScript, and TypeScript.
|
||||
A comprehensive web components library built with TypeScript and LitElement, providing 75+ UI components for building modern web applications with consistent design and behavior.
|
||||
|
||||
## Development Guide
|
||||
For developers working on this library, please refer to the [UI Components Playbook](readme.playbook.md) for comprehensive patterns, best practices, and architectural guidelines.
|
||||
|
||||
## Install
|
||||
To install the `@design.estate/dees-catalog` library, you can use npm or any other compatible JavaScript package manager:
|
||||
@ -12,15 +15,16 @@ npm install @design.estate/dees-catalog
|
||||
|
||||
| Category | Components |
|
||||
|----------|------------|
|
||||
| Core UI | `DeesButton`, `DeesButtonExit`, `DeesButtonGroup`, `DeesBadge`, `DeesChips`, `DeesHeading`, `DeesHint`, `DeesIcon`, `DeesLabel`, `DeesPanel`, `DeesSearchbar`, `DeesSpinner`, `DeesToast`, `DeesWindowcontrols` |
|
||||
| Forms | `DeesForm`, `DeesInputText`, `DeesInputCheckbox`, `DeesInputDropdown`, `DeesInputRadiogroup`, `DeesInputFileupload`, `DeesInputIban`, `DeesInputPhone`, `DeesInputQuantitySelector`, `DeesInputMultitoggle`, `DeesInputTags`, `DeesInputTypelist`, `DeesInputRichtext`, `DeesInputWysiwyg`, `DeesFormSubmit` |
|
||||
| Layout | `DeesAppuiBase`, `DeesAppuiMainmenu`, `DeesAppuiMainselector`, `DeesAppuiMaincontent`, `DeesAppuiAppbar`, `DeesAppuiActivitylog`, `DeesAppuiProfiledropdown`, `DeesAppuiTabs`, `DeesAppuiView`, `DeesMobileNavigation` |
|
||||
| Data Display | `DeesTable`, `DeesDataviewCodebox`, `DeesDataviewStatusobject`, `DeesPdf`, `DeesStatsGrid`, `DeesPagination` |
|
||||
| Visualization | `DeesChartArea`, `DeesChartLog` |
|
||||
| Dialogs & Overlays | `DeesModal`, `DeesContextmenu`, `DeesSpeechbubble`, `DeesWindowlayer` |
|
||||
| Navigation | `DeesStepper`, `DeesProgressbar`, `DeesMobileNavigation` |
|
||||
| Development | `DeesEditor`, `DeesEditorMarkdown`, `DeesEditorMarkdownoutlet`, `DeesTerminal`, `DeesUpdater` |
|
||||
| Auth & Utilities | `DeesSimpleAppdash`, `DeesSimpleLogin` |
|
||||
| Core UI | [`DeesButton`](#deesbutton), [`DeesButtonExit`](#deesbuttonexit), [`DeesButtonGroup`](#deesbuttongroup), [`DeesBadge`](#deesbadge), [`DeesChips`](#deeschips), [`DeesHeading`](#deesheading), [`DeesHint`](#deeshint), [`DeesIcon`](#deesicon), [`DeesLabel`](#deeslabel), [`DeesPanel`](#deespanel), [`DeesSearchbar`](#deessearchbar), [`DeesSpinner`](#deesspinner), [`DeesToast`](#deestoast), [`DeesWindowcontrols`](#deeswindowcontrols) |
|
||||
| Forms | [`DeesForm`](#deesform), [`DeesInputText`](#deesinputtext), [`DeesInputCheckbox`](#deesinputcheckbox), [`DeesInputDropdown`](#deesinputdropdown), [`DeesInputRadiogroup`](#deesinputradiogroup), [`DeesInputFileupload`](#deesinputfileupload), [`DeesInputIban`](#deesinputiban), [`DeesInputPhone`](#deesinputphone), [`DeesInputQuantitySelector`](#deesinputquantityselector), [`DeesInputMultitoggle`](#deesinputmultitoggle), [`DeesInputTags`](#deesinputtags), [`DeesInputTypelist`](#deesinputtypelist), [`DeesInputRichtext`](#deesinputrichtext), [`DeesInputWysiwyg`](#deesinputwysiwyg), [`DeesInputDatepicker`](#deesinputdatepicker), [`DeesInputSearchselect`](#deesinputsearchselect), [`DeesFormSubmit`](#deesformsubmit) |
|
||||
| Layout | [`DeesAppuiBase`](#deesappuibase), [`DeesAppuiMainmenu`](#deesappuimainmenu), [`DeesAppuiMainselector`](#deesappuimainselector), [`DeesAppuiMaincontent`](#deesappuimaincontent), [`DeesAppuiAppbar`](#deesappuiappbar), [`DeesAppuiActivitylog`](#deesappuiactivitylog), [`DeesAppuiProfiledropdown`](#deesappuiprofiledropdown), [`DeesAppuiTabs`](#deesappuitabs), [`DeesAppuiView`](#deesappuiview), [`DeesMobileNavigation`](#deesmobilenavigation), [`DeesDashboardGrid`](#deesdashboardgrid) |
|
||||
| Data Display | [`DeesTable`](#deestable), [`DeesDataviewCodebox`](#deesdataviewcodebox), [`DeesDataviewStatusobject`](#deesdataviewstatusobject), [`DeesPdf`](#deespdf), [`DeesStatsGrid`](#deesstatsgrid), [`DeesPagination`](#deespagination) |
|
||||
| Visualization | [`DeesChartArea`](#deeschartarea), [`DeesChartLog`](#deeschartlog) |
|
||||
| Dialogs & Overlays | [`DeesModal`](#deesmodal), [`DeesContextmenu`](#deescontextmenu), [`DeesSpeechbubble`](#deesspeechbubble), [`DeesWindowlayer`](#deeswindowlayer) |
|
||||
| Navigation | [`DeesStepper`](#deesstepper), [`DeesProgressbar`](#deesprogressbar) |
|
||||
| Development | [`DeesEditor`](#deeseditor), [`DeesEditorMarkdown`](#deeseditormarkdown), [`DeesEditorMarkdownoutlet`](#deeseditormarkdownoutlet), [`DeesTerminal`](#deesterminal), [`DeesUpdater`](#deesupdater) |
|
||||
| Auth & Utilities | [`DeesSimpleAppdash`](#deessimpleappdash), [`DeesSimpleLogin`](#deessimplelogin) |
|
||||
| Shopping | [`DeesShoppingProductcard`](#deesshoppingproductcard) |
|
||||
|
||||
## Detailed Component Documentation
|
||||
|
||||
@ -70,14 +74,36 @@ Interactive chips/tags with selection capabilities.
|
||||
```
|
||||
|
||||
#### `DeesIcon`
|
||||
Display icons from various icon sets including FontAwesome.
|
||||
Display icons from FontAwesome and Lucide icon libraries with library prefixes.
|
||||
|
||||
```typescript
|
||||
// FontAwesome icons - use 'fa:' prefix
|
||||
<dees-icon
|
||||
icon="home" // FontAwesome icon name
|
||||
type="solid" // Options: solid, regular, brands
|
||||
size="1.5rem" // Optional: custom size
|
||||
icon="fa:check" // FontAwesome icon with fa: prefix
|
||||
iconSize="24" // Size in pixels
|
||||
color="#22c55e" // Optional: custom color
|
||||
></dees-icon>
|
||||
|
||||
// Lucide icons - use 'lucide:' prefix
|
||||
<dees-icon
|
||||
icon="lucide:menu" // Lucide icon with lucide: prefix
|
||||
iconSize="24" // Size in pixels
|
||||
color="#007bff" // Optional: custom color
|
||||
strokeWidth="2" // Optional: stroke width for Lucide icons
|
||||
></dees-icon>
|
||||
|
||||
// Available FontAwesome icons include:
|
||||
// fa:check, fa:bell, fa:gear, fa:trash, fa:copy, fa:paste, fa:eye, fa:eyeSlash,
|
||||
// fa:plus, fa:minus, fa:circleInfo, fa:circleCheck, fa:circleXmark, fa:message,
|
||||
// fa:arrowRight, fa:facebook, fa:twitter, fa:linkedin, fa:instagram, etc.
|
||||
|
||||
// Available Lucide icons include:
|
||||
// lucide:menu, lucide:settings, lucide:home, lucide:file, lucide:folder,
|
||||
// lucide:search, lucide:user, lucide:heart, lucide:star, lucide:download, etc.
|
||||
|
||||
// Legacy API (deprecated but still supported)
|
||||
<dees-icon
|
||||
iconFA="check" // Without prefix - assumes FontAwesome
|
||||
></dees-icon>
|
||||
```
|
||||
|
||||
@ -431,6 +457,78 @@ Dynamic list input for managing arrays of typed values.
|
||||
></dees-input-typelist>
|
||||
```
|
||||
|
||||
#### `DeesInputDatepicker`
|
||||
Date and time picker component with calendar interface and manual typing support.
|
||||
|
||||
```typescript
|
||||
<dees-input-datepicker
|
||||
key="eventDate"
|
||||
label="Event Date"
|
||||
placeholder="YYYY-MM-DD"
|
||||
value="2025-01-15T14:30:00Z" // ISO string format
|
||||
dateFormat="YYYY-MM-DD" // Display format (default: YYYY-MM-DD)
|
||||
enableTime={true} // Enable time selection
|
||||
timeFormat="24h" // Options: 24h, 12h
|
||||
minuteIncrement={15} // Time step in minutes
|
||||
minDate="2025-01-01" // Minimum selectable date
|
||||
maxDate="2025-12-31" // Maximum selectable date
|
||||
.disabledDates=${[ // Array of disabled dates
|
||||
'2025-01-10',
|
||||
'2025-01-11'
|
||||
]}
|
||||
weekStartsOn={1} // 0 = Sunday, 1 = Monday
|
||||
required
|
||||
@change=${handleDateChange}
|
||||
></dees-input-datepicker>
|
||||
```
|
||||
|
||||
Key Features:
|
||||
- Interactive calendar popup
|
||||
- Manual date typing with multiple formats
|
||||
- Optional time selection
|
||||
- Configurable date format
|
||||
- Min/max date constraints
|
||||
- Disable specific dates
|
||||
- Keyboard navigation
|
||||
- Today button
|
||||
- Clear functionality
|
||||
- 12/24 hour time formats
|
||||
- Theme-aware styling
|
||||
- Live parsing and validation
|
||||
|
||||
Manual Input Formats:
|
||||
```typescript
|
||||
// Date formats supported
|
||||
"2023-12-20" // ISO format (YYYY-MM-DD)
|
||||
"20.12.2023" // European format (DD.MM.YYYY)
|
||||
"12/20/2023" // US format (MM/DD/YYYY)
|
||||
|
||||
// Date with time (add space and time after any date format)
|
||||
"2023-12-20 14:30"
|
||||
"20.12.2023 9:45"
|
||||
"12/20/2023 16:00"
|
||||
```
|
||||
|
||||
The component automatically parses and validates input as you type, updating the internal date value when a valid date is recognized.
|
||||
|
||||
#### `DeesInputSearchselect`
|
||||
Search-enabled dropdown selection component.
|
||||
|
||||
```typescript
|
||||
<dees-input-searchselect
|
||||
key="category"
|
||||
label="Select Category"
|
||||
placeholder="Search categories..."
|
||||
.options=${[
|
||||
{ key: 'tech', label: 'Technology' },
|
||||
{ key: 'health', label: 'Healthcare' },
|
||||
{ key: 'finance', label: 'Finance' }
|
||||
]}
|
||||
required
|
||||
@change=${handleCategoryChange}
|
||||
></dees-input-searchselect>
|
||||
```
|
||||
|
||||
#### `DeesInputRichtext`
|
||||
Rich text editor with formatting toolbar powered by TipTap.
|
||||
|
||||
@ -919,6 +1017,100 @@ Responsive navigation component for mobile devices.
|
||||
></dees-mobile-navigation>
|
||||
```
|
||||
|
||||
#### `DeesDashboardGrid`
|
||||
Drag-and-drop grid layout system for creating customizable dashboards.
|
||||
|
||||
```typescript
|
||||
<dees-dashboardgrid
|
||||
.widgets=${[
|
||||
{
|
||||
id: 'widget1',
|
||||
x: 0, // Grid column position
|
||||
y: 0, // Grid row position
|
||||
w: 4, // Width in grid units
|
||||
h: 3, // Height in grid units
|
||||
minW: 2, // Minimum width
|
||||
minH: 2, // Minimum height
|
||||
maxW: 6, // Maximum width
|
||||
title: 'Sales Overview',
|
||||
icon: 'fa:chart-line',
|
||||
content: html`<div>Widget content here</div>`,
|
||||
noMove: false, // Allow moving
|
||||
noResize: false // Allow resizing
|
||||
},
|
||||
{
|
||||
id: 'widget2',
|
||||
x: 4,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 3,
|
||||
title: 'Recent Activity',
|
||||
content: html`<dees-table .data=${activityData}></dees-table>`,
|
||||
autoPosition: true // Auto-find position
|
||||
}
|
||||
]}
|
||||
columns={12} // Number of grid columns
|
||||
cellHeight={80} // Height of each grid cell in pixels
|
||||
cellHeightUnit="px" // Options: px, em, rem, auto
|
||||
margin={10} // Gap between widgets
|
||||
editable={true} // Enable drag and resize
|
||||
showGridLines={false} // Show grid guidelines
|
||||
enableAnimation={true} // Smooth transitions
|
||||
rtl={false} // Right-to-left support
|
||||
@widget-move=${handleWidgetMove}
|
||||
@widget-resize=${handleWidgetResize}
|
||||
></dees-dashboardgrid>
|
||||
|
||||
// Programmatic methods
|
||||
const grid = document.querySelector('dees-dashboardgrid');
|
||||
|
||||
// Add a new widget
|
||||
grid.addWidget({
|
||||
id: 'newWidget',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
content: html`<div>New widget</div>`
|
||||
}, true); // true = auto-position
|
||||
|
||||
// Remove widget
|
||||
grid.removeWidget('widget1');
|
||||
|
||||
// Update widget
|
||||
grid.updateWidget('widget2', {
|
||||
title: 'Updated Title',
|
||||
w: 6
|
||||
});
|
||||
|
||||
// Get/set layout
|
||||
const layout = grid.getLayout(); // Returns position data
|
||||
grid.setLayout(savedLayout); // Restore positions
|
||||
|
||||
// Compact widgets
|
||||
grid.compact('vertical'); // Or 'horizontal'
|
||||
|
||||
// Lock/unlock editing
|
||||
grid.lockGrid();
|
||||
grid.unlockGrid();
|
||||
```
|
||||
|
||||
Key Features:
|
||||
- Drag-and-drop widget repositioning
|
||||
- Resize handles on edges and corners
|
||||
- Grid-based layout system
|
||||
- Collision detection
|
||||
- Auto-positioning for new widgets
|
||||
- Configurable constraints (min/max dimensions)
|
||||
- Lock individual widgets or entire grid
|
||||
- Compact layout algorithm
|
||||
- Save/restore layout positions
|
||||
- RTL layout support
|
||||
- Optional grid lines for alignment
|
||||
- Smooth animations
|
||||
- Responsive sizing
|
||||
- Empty state display
|
||||
|
||||
### Data Display Components
|
||||
|
||||
#### `DeesTable`
|
||||
@ -1913,6 +2105,69 @@ Key Features:
|
||||
- Responsive layout
|
||||
- Loading states
|
||||
|
||||
### Shopping Components
|
||||
|
||||
#### `DeesShoppingProductcard`
|
||||
Product card component for e-commerce applications.
|
||||
|
||||
```typescript
|
||||
<dees-shopping-productcard
|
||||
.productData=${{
|
||||
name: 'Premium Headphones',
|
||||
category: 'Electronics',
|
||||
description: 'High-quality wireless headphones with noise cancellation',
|
||||
price: 199.99,
|
||||
originalPrice: 249.99, // Shows strikethrough price
|
||||
currency: '$',
|
||||
inStock: true,
|
||||
stockText: 'In Stock', // Custom stock text
|
||||
imageUrl: '/images/headphones.jpg',
|
||||
iconName: 'lucide:headphones' // Fallback icon if no image
|
||||
}}
|
||||
quantity={1} // Current quantity
|
||||
showQuantitySelector={true} // Show quantity selector
|
||||
selectable={false} // Enable selection mode
|
||||
selected={false} // Selection state
|
||||
@quantityChange=${(e) => handleQuantityChange(e.detail)}
|
||||
@selectionChange=${(e) => handleSelectionChange(e.detail)}
|
||||
></dees-shopping-productcard>
|
||||
```
|
||||
|
||||
Key Features:
|
||||
- Product image with fallback icon
|
||||
- Category label
|
||||
- Product name and description
|
||||
- Price display with original price strikethrough
|
||||
- Stock status indicator
|
||||
- Built-in quantity selector
|
||||
- Selection mode for bulk operations
|
||||
- Hover effects
|
||||
- Responsive design
|
||||
- Theme-aware styling
|
||||
|
||||
Product Data Interface:
|
||||
```typescript
|
||||
interface IProductData {
|
||||
name: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
originalPrice?: number;
|
||||
currency?: string;
|
||||
inStock?: boolean;
|
||||
stockText?: string;
|
||||
imageUrl?: string;
|
||||
iconName?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Common Use Cases:
|
||||
- Product listings
|
||||
- Shopping carts
|
||||
- Order summaries
|
||||
- Product comparisons
|
||||
- Wishlist displays
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the license file within this repository.
|
||||
|
784
readme.playbook.md
Normal file
784
readme.playbook.md
Normal file
@ -0,0 +1,784 @@
|
||||
# UI Components Playbook
|
||||
|
||||
This playbook provides comprehensive guidance for creating and maintaining UI components in the @design.estate/dees-catalog library. Follow these patterns and best practices to ensure consistency, maintainability, and quality.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Component Creation Checklist](#component-creation-checklist)
|
||||
2. [Architectural Patterns](#architectural-patterns)
|
||||
3. [Component Types and Base Classes](#component-types-and-base-classes)
|
||||
4. [Theming System](#theming-system)
|
||||
5. [Event Handling](#event-handling)
|
||||
6. [State Management](#state-management)
|
||||
7. [Form Components](#form-components)
|
||||
8. [Overlay Components](#overlay-components)
|
||||
9. [Complex Components](#complex-components)
|
||||
10. [Performance Optimization](#performance-optimization)
|
||||
11. [Focus Management](#focus-management)
|
||||
12. [Demo System](#demo-system)
|
||||
13. [Common Pitfalls and Anti-patterns](#common-pitfalls-and-anti-patterns)
|
||||
14. [Code Examples](#code-examples)
|
||||
|
||||
## Component Creation Checklist
|
||||
|
||||
When creating a new component, follow this checklist:
|
||||
|
||||
- [ ] Choose the appropriate base class (`DeesElement` or `DeesInputBase`)
|
||||
- [ ] Use `@customElement('dees-componentname')` decorator
|
||||
- [ ] Implement consistent theming with `cssManager.bdTheme()`
|
||||
- [ ] Create demo function in separate `.demo.ts` file
|
||||
- [ ] Export component from `ts_web/elements/index.ts`
|
||||
- [ ] Use proper TypeScript types and interfaces (prefix with `I` for interfaces, `T` for types)
|
||||
- [ ] Implement proper event handling with bubbling and composition
|
||||
- [ ] Consider mobile responsiveness
|
||||
- [ ] Add focus states for accessibility
|
||||
- [ ] Clean up resources in `destroy()` method
|
||||
- [ ] Follow lowercase naming convention for files
|
||||
- [ ] Add z-index registry support if it's an overlay component
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### Base Component Structure
|
||||
|
||||
```typescript
|
||||
import { customElement, property, state, css, TemplateResult, html } from '@design.estate/dees-element';
|
||||
import { DeesElement } from '@design.estate/dees-element';
|
||||
import * as cssManager from './00colors.js';
|
||||
import * as demoFunc from './dees-componentname.demo.js';
|
||||
|
||||
@customElement('dees-componentname')
|
||||
export class DeesComponentName extends DeesElement {
|
||||
// Static demo reference
|
||||
public static demo = demoFunc.demoFunc;
|
||||
|
||||
// Public properties (reactive, can be set via attributes)
|
||||
@property({ type: String })
|
||||
public label: string = '';
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public disabled: boolean = false;
|
||||
|
||||
// Internal state (reactive, but not exposed as attributes)
|
||||
@state()
|
||||
private internalState: string = '';
|
||||
|
||||
// Static styles with theme support
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
// Render method
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="main-container">
|
||||
<!-- Component content -->
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Lifecycle methods
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Setup that needs DOM access
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
// One-time initialization after first render
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
public destroy() {
|
||||
// Clean up listeners, observers, registrations
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Patterns
|
||||
|
||||
#### 1. Separation of Concerns (Complex Components)
|
||||
|
||||
For complex components like WYSIWYG editors, separate concerns into handler classes:
|
||||
|
||||
```typescript
|
||||
export class DeesComplexComponent extends DeesElement {
|
||||
// Orchestrator pattern - main component coordinates handlers
|
||||
private inputHandler: InputHandler;
|
||||
private stateHandler: StateHandler;
|
||||
private renderHandler: RenderHandler;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.inputHandler = new InputHandler(this);
|
||||
this.stateHandler = new StateHandler(this);
|
||||
this.renderHandler = new RenderHandler(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Singleton Pattern (Global Components)
|
||||
|
||||
For global UI elements like menus:
|
||||
|
||||
```typescript
|
||||
export class DeesGlobalMenu extends DeesElement {
|
||||
private static instance: DeesGlobalMenu;
|
||||
|
||||
public static getInstance(): DeesGlobalMenu {
|
||||
if (!DeesGlobalMenu.instance) {
|
||||
DeesGlobalMenu.instance = new DeesGlobalMenu();
|
||||
document.body.appendChild(DeesGlobalMenu.instance);
|
||||
}
|
||||
return DeesGlobalMenu.instance;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Registry Pattern (Z-Index Management)
|
||||
|
||||
Use centralized registries for global state:
|
||||
|
||||
```typescript
|
||||
class ComponentRegistry {
|
||||
private static instance: ComponentRegistry;
|
||||
private registry = new WeakMap<HTMLElement, number>();
|
||||
|
||||
public register(element: HTMLElement, value: number) {
|
||||
this.registry.set(element, value);
|
||||
}
|
||||
|
||||
public unregister(element: HTMLElement) {
|
||||
this.registry.delete(element);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Component Types and Base Classes
|
||||
|
||||
### Standard Component (extends DeesElement)
|
||||
|
||||
Use for most UI components:
|
||||
- Buttons, badges, icons
|
||||
- Layout components
|
||||
- Data display components
|
||||
- Overlay components
|
||||
|
||||
### Form Input Component (extends DeesInputBase)
|
||||
|
||||
Use for all form inputs:
|
||||
- Text inputs, dropdowns, checkboxes
|
||||
- Date pickers, file uploads
|
||||
- Rich text editors
|
||||
|
||||
**Required implementations:**
|
||||
```typescript
|
||||
export class DeesInputCustom extends DeesInputBase<ValueType> {
|
||||
// Required: Get current value
|
||||
public getValue(): ValueType {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
// Required: Set value programmatically
|
||||
public setValue(value: ValueType): void {
|
||||
this.value = value;
|
||||
this.changeSubject.next(this); // Notify form
|
||||
}
|
||||
|
||||
// Optional: Custom validation
|
||||
public async validate(): Promise<boolean> {
|
||||
// Custom validation logic
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Theming System
|
||||
|
||||
### DO: Use Theme Functions
|
||||
|
||||
Always use `cssManager.bdTheme()` for colors that change between themes:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333333')};
|
||||
|
||||
// ❌ INCORRECT
|
||||
background: #ffffff; // Hard-coded color
|
||||
color: var(--custom-color); // Custom CSS variable
|
||||
```
|
||||
|
||||
### DO: Use Consistent Color Values
|
||||
|
||||
Reference shared color constants when possible:
|
||||
|
||||
```typescript
|
||||
// From 00colors.ts
|
||||
background: ${cssManager.bdTheme(colors.bright.background, colors.dark.background)};
|
||||
```
|
||||
|
||||
## Event Handling
|
||||
|
||||
### DO: Dispatch Custom Events Properly
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Events bubble and cross shadow DOM
|
||||
this.dispatchEvent(new CustomEvent('dees-componentname-change', {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
|
||||
// ❌ INCORRECT - Event won't propagate properly
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: { value: this.value }
|
||||
// Missing bubbles and composed
|
||||
}));
|
||||
```
|
||||
|
||||
### DO: Use Event Delegation
|
||||
|
||||
For dynamic content, use event delegation:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Single listener for all items
|
||||
this.addEventListener('click', (e: MouseEvent) => {
|
||||
const item = (e.target as HTMLElement).closest('.item');
|
||||
if (item) {
|
||||
this.handleItemClick(item);
|
||||
}
|
||||
});
|
||||
|
||||
// ❌ INCORRECT - Multiple listeners
|
||||
this.items.forEach(item => {
|
||||
item.addEventListener('click', () => this.handleItemClick(item));
|
||||
});
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### DO: Use Appropriate Property Decorators
|
||||
|
||||
```typescript
|
||||
// Public API - use @property
|
||||
@property({ type: String })
|
||||
public label: string;
|
||||
|
||||
// Internal state - use @state
|
||||
@state()
|
||||
private isLoading: boolean = false;
|
||||
|
||||
// Reflect to attribute when needed
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public disabled: boolean = false;
|
||||
```
|
||||
|
||||
### DON'T: Manipulate State in Render
|
||||
|
||||
```typescript
|
||||
// ❌ INCORRECT - Side effects in render
|
||||
public render() {
|
||||
this.counter++; // Don't modify state
|
||||
return html`<div>${this.counter}</div>`;
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Pure render function
|
||||
public render() {
|
||||
return html`<div>${this.counter}</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
## Form Components
|
||||
|
||||
### DO: Extend DeesInputBase
|
||||
|
||||
All form inputs must extend the base class:
|
||||
|
||||
```typescript
|
||||
export class DeesInputNew extends DeesInputBase<string> {
|
||||
// Inherits: key, label, value, required, disabled, validationState
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Emit Changes Consistently
|
||||
|
||||
```typescript
|
||||
private handleInput(e: Event) {
|
||||
this.value = (e.target as HTMLInputElement).value;
|
||||
this.changeSubject.next(this); // Notify form system
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Support Standard Form Properties
|
||||
|
||||
```typescript
|
||||
// All form inputs should support:
|
||||
@property() public key: string;
|
||||
@property() public label: string;
|
||||
@property() public required: boolean = false;
|
||||
@property() public disabled: boolean = false;
|
||||
@property() public validationState: 'valid' | 'warn' | 'invalid';
|
||||
```
|
||||
|
||||
## Overlay Components
|
||||
|
||||
### DO: Use Z-Index Registry
|
||||
|
||||
Never hardcode z-index values:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
import { zIndexRegistry } from './00zindex.js';
|
||||
|
||||
public async show() {
|
||||
this.modalZIndex = zIndexRegistry.getNextZIndex();
|
||||
zIndexRegistry.register(this, this.modalZIndex);
|
||||
this.style.zIndex = `${this.modalZIndex}`;
|
||||
}
|
||||
|
||||
public async hide() {
|
||||
zIndexRegistry.unregister(this);
|
||||
}
|
||||
|
||||
// ❌ INCORRECT
|
||||
public async show() {
|
||||
this.style.zIndex = '9999'; // Hardcoded z-index
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Use Window Layers
|
||||
|
||||
For modal backdrops:
|
||||
|
||||
```typescript
|
||||
import { DeesWindowLayer } from './dees-windowlayer.js';
|
||||
|
||||
private windowLayer: DeesWindowLayer;
|
||||
|
||||
public async show() {
|
||||
this.windowLayer = new DeesWindowLayer();
|
||||
this.windowLayer.zIndex = zIndexRegistry.getNextZIndex();
|
||||
document.body.append(this.windowLayer);
|
||||
}
|
||||
```
|
||||
|
||||
## Complex Components
|
||||
|
||||
### DO: Use Handler Classes
|
||||
|
||||
For complex logic, separate into specialized handlers:
|
||||
|
||||
```typescript
|
||||
// wysiwyg/handlers/input.handler.ts
|
||||
export class InputHandler {
|
||||
constructor(private component: DeesInputWysiwyg) {}
|
||||
|
||||
public handleInput(event: InputEvent) {
|
||||
// Specialized input handling
|
||||
}
|
||||
}
|
||||
|
||||
// Main component orchestrates
|
||||
export class DeesInputWysiwyg extends DeesInputBase {
|
||||
private inputHandler = new InputHandler(this);
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Use Programmatic Rendering
|
||||
|
||||
For performance-critical updates that shouldn't trigger re-renders:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Direct DOM manipulation when needed
|
||||
private updateBlockContent(blockId: string, content: string) {
|
||||
const blockElement = this.shadowRoot.querySelector(`#${blockId}`);
|
||||
if (blockElement) {
|
||||
blockElement.textContent = content; // Direct update
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ INCORRECT - Triggering full re-render
|
||||
private updateBlockContent(blockId: string, content: string) {
|
||||
this.blocks.find(b => b.id === blockId).content = content;
|
||||
this.requestUpdate(); // Unnecessary re-render
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### DO: Debounce Expensive Operations
|
||||
|
||||
```typescript
|
||||
private resizeTimeout: number;
|
||||
|
||||
private handleResize = () => {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
this.resizeTimeout = window.setTimeout(() => {
|
||||
this.updateLayout();
|
||||
}, 250);
|
||||
};
|
||||
```
|
||||
|
||||
### DO: Use Observers Efficiently
|
||||
|
||||
```typescript
|
||||
// Clean up observers
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.resizeObserver?.disconnect();
|
||||
this.mutationObserver?.disconnect();
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Implement Virtual Scrolling
|
||||
|
||||
For large lists:
|
||||
|
||||
```typescript
|
||||
// Only render visible items
|
||||
private getVisibleItems() {
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
const containerHeight = this.scrollContainer.clientHeight;
|
||||
const itemHeight = 50;
|
||||
|
||||
const startIndex = Math.floor(scrollTop / itemHeight);
|
||||
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
|
||||
|
||||
return this.items.slice(startIndex, endIndex);
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Management
|
||||
|
||||
### DO: Handle Focus Timing
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Wait for render
|
||||
async focusInput() {
|
||||
await this.updateComplete;
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
this.inputElement?.focus();
|
||||
}
|
||||
|
||||
// ❌ INCORRECT - Focus too early
|
||||
focusInput() {
|
||||
this.inputElement?.focus(); // Element might not exist
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Prevent Focus Loss
|
||||
|
||||
```typescript
|
||||
// For global menus
|
||||
constructor() {
|
||||
super();
|
||||
// Prevent focus loss when clicking menu
|
||||
this.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Implement Blur Debouncing
|
||||
|
||||
```typescript
|
||||
private blurTimeout: number;
|
||||
|
||||
private handleBlur = () => {
|
||||
clearTimeout(this.blurTimeout);
|
||||
this.blurTimeout = window.setTimeout(() => {
|
||||
// Check if truly blurred
|
||||
if (!this.contains(document.activeElement)) {
|
||||
this.handleTrueBlur();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
```
|
||||
|
||||
## Demo System
|
||||
|
||||
### DO: Create Comprehensive Demos
|
||||
|
||||
Every component needs a demo:
|
||||
|
||||
```typescript
|
||||
// dees-button.demo.ts
|
||||
import { html } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-button>Default Button</dees-button>
|
||||
<dees-button type="primary">Primary Button</dees-button>
|
||||
<dees-button type="danger" disabled>Disabled Danger</dees-button>
|
||||
`;
|
||||
|
||||
// In component file
|
||||
import * as demoFunc from './dees-button.demo.js';
|
||||
|
||||
export class DeesButton extends DeesElement {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Include All Variants
|
||||
|
||||
Show all component states and variations in demos:
|
||||
- Default state
|
||||
- Different types/variants
|
||||
- Disabled state
|
||||
- Loading state
|
||||
- Error states
|
||||
- Edge cases (long text, empty content)
|
||||
|
||||
## Common Pitfalls and Anti-patterns
|
||||
|
||||
### ❌ DON'T: Hardcode Z-Index Values
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
this.style.zIndex = '9999';
|
||||
|
||||
// ✅ CORRECT
|
||||
this.style.zIndex = `${zIndexRegistry.getNextZIndex()}`;
|
||||
```
|
||||
|
||||
### ❌ DON'T: Skip Base Classes
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Form input without base class
|
||||
export class DeesInputCustom extends DeesElement {
|
||||
// Missing standard form functionality
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
export class DeesInputCustom extends DeesInputBase<string> {
|
||||
// Inherits all form functionality
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Forget Theme Support
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
|
||||
// ✅ CORRECT
|
||||
background-color: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
```
|
||||
|
||||
### ❌ DON'T: Create Components Without Demos
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
export class DeesComponent extends DeesElement {
|
||||
// No demo property
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
export class DeesComponent extends DeesElement {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Emit Non-Bubbling Events
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: this.value
|
||||
}));
|
||||
|
||||
// ✅ CORRECT
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: this.value,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
```
|
||||
|
||||
### ❌ DON'T: Skip Cleanup
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
public connectedCallback() {
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Use Inline Styles for Theming
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
<div style="background-color: ${this.darkMode ? '#000' : '#fff'}">
|
||||
|
||||
// ✅ CORRECT
|
||||
<div class="themed-container">
|
||||
// In styles:
|
||||
.themed-container {
|
||||
background-color: ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Forget Mobile Responsiveness
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
:host {
|
||||
width: 800px; // Fixed width
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
:host {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:host {
|
||||
/* Mobile adjustments */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Example: Creating a New Button Variant
|
||||
|
||||
```typescript
|
||||
// dees-special-button.ts
|
||||
import { customElement, property, css, html } from '@design.estate/dees-element';
|
||||
import { DeesElement } from '@design.estate/dees-element';
|
||||
import * as cssManager from './00colors.js';
|
||||
import * as demoFunc from './dees-special-button.demo.js';
|
||||
|
||||
@customElement('dees-special-button')
|
||||
export class DeesSpecialButton extends DeesElement {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
|
||||
@property({ type: String })
|
||||
public text: string = 'Click me';
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public loading: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 8px 16px;
|
||||
background: ${cssManager.bdTheme('#0066ff', '#0044cc')};
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
|
||||
}
|
||||
|
||||
:host([loading]) .button {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<button class="button" ?disabled=${this.loading} @click=${this.handleClick}>
|
||||
${this.loading ? html`<dees-spinner size="small"></dees-spinner>` : this.text}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
this.dispatchEvent(new CustomEvent('special-click', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Creating a Form Input
|
||||
|
||||
```typescript
|
||||
// dees-input-special.ts
|
||||
export class DeesInputSpecial extends DeesInputBase<string> {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<dees-label .label=${this.label} .required=${this.required}>
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.value || ''}
|
||||
?disabled=${this.disabled}
|
||||
@input=${this.handleInput}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
</dees-label>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleInput(e: Event) {
|
||||
this.value = (e.target as HTMLInputElement).value;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.dispatchEvent(new CustomEvent('blur', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
This playbook represents the collective wisdom and patterns found in the @design.estate/dees-catalog component library. Following these guidelines will help you create components that are:
|
||||
|
||||
- **Consistent**: Following established patterns
|
||||
- **Maintainable**: Easy to understand and modify
|
||||
- **Performant**: Optimized for real-world use
|
||||
- **Accessible**: Usable by everyone
|
||||
- **Theme-aware**: Supporting light and dark modes
|
||||
- **Well-integrated**: Working seamlessly with the component ecosystem
|
||||
|
||||
Remember: When in doubt, look at existing components for examples. The codebase itself is the best documentation of these patterns in action.
|
@ -1,138 +0,0 @@
|
||||
# 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.
|
@ -1,82 +0,0 @@
|
||||
# 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
|
@ -1,72 +0,0 @@
|
||||
|
||||
> @design.estate/dees-catalog@1.10.8 test /mnt/data/lossless/design.estate/dees-catalog
|
||||
> tstest test/ --web --verbose --timeout 30 --logfile test/test.tabs-indicator.browser.ts
|
||||
|
||||
[38;5;231m
|
||||
🔍 Test Discovery[0m
|
||||
[38;5;231m Mode: file[0m
|
||||
[38;5;231m Pattern: test/test.tabs-indicator.browser.ts[0m
|
||||
[38;5;113m Found: 1 test file(s)[0m
|
||||
[38;5;33m
|
||||
▶️ test/test.tabs-indicator.browser.ts (1/1)[0m
|
||||
[38;5;231m Runtime: chromium[0m
|
||||
running spawned compilation process
|
||||
=======> ESBUILD
|
||||
{
|
||||
cwd: '/mnt/data/lossless/design.estate/dees-catalog',
|
||||
from: 'test/test.tabs-indicator.browser.ts',
|
||||
to: '/mnt/data/lossless/design.estate/dees-catalog/.nogit/tstest_cache/test__test.tabs-indicator.browser.ts.js',
|
||||
mode: 'test',
|
||||
argv: { bundler: 'esbuild' }
|
||||
}
|
||||
switched to /mnt/data/lossless/design.estate/dees-catalog
|
||||
building for test:
|
||||
Got no SSL certificates. Please ensure encryption using e.g. a reverse proxy
|
||||
"/test" maps to 1 handlers
|
||||
-> GET
|
||||
"*" maps to 1 handlers
|
||||
-> GET
|
||||
now listening on 3007!
|
||||
Launching puppeteer browser with arguments:
|
||||
[]
|
||||
Using executable: /usr/bin/google-chrome
|
||||
added connection. now 1 sockets connected.
|
||||
added connection. now 2 sockets connected.
|
||||
connection ended
|
||||
removed connection. 1 sockets remaining.
|
||||
connection ended
|
||||
removed connection. 0 sockets remaining.
|
||||
added connection. now 1 sockets connected.
|
||||
/favicon.ico
|
||||
could not resolve /mnt/data/lossless/design.estate/dees-catalog/.nogit/tstest_cache/favicon.ico
|
||||
/test__test.tabs-indicator.browser.ts.js
|
||||
[38;5;231m [38;5;116mTest starting: tabs indicator positioning debug[0m[0m
|
||||
[38;5;231m !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!![0m
|
||||
[38;5;231m Using globalThis.tapPromise[0m
|
||||
[38;5;231m !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!![0m
|
||||
connection ended
|
||||
removed connection. 0 sockets remaining.
|
||||
[38;5;33m=> [0m Stopped [38;5;215mtest/test.tabs-indicator.browser.ts[0m chromium instance and server.
|
||||
[38;5;196m
|
||||
⚠️ Error[0m
|
||||
[38;5;196m Only 0 out of 1 completed![0m
|
||||
[38;5;196m
|
||||
⚠️ Error[0m
|
||||
[38;5;196m The amount of received tests and expectedTests is unequal! Therefore the testfile failed[0m
|
||||
[38;5;196m Summary: -1 passed, 1 failed of 0 tests in 2.7s[0m
|
||||
[38;5;231m
|
||||
📊 Test Summary[0m
|
||||
[38;5;231m┌────────────────────────────────┐[0m
|
||||
[38;5;231m│ Total Files: 1 │[0m
|
||||
[38;5;231m│ Total Tests: 0 │[0m
|
||||
[38;5;113m│ Passed: 0 │[0m
|
||||
[38;5;113m│ Failed: 0 │[0m
|
||||
[38;5;231m│ Duration: 4.2s │[0m
|
||||
[38;5;231m└────────────────────────────────┘[0m
|
||||
[38;5;116m
|
||||
⏱️ Performance Metrics:[0m
|
||||
[38;5;231m Average per test: 0ms[0m
|
||||
[38;5;113m
|
||||
ALL TESTS PASSED! 🎉[0m
|
||||
Exited NOT OK!
|
||||
ELIFECYCLE Test failed. See above for more details.
|
85
test/test.wysiwyg-blockmovement.browser.ts
Normal file
85
test/test.wysiwyg-blockmovement.browser.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg block movement during drag', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
|
||||
// Start dragging block 1
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: () => {},
|
||||
setDragImage: () => {}
|
||||
},
|
||||
clientY: 50,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Wait for dragging class
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Verify drag state
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Check that drag height was calculated
|
||||
console.log('Checking drag height...');
|
||||
const dragHandler = element.dragDropHandler as any;
|
||||
console.log('draggedBlockHeight:', dragHandler.draggedBlockHeight);
|
||||
console.log('draggedBlockContentHeight:', dragHandler.draggedBlockContentHeight);
|
||||
|
||||
// Manually call updateBlockPositions to simulate drag movement
|
||||
console.log('Simulating drag movement...');
|
||||
const updateBlockPositions = dragHandler.updateBlockPositions.bind(dragHandler);
|
||||
|
||||
// Simulate dragging down past block 2
|
||||
const block2 = editorContent.querySelector('[data-block-id="block2"]') as HTMLElement;
|
||||
const block2Rect = block2.getBoundingClientRect();
|
||||
const dragToY = block2Rect.bottom + 10;
|
||||
|
||||
console.log('Dragging to Y position:', dragToY);
|
||||
updateBlockPositions(dragToY);
|
||||
|
||||
// Check if blocks have moved
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
|
||||
console.log('Block states after drag:');
|
||||
blocks.forEach((block, i) => {
|
||||
const classes = block.className;
|
||||
const offset = (block as HTMLElement).style.getPropertyValue('--drag-offset');
|
||||
console.log(`Block ${i}: classes="${classes}", offset="${offset}"`);
|
||||
});
|
||||
|
||||
// Check that at least one block has move class
|
||||
const movedUpBlocks = editorContent.querySelectorAll('.block-wrapper.move-up');
|
||||
const movedDownBlocks = editorContent.querySelectorAll('.block-wrapper.move-down');
|
||||
console.log('Moved up blocks:', movedUpBlocks.length);
|
||||
console.log('Moved down blocks:', movedDownBlocks.length);
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
95
test/test.wysiwyg-dragdrop-simple.browser.ts
Normal file
95
test/test.wysiwyg-dragdrop-simple.browser.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag handler initialization', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Wait for element to be ready
|
||||
await element.updateComplete;
|
||||
|
||||
// Check that drag handler is initialized
|
||||
expect(element.dragDropHandler).toBeTruthy();
|
||||
|
||||
// Set initial content with multiple blocks
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'First paragraph' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Second paragraph' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Check that editor content ref exists
|
||||
console.log('editorContentRef:', element.editorContentRef);
|
||||
expect(element.editorContentRef).toBeTruthy();
|
||||
|
||||
// Check that blocks are rendered
|
||||
const blockWrappers = element.shadowRoot!.querySelectorAll('.block-wrapper');
|
||||
console.log('Number of block wrappers:', blockWrappers.length);
|
||||
expect(blockWrappers.length).toEqual(2);
|
||||
|
||||
// Check drag handles
|
||||
const dragHandles = element.shadowRoot!.querySelectorAll('.drag-handle');
|
||||
console.log('Number of drag handles:', dragHandles.length);
|
||||
expect(dragHandles.length).toEqual(2);
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drag start behavior', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const dragHandle = element.shadowRoot!.querySelector('.drag-handle') as HTMLElement;
|
||||
expect(dragHandle).toBeTruthy();
|
||||
|
||||
// Check that drag handle has draggable attribute
|
||||
console.log('Drag handle draggable:', dragHandle.draggable);
|
||||
expect(dragHandle.draggable).toBeTrue();
|
||||
|
||||
// Test drag handler state before drag
|
||||
console.log('Initial drag state:', element.dragDropHandler.dragState);
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toBeNull();
|
||||
|
||||
// Try to manually call handleDragStart
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {
|
||||
console.log('setData called with:', type, data);
|
||||
},
|
||||
setDragImage: (img: any, x: number, y: number) => {
|
||||
console.log('setDragImage called');
|
||||
}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Check drag state after drag start
|
||||
console.log('Drag state after start:', element.dragDropHandler.dragState);
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
133
test/test.wysiwyg-dragdrop-visual.browser.ts
Normal file
133
test/test.wysiwyg-dragdrop-visual.browser.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag visual feedback - block movement', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
|
||||
// Manually start drag
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {},
|
||||
setDragImage: (img: any, x: number, y: number) => {}
|
||||
},
|
||||
clientY: 50,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Wait for dragging class
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Check dragging state
|
||||
console.log('Block 1 classes:', block1.className);
|
||||
console.log('Editor content classes:', editorContent.className);
|
||||
expect(block1.classList.contains('dragging')).toBeTrue();
|
||||
expect(editorContent.classList.contains('dragging')).toBeTrue();
|
||||
|
||||
// Check drop indicator exists
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
|
||||
console.log('Drop indicator:', dropIndicator);
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Test block movement calculation
|
||||
console.log('Testing updateBlockPositions...');
|
||||
|
||||
// Access private method for testing
|
||||
const updateBlockPositions = element.dragDropHandler['updateBlockPositions'].bind(element.dragDropHandler);
|
||||
|
||||
// Simulate dragging to different position
|
||||
updateBlockPositions(150); // Move down
|
||||
|
||||
// Check if blocks have move classes
|
||||
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
|
||||
console.log('Block classes after move:');
|
||||
blocks.forEach((block, i) => {
|
||||
console.log(`Block ${i}:`, block.className, 'transform:', (block as HTMLElement).style.getPropertyValue('--drag-offset'));
|
||||
});
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drop indicator positioning', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Paragraph 1' },
|
||||
{ id: 'block2', type: 'heading-2', content: 'Heading 2' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
|
||||
// Start dragging first block
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {},
|
||||
setDragImage: (img: any, x: number, y: number) => {}
|
||||
},
|
||||
clientY: 50,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Wait for initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Get drop indicator
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Check initial display state
|
||||
console.log('Drop indicator initial display:', dropIndicator.style.display);
|
||||
|
||||
// Trigger updateBlockPositions to see drop indicator
|
||||
const updateBlockPositions = element.dragDropHandler['updateBlockPositions'].bind(element.dragDropHandler);
|
||||
updateBlockPositions(100);
|
||||
|
||||
// Check drop indicator position
|
||||
console.log('Drop indicator after update:');
|
||||
console.log('- display:', dropIndicator.style.display);
|
||||
console.log('- top:', dropIndicator.style.top);
|
||||
console.log('- height:', dropIndicator.style.height);
|
||||
|
||||
expect(dropIndicator.style.display).toEqual('block');
|
||||
expect(dropIndicator.style.top).toBeTruthy();
|
||||
expect(dropIndicator.style.height).toBeTruthy();
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
172
test/test.wysiwyg-dragdrop.browser.ts
Normal file
172
test/test.wysiwyg-dragdrop.browser.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag and drop should work correctly', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Wait for element to be ready
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content with multiple blocks
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'First paragraph' },
|
||||
{ id: 'block2', type: 'heading-2', content: 'Test Heading' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Second paragraph' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Check that blocks are rendered
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
expect(editorContent).toBeTruthy();
|
||||
|
||||
const blockWrappers = editorContent.querySelectorAll('.block-wrapper');
|
||||
expect(blockWrappers.length).toEqual(3);
|
||||
|
||||
// Test drag handles exist for non-divider blocks
|
||||
const dragHandles = editorContent.querySelectorAll('.drag-handle');
|
||||
expect(dragHandles.length).toEqual(3);
|
||||
|
||||
// Get references to specific blocks
|
||||
const firstBlock = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const secondBlock = editorContent.querySelector('[data-block-id="block2"]') as HTMLElement;
|
||||
const firstDragHandle = firstBlock.querySelector('.drag-handle') as HTMLElement;
|
||||
|
||||
expect(firstBlock).toBeTruthy();
|
||||
expect(secondBlock).toBeTruthy();
|
||||
expect(firstDragHandle).toBeTruthy();
|
||||
|
||||
// Test drag initialization
|
||||
console.log('Testing drag initialization...');
|
||||
|
||||
// Create drag event
|
||||
const dragStartEvent = new DragEvent('dragstart', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 100,
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
// Simulate drag start
|
||||
firstDragHandle.dispatchEvent(dragStartEvent);
|
||||
|
||||
// Check that drag state is initialized
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Check that dragging class is applied
|
||||
await new Promise(resolve => setTimeout(resolve, 20)); // Wait for setTimeout in drag start
|
||||
expect(firstBlock.classList.contains('dragging')).toBeTrue();
|
||||
expect(editorContent.classList.contains('dragging')).toBeTrue();
|
||||
|
||||
// Test drop indicator creation
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator');
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Simulate drag over
|
||||
const dragOverEvent = new DragEvent('dragover', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 200,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
document.dispatchEvent(dragOverEvent);
|
||||
|
||||
// Check that blocks move out of the way
|
||||
console.log('Checking block movements...');
|
||||
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
|
||||
const hasMovedBlocks = blocks.some(block =>
|
||||
block.classList.contains('move-up') || block.classList.contains('move-down')
|
||||
);
|
||||
|
||||
console.log('Blocks with move classes:', blocks.filter(block =>
|
||||
block.classList.contains('move-up') || block.classList.contains('move-down')
|
||||
).length);
|
||||
|
||||
// Test drag end
|
||||
const dragEndEvent = new DragEvent('dragend', {
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
document.dispatchEvent(dragEndEvent);
|
||||
|
||||
// Wait for cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Check that drag state is cleaned up
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toBeNull();
|
||||
expect(firstBlock.classList.contains('dragging')).toBeFalse();
|
||||
expect(editorContent.classList.contains('dragging')).toBeFalse();
|
||||
|
||||
// Check that drop indicator is removed
|
||||
const dropIndicatorAfter = editorContent.querySelector('.drop-indicator');
|
||||
expect(dropIndicatorAfter).toBeFalsy();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drag and drop visual feedback', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const dragHandle1 = block1.querySelector('.drag-handle') as HTMLElement;
|
||||
|
||||
// Start dragging block 1
|
||||
const dragStartEvent = new DragEvent('dragstart', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 50,
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
dragHandle1.dispatchEvent(dragStartEvent);
|
||||
|
||||
// Wait for dragging class
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Simulate dragging down
|
||||
const dragOverEvent = new DragEvent('dragover', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 150, // Move down past block 2
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
// Trigger the global drag over handler
|
||||
element.dragDropHandler['handleGlobalDragOver'](dragOverEvent);
|
||||
|
||||
// Check that transform is applied to dragged block
|
||||
const transform = block1.style.transform;
|
||||
console.log('Dragged block transform:', transform);
|
||||
expect(transform).toContain('translateY');
|
||||
|
||||
// Check drop indicator position
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
|
||||
if (dropIndicator) {
|
||||
const indicatorStyle = dropIndicator.style;
|
||||
console.log('Drop indicator position:', indicatorStyle.top, 'display:', indicatorStyle.display);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
124
test/test.wysiwyg-dragissue.browser.ts
Normal file
124
test/test.wysiwyg-dragissue.browser.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag full flow without await', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Mock drag event
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {
|
||||
console.log('setData:', type, data);
|
||||
},
|
||||
setDragImage: (img: any, x: number, y: number) => {
|
||||
console.log('setDragImage');
|
||||
}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
console.log('Starting drag...');
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
console.log('Drag started');
|
||||
|
||||
// Check immediate state
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Instead of await with setTimeout, use a done callback
|
||||
return new Promise<void>((resolve) => {
|
||||
console.log('Setting up delayed check...');
|
||||
|
||||
// Use regular setTimeout
|
||||
setTimeout(() => {
|
||||
console.log('In setTimeout callback');
|
||||
|
||||
try {
|
||||
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
|
||||
console.log('Block has dragging class:', block1?.classList.contains('dragging'));
|
||||
console.log('Editor has dragging class:', editorContent?.classList.contains('dragging'));
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error('Error in setTimeout:', error);
|
||||
throw error;
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('identify the crash point', async () => {
|
||||
console.log('Test started');
|
||||
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
console.log('Element created');
|
||||
await element.updateComplete;
|
||||
|
||||
console.log('Setting blocks');
|
||||
element.blocks = [{ id: 'block1', type: 'paragraph', content: 'Test' }];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
console.log('Waiting for update');
|
||||
await element.updateComplete;
|
||||
|
||||
console.log('Creating mock event');
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: () => {},
|
||||
setDragImage: () => {}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
console.log('Calling handleDragStart');
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
console.log('handleDragStart completed');
|
||||
|
||||
// Try different wait methods
|
||||
console.log('About to wait...');
|
||||
|
||||
// Method 1: Direct promise
|
||||
await Promise.resolve();
|
||||
console.log('Promise.resolve completed');
|
||||
|
||||
// Method 2: setTimeout 0
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
console.log('setTimeout 0 completed');
|
||||
|
||||
// Method 3: requestAnimationFrame
|
||||
await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)));
|
||||
console.log('requestAnimationFrame completed');
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
console.log('Cleanup completed');
|
||||
});
|
||||
|
||||
tap.start();
|
108
test/test.wysiwyg-dropindicator.browser.ts
Normal file
108
test/test.wysiwyg-dropindicator.browser.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drop indicator creation', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Check editorContentRef
|
||||
console.log('editorContentRef exists:', !!element.editorContentRef);
|
||||
console.log('editorContentRef tagName:', element.editorContentRef?.tagName);
|
||||
expect(element.editorContentRef).toBeTruthy();
|
||||
|
||||
// Check initial state - no drop indicator
|
||||
let dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
|
||||
console.log('Drop indicator before drag:', dropIndicator);
|
||||
expect(dropIndicator).toBeFalsy();
|
||||
|
||||
// Manually call createDropIndicator
|
||||
try {
|
||||
console.log('Calling createDropIndicator...');
|
||||
element.dragDropHandler['createDropIndicator']();
|
||||
console.log('createDropIndicator succeeded');
|
||||
} catch (error) {
|
||||
console.error('Error creating drop indicator:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check drop indicator was created
|
||||
dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
|
||||
console.log('Drop indicator after creation:', dropIndicator);
|
||||
console.log('Drop indicator parent:', dropIndicator?.parentElement?.className);
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
expect(dropIndicator!.style.display).toEqual('none');
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drag initialization with drop indicator', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Mock drag event
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {
|
||||
console.log('setData:', type, data);
|
||||
},
|
||||
setDragImage: (img: any, x: number, y: number) => {
|
||||
console.log('setDragImage');
|
||||
}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
console.log('Starting drag...');
|
||||
|
||||
try {
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
console.log('Drag start succeeded');
|
||||
} catch (error) {
|
||||
console.error('Error during drag start:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait for async operations
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Check drop indicator exists
|
||||
const dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
|
||||
console.log('Drop indicator after drag start:', dropIndicator);
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Check drag state
|
||||
console.log('Drag state:', element.dragDropHandler.dragState);
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
114
test/test.wysiwyg-eventlisteners.browser.ts
Normal file
114
test/test.wysiwyg-eventlisteners.browser.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg global event listeners', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
console.log('Block 1 found:', !!block1);
|
||||
|
||||
// Set up drag state manually without using handleDragStart
|
||||
element.dragDropHandler['draggedBlockId'] = 'block1';
|
||||
element.dragDropHandler['draggedBlockElement'] = block1;
|
||||
element.dragDropHandler['initialMouseY'] = 100;
|
||||
|
||||
// Create drop indicator manually
|
||||
element.dragDropHandler['createDropIndicator']();
|
||||
|
||||
// Test adding global event listeners
|
||||
console.log('Adding event listeners...');
|
||||
const handleGlobalDragOver = element.dragDropHandler['handleGlobalDragOver'];
|
||||
const handleGlobalDragEnd = element.dragDropHandler['handleGlobalDragEnd'];
|
||||
|
||||
try {
|
||||
document.addEventListener('dragover', handleGlobalDragOver);
|
||||
console.log('dragover listener added');
|
||||
|
||||
document.addEventListener('dragend', handleGlobalDragEnd);
|
||||
console.log('dragend listener added');
|
||||
} catch (error) {
|
||||
console.error('Error adding event listeners:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Test firing a dragover event
|
||||
console.log('Creating dragover event...');
|
||||
const dragOverEvent = new Event('dragover', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
Object.defineProperty(dragOverEvent, 'clientY', { value: 150 });
|
||||
|
||||
console.log('Dispatching dragover event...');
|
||||
document.dispatchEvent(dragOverEvent);
|
||||
console.log('dragover event dispatched');
|
||||
|
||||
// Clean up
|
||||
document.removeEventListener('dragover', handleGlobalDragOver);
|
||||
document.removeEventListener('dragend', handleGlobalDragEnd);
|
||||
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg setTimeout in drag start', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
|
||||
// Set drag state
|
||||
element.dragDropHandler['draggedBlockId'] = 'block1';
|
||||
element.dragDropHandler['draggedBlockElement'] = block1;
|
||||
|
||||
console.log('Testing setTimeout callback...');
|
||||
|
||||
// Test the setTimeout callback directly
|
||||
try {
|
||||
if (block1) {
|
||||
console.log('Adding dragging class to block...');
|
||||
block1.classList.add('dragging');
|
||||
console.log('Block classes:', block1.className);
|
||||
}
|
||||
if (editorContent) {
|
||||
console.log('Adding dragging class to editor...');
|
||||
editorContent.classList.add('dragging');
|
||||
console.log('Editor classes:', editorContent.className);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in setTimeout callback:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
expect(block1.classList.contains('dragging')).toBeTrue();
|
||||
expect(editorContent.classList.contains('dragging')).toBeTrue();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
@ -1,83 +1,93 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import { html, css, cssManager, domtools } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import './dees-panel.js';
|
||||
import './dees-form.js';
|
||||
import './dees-form-submit.js';
|
||||
import './dees-input-text.js';
|
||||
import './dees-icon.js';
|
||||
import type { DeesButton } from './dees-button.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;
|
||||
}
|
||||
<style>
|
||||
${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 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vertical-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
.vertical-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.demo-output {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
}
|
||||
.demo-output {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
}
|
||||
|
||||
.icon-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.icon-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.code-snippet {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 11.8%)')};
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
display: inline-block;
|
||||
margin: 4px 0;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
.code-snippet {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 11.8%)')};
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
display: inline-block;
|
||||
margin: 4px 0;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="demo-container">
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Log button clicks for demo purposes
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener('clicked', () => {
|
||||
const type = button.getAttribute('type') || 'default';
|
||||
console.log(`Button variant clicked: ${type}`);
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'1. Button Variants'} .subtitle=${'Different visual styles for various use cases'}>
|
||||
<div class="button-group">
|
||||
<dees-button type="default">Default</dees-button>
|
||||
@ -88,7 +98,18 @@ export const demoFunc = () => html`
|
||||
<dees-button type="link">Link Button</dees-button>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate size differences programmatically
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener('clicked', () => {
|
||||
const size = button.getAttribute('size') || 'default';
|
||||
console.log(`Button size: ${size}`);
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'2. Button Sizes'} .subtitle=${'Multiple sizes for different contexts and use cases'}>
|
||||
<div class="button-group">
|
||||
<dees-button size="sm">Small Button</dees-button>
|
||||
@ -103,7 +124,21 @@ export const demoFunc = () => html`
|
||||
<dees-button size="lg" type="outline">Large Outline</dees-button>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Track icon button clicks
|
||||
const iconButtons = elementArg.querySelectorAll('dees-button');
|
||||
iconButtons.forEach((button) => {
|
||||
button.addEventListener('clicked', () => {
|
||||
const hasIcon = button.querySelector('dees-icon');
|
||||
if (hasIcon) {
|
||||
const iconName = hasIcon.getAttribute('iconFA') || 'unknown';
|
||||
console.log(`Icon button clicked: ${iconName}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'3. Buttons with Icons'} .subtitle=${'Combining icons with text for enhanced visual communication'}>
|
||||
<div class="icon-row">
|
||||
<dees-button>
|
||||
@ -153,7 +188,33 @@ export const demoFunc = () => html`
|
||||
</dees-button>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate status changes
|
||||
const pendingButton = elementArg.querySelector('dees-button[status="pending"]');
|
||||
const successButton = elementArg.querySelector('dees-button[status="success"]');
|
||||
const errorButton = elementArg.querySelector('dees-button[status="error"]');
|
||||
|
||||
// Simulate status changes
|
||||
if (pendingButton) {
|
||||
setTimeout(() => {
|
||||
console.log('Pending button is showing loading state');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
if (successButton) {
|
||||
successButton.addEventListener('clicked', () => {
|
||||
console.log('Success state button clicked');
|
||||
});
|
||||
}
|
||||
|
||||
if (errorButton) {
|
||||
errorButton.addEventListener('clicked', () => {
|
||||
console.log('Error state button clicked');
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'4. Button States'} .subtitle=${'Different states to indicate button status and loading conditions'}>
|
||||
<div class="button-group">
|
||||
<dees-button status="normal">Normal</dees-button>
|
||||
@ -169,61 +230,81 @@ export const demoFunc = () => html`
|
||||
<dees-button type="destructive" status="pending" size="lg">Large Loading</dees-button>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Set up click handlers with the output element
|
||||
const output = elementArg.querySelector('#click-output');
|
||||
|
||||
const clickMeBtn = elementArg.querySelector('dees-button:first-of-type');
|
||||
const dataBtn = elementArg.querySelector('dees-button[type="secondary"]');
|
||||
const asyncBtn = elementArg.querySelector('dees-button[type="destructive"]');
|
||||
|
||||
if (clickMeBtn && output) {
|
||||
clickMeBtn.addEventListener('clicked', () => {
|
||||
output.textContent = `Clicked: Default button at ${new Date().toLocaleTimeString()}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (dataBtn && output) {
|
||||
dataBtn.addEventListener('clicked', (e: CustomEvent) => {
|
||||
output.textContent = `Clicked: Secondary button with data: ${e.detail.data}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (asyncBtn && output) {
|
||||
asyncBtn.addEventListener('clicked', async () => {
|
||||
output.textContent = 'Processing...';
|
||||
await domtools.plugins.smartdelay.delayFor(2000);
|
||||
output.textContent = 'Action completed!';
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'5. Event Handling'} .subtitle=${'Interactive examples with click event handling'}>
|
||||
<div class="button-group">
|
||||
<dees-button
|
||||
@clicked=${() => {
|
||||
const output = document.querySelector('#click-output');
|
||||
if (output) {
|
||||
output.textContent = `Clicked: Default button at ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
Click Me
|
||||
</dees-button>
|
||||
|
||||
<dees-button
|
||||
type="secondary"
|
||||
.eventDetailData=${'custom-data-123'}
|
||||
@clicked=${(e: CustomEvent) => {
|
||||
const output = document.querySelector('#click-output');
|
||||
if (output) {
|
||||
output.textContent = `Clicked: Secondary button with data: ${e.detail.data}`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<dees-button>Click Me</dees-button>
|
||||
<dees-button type="secondary" .eventDetailData=${'custom-data-123'}>
|
||||
Click with Data
|
||||
</dees-button>
|
||||
|
||||
<dees-button
|
||||
type="destructive"
|
||||
@clicked=${async () => {
|
||||
const output = document.querySelector('#click-output');
|
||||
if (output) {
|
||||
output.textContent = 'Processing...';
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
output.textContent = 'Action completed!';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Async Action
|
||||
</dees-button>
|
||||
<dees-button type="destructive">Async Action</dees-button>
|
||||
</div>
|
||||
|
||||
<div id="click-output" class="demo-output">
|
||||
<em>Click a button to see the result...</em>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Set up form submission handling
|
||||
const form = elementArg.querySelector('dees-form');
|
||||
const output = elementArg.querySelector('#form-output');
|
||||
|
||||
if (form && output) {
|
||||
form.addEventListener('formData', (e: CustomEvent) => {
|
||||
output.innerHTML = '<strong>Form submitted with data:</strong><br>' +
|
||||
JSON.stringify(e.detail.data, null, 2);
|
||||
});
|
||||
}
|
||||
|
||||
// Track non-submit button clicks
|
||||
const draftBtn = elementArg.querySelector('dees-button[type="secondary"]');
|
||||
const cancelBtn = elementArg.querySelector('dees-button[type="ghost"]');
|
||||
|
||||
if (draftBtn) {
|
||||
draftBtn.addEventListener('clicked', () => {
|
||||
console.log('Save Draft clicked');
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('clicked', () => {
|
||||
console.log('Cancel clicked');
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'6. Form Integration'} .subtitle=${'Buttons working within forms with automatic spacing'}>
|
||||
<dees-form @formData=${(e: CustomEvent) => {
|
||||
const output = document.querySelector('#form-output');
|
||||
if (output) {
|
||||
output.innerHTML = '<strong>Form submitted with data:</strong><br>' +
|
||||
JSON.stringify(e.detail.data, null, 2);
|
||||
}
|
||||
}}>
|
||||
<dees-form>
|
||||
<dees-input-text label="Name" key="name" required></dees-input-text>
|
||||
<dees-input-text label="Email" key="email" type="email" required></dees-input-text>
|
||||
<dees-input-text label="Message" key="message" isMultiline></dees-input-text>
|
||||
@ -237,7 +318,18 @@ export const demoFunc = () => html`
|
||||
<em>Submit the form to see the data...</em>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Log legacy type mappings
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach((button) => {
|
||||
const type = button.getAttribute('type');
|
||||
if (type) {
|
||||
console.log(`Legacy type "${type}" is supported for backward compatibility`);
|
||||
}
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'7. Backward Compatibility'} .subtitle=${'Old button types are automatically mapped to new variants'}>
|
||||
<div class="button-group">
|
||||
<dees-button type="normal">Normal → Default</dees-button>
|
||||
@ -250,7 +342,35 @@ export const demoFunc = () => html`
|
||||
These legacy type values are maintained for backward compatibility but we recommend using the new variant system.
|
||||
</p>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Track action group clicks
|
||||
const actionGroup = elementArg.querySelectorAll('.vertical-group')[0];
|
||||
const dangerGroup = elementArg.querySelectorAll('.vertical-group')[1];
|
||||
|
||||
if (actionGroup) {
|
||||
const buttons = actionGroup.querySelectorAll('dees-button');
|
||||
buttons.forEach((button, index) => {
|
||||
button.addEventListener('clicked', () => {
|
||||
const action = ['Save Changes', 'Discard', 'Help'][index];
|
||||
console.log(`Action group: ${action} clicked`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (dangerGroup) {
|
||||
const buttons = dangerGroup.querySelectorAll('dees-button');
|
||||
buttons.forEach((button, index) => {
|
||||
button.addEventListener('clicked', () => {
|
||||
const action = ['Delete Account', 'Archive Data', 'Not Available'][index];
|
||||
if (index !== 2) { // Skip disabled button
|
||||
console.log(`Danger zone: ${action} clicked`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'8. Advanced Examples'} .subtitle=${'Complex button configurations and real-world use cases'}>
|
||||
<div class="horizontal-group">
|
||||
<div class="vertical-group">
|
||||
@ -296,6 +416,6 @@ export const demoFunc = () => html`
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`;
|
@ -168,7 +168,7 @@ export class DeesChips extends DeesElement {
|
||||
event.stopPropagation(); // prevent the selectChip event from being triggered
|
||||
this.removeChip(chip);
|
||||
}}
|
||||
.iconFA=${'xmark'}
|
||||
.icon=${'fa:xmark'}
|
||||
></dees-icon>
|
||||
`
|
||||
: html``}
|
||||
|
191
ts_web/elements/dees-dashboardgrid.demo.ts
Normal file
191
ts_web/elements/dees-dashboardgrid.demo.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
export const demoFunc = () => {
|
||||
return html`
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid;
|
||||
|
||||
// Set initial widgets
|
||||
grid.widgets = [
|
||||
{
|
||||
id: 'metrics1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Revenue',
|
||||
icon: 'lucide:dollarSign',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">$124,563</div>
|
||||
<div style="color: #22c55e; font-size: 14px; margin-top: 8px;">↑ 12.5% from last month</div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'metrics2',
|
||||
x: 3,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Users',
|
||||
icon: 'lucide:users',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">8,234</div>
|
||||
<div style="color: #3b82f6; font-size: 14px; margin-top: 8px;">↑ 5.2% from last week</div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'chart1',
|
||||
x: 6,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 4,
|
||||
title: 'Analytics',
|
||||
icon: 'lucide:lineChart',
|
||||
content: html`
|
||||
<div style="padding: 20px; height: 100%; display: flex; align-items: center; justify-content: center;">
|
||||
<div style="text-align: center; color: #71717a;">
|
||||
<dees-icon .icon=${'lucide:lineChart'} style="font-size: 48px; margin-bottom: 16px;"></dees-icon>
|
||||
<div>Chart visualization area</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
// Configure grid
|
||||
grid.cellHeight = 80;
|
||||
grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
|
||||
grid.enableAnimation = true;
|
||||
grid.showGridLines = false;
|
||||
|
||||
let widgetCounter = 4;
|
||||
|
||||
// Control buttons
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent?.trim();
|
||||
|
||||
if (text === 'Toggle Animation') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.enableAnimation = !grid.enableAnimation;
|
||||
});
|
||||
} else if (text === 'Toggle Grid Lines') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.showGridLines = !grid.showGridLines;
|
||||
});
|
||||
} else if (text === 'Add Widget') {
|
||||
button.addEventListener('click', () => {
|
||||
const newWidget = {
|
||||
id: `widget${widgetCounter++}`,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
autoPosition: true,
|
||||
title: `Widget ${widgetCounter - 1}`,
|
||||
icon: 'lucide:package',
|
||||
content: html`
|
||||
<div style="padding: 20px; text-align: center;">
|
||||
<div style="color: #71717a;">New widget content</div>
|
||||
<div style="margin-top: 8px; font-size: 24px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">${Math.floor(Math.random() * 1000)}</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
grid.addWidget(newWidget, true);
|
||||
});
|
||||
} else if (text === 'Compact Grid') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.compact();
|
||||
});
|
||||
} else if (text === 'Toggle Edit Mode') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.editable = !grid.editable;
|
||||
button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to grid events
|
||||
grid.addEventListener('widget-move', (e: CustomEvent) => {
|
||||
console.log('Widget moved:', e.detail.widget);
|
||||
});
|
||||
|
||||
grid.addEventListener('widget-resize', (e: CustomEvent) => {
|
||||
console.log('Widget resized:', e.detail.widget);
|
||||
});
|
||||
}}>
|
||||
<style>
|
||||
${css`
|
||||
.demoBox {
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#09090b')};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.demo-controls dees-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid-container-wrapper {
|
||||
flex: 1;
|
||||
min-height: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
font-size: 12px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demoBox">
|
||||
<div class="demo-controls">
|
||||
<dees-button-group label="Animation:">
|
||||
<dees-button>Toggle Animation</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Display:">
|
||||
<dees-button>Toggle Grid Lines</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Actions:">
|
||||
<dees-button>Add Widget</dees-button>
|
||||
<dees-button>Compact Grid</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Mode:">
|
||||
<dees-button>Toggle Edit Mode</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="grid-container-wrapper">
|
||||
<dees-dashboardgrid id="dashboardGrid"></dees-dashboardgrid>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
Drag widgets to reposition • Resize from edges and corners • Add widgets with auto-positioning
|
||||
</div>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
813
ts_web/elements/dees-dashboardgrid.ts
Normal file
813
ts_web/elements/dees-dashboardgrid.ts
Normal file
@ -0,0 +1,813 @@
|
||||
import * as plugins from './00plugins.js';
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import './dees-icon.js';
|
||||
import { demoFunc } from './dees-dashboardgrid.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-dashboardgrid': DeesDashboardgrid;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IDashboardWidget {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
maxW?: number;
|
||||
maxH?: number;
|
||||
content: TemplateResult | string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
noMove?: boolean;
|
||||
noResize?: boolean;
|
||||
locked?: boolean;
|
||||
autoPosition?: boolean; // Auto-position widget in first available space
|
||||
}
|
||||
|
||||
@customElement('dees-dashboardgrid')
|
||||
export class DeesDashboardgrid extends DeesElement {
|
||||
// STATIC
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
@property({ type: Array })
|
||||
public widgets: IDashboardWidget[] = [];
|
||||
|
||||
@property({ type: Number })
|
||||
public cellHeight: number = 80;
|
||||
|
||||
@property({ type: Object })
|
||||
public margin: number | { top?: number; right?: number; bottom?: number; left?: number } = 10;
|
||||
|
||||
@property({ type: Number })
|
||||
public columns: number = 12;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public editable: boolean = true;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public enableAnimation: boolean = true;
|
||||
|
||||
@property({ type: String })
|
||||
public cellHeightUnit: 'px' | 'em' | 'rem' | 'auto' = 'px';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public rtl: boolean = false; // Right-to-left support
|
||||
|
||||
@property({ type: Boolean })
|
||||
public showGridLines: boolean = false;
|
||||
|
||||
@state()
|
||||
private draggedWidget: IDashboardWidget | null = null;
|
||||
|
||||
@state()
|
||||
private draggedElement: HTMLElement | null = null;
|
||||
|
||||
@state()
|
||||
private dragOffsetX: number = 0;
|
||||
|
||||
@state()
|
||||
private dragOffsetY: number = 0;
|
||||
|
||||
@state()
|
||||
private dragMouseX: number = 0;
|
||||
|
||||
@state()
|
||||
private dragMouseY: number = 0;
|
||||
|
||||
@state()
|
||||
private placeholderPosition: { x: number; y: number } | null = null;
|
||||
|
||||
@state()
|
||||
private resizingWidget: IDashboardWidget | null = null;
|
||||
|
||||
@state()
|
||||
private resizeStartW: number = 0;
|
||||
|
||||
@state()
|
||||
private resizeStartH: number = 0;
|
||||
|
||||
@state()
|
||||
private resizeStartX: number = 0;
|
||||
|
||||
@state()
|
||||
private resizeStartY: number = 0;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.grid-widget {
|
||||
position: absolute;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
:host([enableanimation]) .grid-widget {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.grid-widget.dragging {
|
||||
z-index: 1000;
|
||||
transition: none !important;
|
||||
opacity: 0.8;
|
||||
cursor: grabbing;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder {
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder .widget-content {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.grid-widget.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
'0 1px 3px rgba(0, 0, 0, 0.3)'
|
||||
)};
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.grid-widget:hover .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
'0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
)};
|
||||
}
|
||||
|
||||
.grid-widget.dragging .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 16px 48px rgba(0, 0, 0, 0.25)',
|
||||
'0 16px 48px rgba(0, 0, 0, 0.6)'
|
||||
)};
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.widget-header:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.widget-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.widget-header.locked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.widget-header.locked:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.widget-header dees-icon {
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.widget-body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.widget-body.has-header {
|
||||
top: 45px;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.resize-handle-e {
|
||||
cursor: ew-resize;
|
||||
width: 12px;
|
||||
right: -6px;
|
||||
top: 10%;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.resize-handle-s {
|
||||
cursor: ns-resize;
|
||||
height: 12px;
|
||||
width: 80%;
|
||||
bottom: -6px;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.resize-handle-se {
|
||||
cursor: se-resize;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.resize-handle-se::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
border-bottom: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.grid-widget:hover .resize-handle-se {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover::after {
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.grid-placeholder {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.1;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state dees-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Grid lines */
|
||||
.grid-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.grid-line-vertical {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.grid-line-horizontal {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.widgets.length === 0) {
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:layoutGrid'}></dees-icon>
|
||||
<div>No widgets configured</div>
|
||||
<div style="font-size: 14px; margin-top: 8px;">Add widgets to populate the dashboard</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const margins = this.getMargins();
|
||||
const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 4);
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
const gridHeight = maxY * cellHeightValue + (maxY + 1) * margins.vertical;
|
||||
|
||||
return html`
|
||||
<div class="grid-container" style="height: ${gridHeight}px;">
|
||||
${this.showGridLines ? this.renderGridLines(gridHeight) : ''}
|
||||
${this.widgets.map(widget => this.renderWidget(widget))}
|
||||
${this.placeholderPosition && this.draggedWidget ? this.renderPlaceholder() : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGridLines(gridHeight: number): TemplateResult {
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Convert margin to percentage for consistent calculation
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
|
||||
|
||||
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
|
||||
|
||||
const verticalLines = [];
|
||||
const horizontalLines = [];
|
||||
|
||||
// Vertical lines
|
||||
for (let i = 0; i <= this.columns; i++) {
|
||||
const left = i * cellWidth + i * marginHorizontalPercent;
|
||||
verticalLines.push(html`
|
||||
<div class="grid-line-vertical" style="left: ${left}%;"></div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
const numHorizontalLines = Math.ceil(gridHeight / (cellHeightValue + margins.vertical));
|
||||
for (let i = 0; i <= numHorizontalLines; i++) {
|
||||
const top = i * cellHeightValue + i * margins.vertical;
|
||||
horizontalLines.push(html`
|
||||
<div class="grid-line-horizontal" style="top: ${top}px;"></div>
|
||||
`);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="grid-lines">
|
||||
${verticalLines}
|
||||
${horizontalLines}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWidget(widget: IDashboardWidget): TemplateResult {
|
||||
const isDragging = this.draggedWidget?.id === widget.id;
|
||||
const isResizing = this.resizingWidget?.id === widget.id;
|
||||
const isLocked = widget.locked || !this.editable;
|
||||
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Convert margin to percentage of container width for consistent calculation
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
|
||||
|
||||
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
|
||||
|
||||
const left = widget.x * cellWidth + (widget.x + 1) * marginHorizontalPercent;
|
||||
const top = widget.y * cellHeightValue + (widget.y + 1) * margins.vertical;
|
||||
const width = widget.w * cellWidth + (widget.w - 1) * marginHorizontalPercent;
|
||||
const height = widget.h * cellHeightValue + (widget.h - 1) * margins.vertical;
|
||||
|
||||
// Apply transform when dragging for smooth movement
|
||||
let transform = '';
|
||||
if (isDragging && this.draggedElement) {
|
||||
const containerRect = this.getBoundingClientRect();
|
||||
const translateX = this.dragMouseX - containerRect.left - this.dragOffsetX - (left / 100 * containerRect.width);
|
||||
const translateY = this.dragMouseY - containerRect.top - this.dragOffsetY - top;
|
||||
transform = `transform: translate(${translateX}px, ${translateY}px);`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''}"
|
||||
style="
|
||||
${this.rtl ? 'right' : 'left'}: ${left}%;
|
||||
top: ${top}px;
|
||||
width: ${width}%;
|
||||
height: ${height}px;
|
||||
${transform}
|
||||
"
|
||||
data-widget-id="${widget.id}"
|
||||
>
|
||||
<div class="widget-content">
|
||||
${widget.title ? html`
|
||||
<div
|
||||
class="widget-header ${isLocked ? 'locked' : ''}"
|
||||
@mousedown=${!isLocked && !widget.noMove ? (e: MouseEvent) => this.startDrag(e, widget) : null}
|
||||
>
|
||||
${widget.icon ? html`<dees-icon .icon=${widget.icon}></dees-icon>` : ''}
|
||||
${widget.title}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="widget-body ${widget.title ? 'has-header' : ''}">
|
||||
${widget.content}
|
||||
</div>
|
||||
${!isLocked && !widget.noResize ? html`
|
||||
<div class="resize-handle resize-handle-e" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'e')}></div>
|
||||
<div class="resize-handle resize-handle-s" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 's')}></div>
|
||||
<div class="resize-handle resize-handle-se" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'se')}></div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(): TemplateResult {
|
||||
if (!this.placeholderPosition || !this.draggedWidget) return html``;
|
||||
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Convert margin to percentage of container width for consistent calculation
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
|
||||
|
||||
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
|
||||
|
||||
const left = this.placeholderPosition.x * cellWidth + (this.placeholderPosition.x + 1) * marginHorizontalPercent;
|
||||
const top = this.placeholderPosition.y * cellHeightValue + (this.placeholderPosition.y + 1) * margins.vertical;
|
||||
const width = this.draggedWidget.w * cellWidth + (this.draggedWidget.w - 1) * marginHorizontalPercent;
|
||||
const height = this.draggedWidget.h * cellHeightValue + (this.draggedWidget.h - 1) * margins.vertical;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget placeholder"
|
||||
style="
|
||||
${this.rtl ? 'right' : 'left'}: ${left}%;
|
||||
top: ${top}px;
|
||||
width: ${width}%;
|
||||
height: ${height}px;
|
||||
"
|
||||
>
|
||||
<div class="widget-content"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private startDrag(e: MouseEvent, widget: IDashboardWidget) {
|
||||
e.preventDefault();
|
||||
this.draggedWidget = widget;
|
||||
this.draggedElement = (e.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement;
|
||||
|
||||
const rect = this.draggedElement.getBoundingClientRect();
|
||||
|
||||
this.dragOffsetX = e.clientX - rect.left;
|
||||
this.dragOffsetY = e.clientY - rect.top;
|
||||
|
||||
// Initialize mouse position
|
||||
this.dragMouseX = e.clientX;
|
||||
this.dragMouseY = e.clientY;
|
||||
|
||||
// Initialize placeholder at current widget position
|
||||
this.placeholderPosition = { x: widget.x, y: widget.y };
|
||||
|
||||
document.addEventListener('mousemove', this.handleDrag);
|
||||
document.addEventListener('mouseup', this.endDrag);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleDrag = (e: MouseEvent) => {
|
||||
if (!this.draggedWidget || !this.draggedElement) return;
|
||||
|
||||
// Update mouse position for smooth dragging
|
||||
this.dragMouseX = e.clientX;
|
||||
this.dragMouseY = e.clientY;
|
||||
|
||||
const containerRect = this.getBoundingClientRect();
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Get widget position relative to grid container
|
||||
const mouseX = e.clientX - containerRect.left - this.dragOffsetX;
|
||||
const mouseY = e.clientY - containerRect.top - this.dragOffsetY;
|
||||
|
||||
// Use pixel calculations for accuracy
|
||||
const totalWidth = containerRect.width;
|
||||
const totalMarginWidth = margins.horizontal * (this.columns + 1);
|
||||
const availableWidth = totalWidth - totalMarginWidth;
|
||||
const cellWidthPx = availableWidth / this.columns;
|
||||
|
||||
// Calculate grid X position
|
||||
// Account for the initial margin and then repeating pattern of cell+margin
|
||||
let gridX = 0;
|
||||
if (mouseX > margins.horizontal) {
|
||||
const adjustedX = mouseX - margins.horizontal;
|
||||
const cellPlusMargin = cellWidthPx + margins.horizontal;
|
||||
gridX = Math.floor(adjustedX / cellPlusMargin + 0.5); // +0.5 for rounding to nearest
|
||||
}
|
||||
|
||||
// Calculate grid Y position
|
||||
let gridY = 0;
|
||||
if (mouseY > margins.vertical) {
|
||||
const adjustedY = mouseY - margins.vertical;
|
||||
const cellPlusMargin = cellHeightValue + margins.vertical;
|
||||
gridY = Math.floor(adjustedY / cellPlusMargin + 0.5); // +0.5 for rounding to nearest
|
||||
}
|
||||
|
||||
const clampedX = Math.max(0, Math.min(gridX, this.columns - this.draggedWidget.w));
|
||||
const clampedY = Math.max(0, gridY);
|
||||
|
||||
// Update placeholder position instead of widget position during drag
|
||||
if (!this.placeholderPosition ||
|
||||
clampedX !== this.placeholderPosition.x ||
|
||||
clampedY !== this.placeholderPosition.y) {
|
||||
const collision = this.checkCollision(this.draggedWidget, clampedX, clampedY);
|
||||
if (!collision) {
|
||||
this.placeholderPosition = { x: clampedX, y: clampedY };
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private endDrag = () => {
|
||||
// Apply final position from placeholder
|
||||
if (this.draggedWidget && this.placeholderPosition) {
|
||||
this.draggedWidget.x = this.placeholderPosition.x;
|
||||
this.draggedWidget.y = this.placeholderPosition.y;
|
||||
|
||||
this.dispatchEvent(new CustomEvent('widget-move', {
|
||||
detail: { widget: this.draggedWidget },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear drag state
|
||||
this.draggedWidget = null;
|
||||
this.draggedElement = null;
|
||||
this.placeholderPosition = null;
|
||||
this.dragMouseX = 0;
|
||||
this.dragMouseY = 0;
|
||||
|
||||
document.removeEventListener('mousemove', this.handleDrag);
|
||||
document.removeEventListener('mouseup', this.endDrag);
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private startResize(e: MouseEvent, widget: IDashboardWidget, handle: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.resizingWidget = widget;
|
||||
this.resizeStartW = widget.w;
|
||||
this.resizeStartH = widget.h;
|
||||
this.resizeStartX = e.clientX;
|
||||
this.resizeStartY = e.clientY;
|
||||
|
||||
const handleResize = (e: MouseEvent) => {
|
||||
if (!this.resizingWidget) return;
|
||||
|
||||
const containerRect = this.getBoundingClientRect();
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
const cellWidth = (containerRect.width - margins.horizontal * (this.columns + 1)) / this.columns;
|
||||
|
||||
const deltaX = e.clientX - this.resizeStartX;
|
||||
const deltaY = e.clientY - this.resizeStartY;
|
||||
|
||||
if (handle.includes('e')) {
|
||||
const newW = Math.round(this.resizeStartW + deltaX / (cellWidth + margins.horizontal));
|
||||
const maxW = widget.maxW || (this.columns - this.resizingWidget.x);
|
||||
this.resizingWidget.w = Math.max(widget.minW || 1, Math.min(newW, maxW));
|
||||
}
|
||||
|
||||
if (handle.includes('s')) {
|
||||
const newH = Math.round(this.resizeStartH + deltaY / (cellHeightValue + margins.vertical));
|
||||
const maxH = widget.maxH || Infinity;
|
||||
this.resizingWidget.h = Math.max(widget.minH || 1, Math.min(newH, maxH));
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
|
||||
this.dispatchEvent(new CustomEvent('widget-resize', {
|
||||
detail: { widget: this.resizingWidget },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const endResize = () => {
|
||||
this.resizingWidget = null;
|
||||
document.removeEventListener('mousemove', handleResize);
|
||||
document.removeEventListener('mouseup', endResize);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleResize);
|
||||
document.addEventListener('mouseup', endResize);
|
||||
}
|
||||
|
||||
|
||||
public removeWidget(widgetId: string) {
|
||||
this.widgets = this.widgets.filter(w => w.id !== widgetId);
|
||||
}
|
||||
|
||||
public updateWidget(widgetId: string, updates: Partial<IDashboardWidget>) {
|
||||
this.widgets = this.widgets.map(w =>
|
||||
w.id === widgetId ? { ...w, ...updates } : w
|
||||
);
|
||||
}
|
||||
|
||||
public getLayout(): Array<{ id: string; x: number; y: number; w: number; h: number }> {
|
||||
return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }));
|
||||
}
|
||||
|
||||
public setLayout(layout: Array<{ id: string; x: number; y: number; w: number; h: number }>) {
|
||||
this.widgets = this.widgets.map(widget => {
|
||||
const layoutItem = layout.find(l => l.id === widget.id);
|
||||
return layoutItem ? { ...widget, ...layoutItem } : widget;
|
||||
});
|
||||
}
|
||||
|
||||
public lockGrid() {
|
||||
this.editable = false;
|
||||
}
|
||||
|
||||
public unlockGrid() {
|
||||
this.editable = true;
|
||||
}
|
||||
|
||||
private getMargins(): { horizontal: number; vertical: number; top: number; right: number; bottom: number; left: number } {
|
||||
if (typeof this.margin === 'number') {
|
||||
return {
|
||||
horizontal: this.margin,
|
||||
vertical: this.margin,
|
||||
top: this.margin,
|
||||
right: this.margin,
|
||||
bottom: this.margin,
|
||||
left: this.margin,
|
||||
};
|
||||
}
|
||||
|
||||
const margins = {
|
||||
top: this.margin.top ?? 10,
|
||||
right: this.margin.right ?? 10,
|
||||
bottom: this.margin.bottom ?? 10,
|
||||
left: this.margin.left ?? 10,
|
||||
};
|
||||
|
||||
return {
|
||||
...margins,
|
||||
horizontal: (margins.left + margins.right) / 2,
|
||||
vertical: (margins.top + margins.bottom) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
private getCellHeight(): number {
|
||||
if (this.cellHeightUnit === 'auto') {
|
||||
// Calculate square cells based on container width
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const margins = this.getMargins();
|
||||
const cellWidth = (containerWidth - margins.horizontal * (this.columns + 1)) / this.columns;
|
||||
return cellWidth;
|
||||
}
|
||||
|
||||
return this.cellHeight;
|
||||
}
|
||||
|
||||
private checkCollision(widget: IDashboardWidget, newX: number, newY: number): boolean {
|
||||
const widgets = this.widgets.filter(w => w.id !== widget.id);
|
||||
|
||||
for (const other of widgets) {
|
||||
if (newX < other.x + other.w &&
|
||||
newX + widget.w > other.x &&
|
||||
newY < other.y + other.h &&
|
||||
newY + widget.h > other.y) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public addWidget(widget: IDashboardWidget, autoPosition = false) {
|
||||
if (autoPosition || widget.autoPosition) {
|
||||
// Find first available position
|
||||
const position = this.findAvailablePosition(widget.w, widget.h);
|
||||
widget.x = position.x;
|
||||
widget.y = position.y;
|
||||
}
|
||||
|
||||
this.widgets = [...this.widgets, widget];
|
||||
}
|
||||
|
||||
private findAvailablePosition(width: number, height: number): { x: number; y: number } {
|
||||
// Try to find space starting from top-left
|
||||
for (let y = 0; y < 100; y++) { // Reasonable limit
|
||||
for (let x = 0; x <= this.columns - width; x++) {
|
||||
const testWidget = { id: 'test', x, y, w: width, h: height, content: '' } as IDashboardWidget;
|
||||
if (!this.checkCollision(testWidget, x, y)) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no space found, place at bottom
|
||||
const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 0);
|
||||
return { x: 0, y: maxY };
|
||||
}
|
||||
|
||||
public compact(direction: 'vertical' | 'horizontal' = 'vertical') {
|
||||
const sortedWidgets = [...this.widgets].sort((a, b) => {
|
||||
if (direction === 'vertical') {
|
||||
if (a.y !== b.y) return a.y - b.y;
|
||||
return a.x - b.x;
|
||||
} else {
|
||||
if (a.x !== b.x) return a.x - b.x;
|
||||
return a.y - b.y;
|
||||
}
|
||||
});
|
||||
|
||||
for (const widget of sortedWidgets) {
|
||||
if (widget.locked || widget.noMove) continue;
|
||||
|
||||
if (direction === 'vertical') {
|
||||
// Move up as far as possible
|
||||
while (widget.y > 0 && !this.checkCollision(widget, widget.x, widget.y - 1)) {
|
||||
widget.y--;
|
||||
}
|
||||
} else {
|
||||
// Move left as far as possible
|
||||
while (widget.x > 0 && !this.checkCollision(widget, widget.x - 1, widget.y)) {
|
||||
widget.x--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
@ -175,21 +175,21 @@ export class DeesDataviewStatusobject extends DeesElement {
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'Copy Value',
|
||||
iconName: 'lucideCopy',
|
||||
iconName: 'lucide:copy',
|
||||
action: async () => {
|
||||
await this.copyToClipboard(detailArg.value, 'Value');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Key',
|
||||
iconName: 'lucideKey',
|
||||
iconName: 'lucide:key',
|
||||
action: async () => {
|
||||
await this.copyToClipboard(detailArg.name, 'Key');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Key:Value',
|
||||
iconName: 'lucideCopyPlus',
|
||||
iconName: 'lucide:copy-plus',
|
||||
action: async () => {
|
||||
await this.copyToClipboard(`${detailArg.name}: ${detailArg.value}`, 'Key:Value');
|
||||
},
|
||||
|
@ -3,40 +3,91 @@ import type { DeesForm } from './dees-form.js';
|
||||
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;
|
||||
}
|
||||
<style>
|
||||
${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 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
<div class="demo-container">
|
||||
.form-output {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-message.success {
|
||||
background: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.2)')};
|
||||
color: ${cssManager.bdTheme('hsl(142.1 70.6% 35.3%)', 'hsl(142.1 70.6% 65.3%)')};
|
||||
}
|
||||
|
||||
.status-message.error {
|
||||
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 72.2% 50.6% / 0.2)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 72.2% 40.6%)', 'hsl(0 72.2% 60.6%)')};
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const form = elementArg.querySelector('dees-form') as DeesForm;
|
||||
const outputDiv = elementArg.querySelector('.form-output');
|
||||
|
||||
if (form && outputDiv) {
|
||||
form.addEventListener('formData', async (eventArg: CustomEvent) => {
|
||||
const data = eventArg.detail.data;
|
||||
console.log('Form submitted with data:', data);
|
||||
|
||||
// Show processing state
|
||||
form.setStatus('pending', 'Processing your registration...');
|
||||
outputDiv.innerHTML = `<strong>Submitted Data:</strong>\n${JSON.stringify(data, null, 2)}`;
|
||||
|
||||
// Simulate API call
|
||||
await domtools.plugins.smartdelay.delayFor(2000);
|
||||
|
||||
// Show success
|
||||
form.setStatus('success', 'Registration completed successfully!');
|
||||
|
||||
// Reset form after delay
|
||||
await domtools.plugins.smartdelay.delayFor(2000);
|
||||
form.reset();
|
||||
outputDiv.innerHTML = '<em>Form has been reset</em>';
|
||||
});
|
||||
|
||||
// Track individual field changes
|
||||
const inputs = form.querySelectorAll('dees-input-text, dees-input-dropdown, dees-input-checkbox');
|
||||
inputs.forEach((input) => {
|
||||
input.addEventListener('changeSubject', () => {
|
||||
console.log('Field changed:', input.getAttribute('key'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .heading="Complete Form Example" .description="A comprehensive form with various input types, validation, and form submission handling">
|
||||
<dees-form
|
||||
@formData=${async (eventArg) => {
|
||||
const form: DeesForm = eventArg.currentTarget;
|
||||
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-form>
|
||||
<dees-input-text
|
||||
.required=${true}
|
||||
key="firstName"
|
||||
@ -92,13 +143,47 @@ export const demoFunc = () => html`
|
||||
|
||||
<dees-form-submit>Create Account</dees-form-submit>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
|
||||
<div class="form-output">
|
||||
<em>Submit the form to see the collected data...</em>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const form = elementArg.querySelector('dees-form') as DeesForm;
|
||||
|
||||
if (form) {
|
||||
// Track horizontal layout behavior
|
||||
console.log('Horizontal form layout active');
|
||||
|
||||
// Monitor filter changes
|
||||
form.addEventListener('formData', (event: CustomEvent) => {
|
||||
const filters = event.detail.data;
|
||||
console.log('Filter applied:', filters);
|
||||
|
||||
// Simulate search
|
||||
const resultsCount = Math.floor(Math.random() * 100) + 1;
|
||||
console.log(`Found ${resultsCount} results with filters:`, filters);
|
||||
});
|
||||
|
||||
// Setup real-time filter updates
|
||||
const inputs = form.querySelectorAll('[key]');
|
||||
inputs.forEach((input) => {
|
||||
input.addEventListener('changeSubject', async () => {
|
||||
// Get current form data
|
||||
const formData = await form.collectFormData();
|
||||
console.log('Live filter update:', formData);
|
||||
});
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<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"
|
||||
placeholder="Enter keywords..."
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-dropdown
|
||||
@ -132,16 +217,55 @@ export const demoFunc = () => html`
|
||||
></dees-input-checkbox>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const form = elementArg.querySelector('dees-form') as DeesForm;
|
||||
const statusDiv = elementArg.querySelector('#status-display');
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('formData', async (eventArg: CustomEvent) => {
|
||||
const data = eventArg.detail.data;
|
||||
console.log('Advanced form data:', data);
|
||||
|
||||
// Show validation in progress
|
||||
form.setStatus('pending', 'Validating your information...');
|
||||
|
||||
// Simulate validation
|
||||
await domtools.plugins.smartdelay.delayFor(1500);
|
||||
|
||||
// Check IBAN validity (simple check)
|
||||
if (data.iban && data.iban.length > 15) {
|
||||
form.setStatus('success', 'Application submitted successfully!');
|
||||
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'status-message success';
|
||||
statusDiv.textContent = '✓ Your application has been submitted. We will contact you soon.';
|
||||
}
|
||||
} else {
|
||||
form.setStatus('error', 'Please check your IBAN');
|
||||
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'status-message error';
|
||||
statusDiv.textContent = '✗ Invalid IBAN format. Please check and try again.';
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Form data logged:', data);
|
||||
});
|
||||
|
||||
// Monitor file uploads
|
||||
const fileUpload = form.querySelector('dees-input-fileupload');
|
||||
if (fileUpload) {
|
||||
fileUpload.addEventListener('change', (event: any) => {
|
||||
const files = event.detail?.files || [];
|
||||
console.log(`${files.length} file(s) selected for upload`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<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-form>
|
||||
<dees-input-iban
|
||||
key="iban"
|
||||
label="IBAN"
|
||||
@ -181,7 +305,9 @@ export const demoFunc = () => html`
|
||||
|
||||
<dees-form-submit>Submit Application</dees-form-submit>
|
||||
</dees-form>
|
||||
|
||||
<div id="status-display"></div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`;
|
@ -9,6 +9,7 @@ import {
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
|
||||
import { DeesInputCheckbox } from './dees-input-checkbox.js';
|
||||
import { DeesInputDatepicker } from './dees-input-datepicker.js';
|
||||
import { DeesInputText } from './dees-input-text.js';
|
||||
import { DeesInputQuantitySelector } from './dees-input-quantityselector.js';
|
||||
import { DeesInputRadiogroup } from './dees-input-radiogroup.js';
|
||||
@ -25,6 +26,7 @@ import { demoFunc } from './dees-form.demo.js';
|
||||
// Unified set for form input types
|
||||
const FORM_INPUT_TYPES = [
|
||||
DeesInputCheckbox,
|
||||
DeesInputDatepicker,
|
||||
DeesInputDropdown,
|
||||
DeesInputFileupload,
|
||||
DeesInputIban,
|
||||
@ -39,6 +41,7 @@ const FORM_INPUT_TYPES = [
|
||||
|
||||
export type TFormInputElement =
|
||||
| DeesInputCheckbox
|
||||
| DeesInputDatepicker
|
||||
| DeesInputDropdown
|
||||
| DeesInputFileupload
|
||||
| DeesInputIban
|
||||
|
410
ts_web/elements/dees-input-datepicker.demo.ts
Normal file
410
ts_web/elements/dees-input-datepicker.demo.ts
Normal file
@ -0,0 +1,410 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import './dees-panel.js';
|
||||
import './dees-input-datepicker.js';
|
||||
import type { DeesInputDatepicker } from './dees-input-datepicker.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
${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;
|
||||
}
|
||||
|
||||
.demo-output {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 105, 242, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.date-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate basic date picker functionality
|
||||
const datePicker = elementArg.querySelector('dees-input-datepicker');
|
||||
|
||||
if (datePicker) {
|
||||
datePicker.addEventListener('change', (event: CustomEvent) => {
|
||||
console.log('Basic date selected:', (event.target as DeesInputDatepicker).value);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Basic Date Picker'} .subtitle=${'Simple date selection without time'}>
|
||||
<dees-input-datepicker
|
||||
label="Select Date"
|
||||
description="Choose a date from the calendar"
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate date and time picker
|
||||
const dateTimePicker = elementArg.querySelector('dees-input-datepicker[label="Event Date & Time"]');
|
||||
const appointmentPicker = elementArg.querySelector('dees-input-datepicker[label="Appointment"]');
|
||||
|
||||
if (dateTimePicker) {
|
||||
dateTimePicker.addEventListener('change', (event: CustomEvent) => {
|
||||
const value = (event.target as DeesInputDatepicker).value;
|
||||
console.log('24h format datetime:', value);
|
||||
});
|
||||
}
|
||||
|
||||
if (appointmentPicker) {
|
||||
appointmentPicker.addEventListener('change', (event: CustomEvent) => {
|
||||
const value = (event.target as DeesInputDatepicker).value;
|
||||
console.log('12h format datetime:', value);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Date and Time Selection'} .subtitle=${'Date pickers with time selection in different formats'}>
|
||||
<dees-input-datepicker
|
||||
label="Event Date & Time"
|
||||
description="Select both date and time (24-hour format)"
|
||||
.enableTime=${true}
|
||||
timeFormat="24h"
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="Appointment"
|
||||
description="Date and time with AM/PM selector (15-minute increments)"
|
||||
.enableTime=${true}
|
||||
timeFormat="12h"
|
||||
.minuteIncrement=${15}
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate timezone functionality
|
||||
const timezonePickers = elementArg.querySelectorAll('dees-input-datepicker');
|
||||
|
||||
timezonePickers.forEach((picker) => {
|
||||
picker.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputDatepicker;
|
||||
console.log(`${target.label} value:`, target.value);
|
||||
const input = target.shadowRoot?.querySelector('.date-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
console.log(`${target.label} formatted:`, input.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'Timezone Support'} .subtitle=${'Date and time selection with timezone awareness'}>
|
||||
<dees-input-datepicker
|
||||
label="Meeting Time (with Timezone)"
|
||||
description="Select a date/time and timezone for the meeting"
|
||||
.enableTime=${true}
|
||||
.enableTimezone=${true}
|
||||
timeFormat="24h"
|
||||
timezone="America/New_York"
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="Global Event Schedule"
|
||||
description="Schedule an event across different timezones"
|
||||
.enableTime=${true}
|
||||
.enableTimezone=${true}
|
||||
timeFormat="12h"
|
||||
timezone="Europe/London"
|
||||
.minuteIncrement=${30}
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate date constraints
|
||||
const futureDatePicker = elementArg.querySelector('dees-input-datepicker');
|
||||
|
||||
if (futureDatePicker) {
|
||||
// Show the min/max constraints in action
|
||||
futureDatePicker.addEventListener('change', (event: CustomEvent) => {
|
||||
const value = (event.target as DeesInputDatepicker).value;
|
||||
if (value) {
|
||||
const selectedDate = new Date(value);
|
||||
const today = new Date();
|
||||
const daysDiff = Math.floor((selectedDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
console.log(`Selected date is ${daysDiff} days from today`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Date Range Constraints'} .subtitle=${'Limit selectable dates with min and max values'}>
|
||||
<dees-input-datepicker
|
||||
label="Future Date Only"
|
||||
description="Can only select dates from today to 90 days in the future"
|
||||
.minDate=${new Date().toISOString()}
|
||||
.maxDate=${new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()}
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate different date formats
|
||||
const formatters = {
|
||||
'DD/MM/YYYY': 'European',
|
||||
'MM/DD/YYYY': 'US',
|
||||
'YYYY-MM-DD': 'ISO'
|
||||
};
|
||||
|
||||
const datePickers = elementArg.querySelectorAll('dees-input-datepicker');
|
||||
datePickers.forEach((picker) => {
|
||||
picker.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputDatepicker;
|
||||
// Log the formatted value that's displayed in the input
|
||||
const input = target.shadowRoot?.querySelector('.date-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
console.log(`${target.label} format:`, input.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'Date Formats'} .subtitle=${'Different date display formats for various regions'}>
|
||||
<div class="date-group">
|
||||
<dees-input-datepicker
|
||||
label="European Format"
|
||||
dateFormat="DD/MM/YYYY"
|
||||
.value=${new Date().toISOString()}
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="US Format"
|
||||
dateFormat="MM/DD/YYYY"
|
||||
.value=${new Date().toISOString()}
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="ISO Format"
|
||||
dateFormat="YYYY-MM-DD"
|
||||
.value=${new Date().toISOString()}
|
||||
></dees-input-datepicker>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate required field validation
|
||||
const requiredPicker = elementArg.querySelector('dees-input-datepicker[required]');
|
||||
|
||||
if (requiredPicker) {
|
||||
// Monitor blur events for validation
|
||||
requiredPicker.addEventListener('blur', () => {
|
||||
const picker = requiredPicker as DeesInputDatepicker;
|
||||
const value = picker.getValue();
|
||||
if (!value) {
|
||||
console.log('Required date field is empty');
|
||||
}
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Form States'} .subtitle=${'Required and disabled states'}>
|
||||
<dees-input-datepicker
|
||||
label="Birth Date"
|
||||
description="This field is required"
|
||||
.required=${true}
|
||||
placeholder="Select your birth date"
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="Disabled Date"
|
||||
description="This field cannot be edited"
|
||||
.disabled=${true}
|
||||
.value=${new Date().toISOString()}
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate week start customization
|
||||
const usPicker = elementArg.querySelector('dees-input-datepicker[label="US Calendar"]');
|
||||
const euPicker = elementArg.querySelector('dees-input-datepicker[label="EU Calendar"]');
|
||||
|
||||
if (usPicker) {
|
||||
console.log('US Calendar starts on Sunday (0)');
|
||||
}
|
||||
if (euPicker) {
|
||||
console.log('EU Calendar starts on Monday (1)');
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Calendar Customization'} .subtitle=${'Different week start days for various regions'}>
|
||||
<div class="date-group">
|
||||
<dees-input-datepicker
|
||||
label="US Calendar"
|
||||
description="Week starts on Sunday"
|
||||
.weekStartsOn=${0}
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="EU Calendar"
|
||||
description="Week starts on Monday"
|
||||
.weekStartsOn=${1}
|
||||
></dees-input-datepicker>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Generate weekend dates for the current month
|
||||
const generateWeekends = () => {
|
||||
const weekends = [];
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
|
||||
// Get all weekends for current month
|
||||
const date = new Date(year, month, 1);
|
||||
while (date.getMonth() === month) {
|
||||
if (date.getDay() === 0 || date.getDay() === 6) {
|
||||
weekends.push(new Date(date).toISOString());
|
||||
}
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
return weekends;
|
||||
};
|
||||
|
||||
const picker = elementArg.querySelector('dees-input-datepicker');
|
||||
if (picker) {
|
||||
picker.disabledDates = generateWeekends();
|
||||
console.log('Disabled weekend dates for current month');
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Disabled Dates'} .subtitle=${'Calendar with specific dates disabled (weekends in current month)'}>
|
||||
<dees-input-datepicker
|
||||
label="Availability Calendar"
|
||||
description="Weekends are disabled for the current month"
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Generate sample events for the calendar
|
||||
const today = new Date();
|
||||
const currentMonth = today.getMonth();
|
||||
const currentYear = today.getFullYear();
|
||||
|
||||
const sampleEvents = [
|
||||
// Current week events
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 1).toString().padStart(2, '0')}-${today.getDate().toString().padStart(2, '0')}`,
|
||||
title: "Team Meeting",
|
||||
type: "info" as const,
|
||||
count: 2
|
||||
},
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 1).toString().padStart(2, '0')}-${(today.getDate() + 1).toString().padStart(2, '0')}`,
|
||||
title: "Project Deadline",
|
||||
type: "warning" as const
|
||||
},
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 1).toString().padStart(2, '0')}-${(today.getDate() + 2).toString().padStart(2, '0')}`,
|
||||
title: "Release Day",
|
||||
type: "success" as const
|
||||
},
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 1).toString().padStart(2, '0')}-${(today.getDate() + 5).toString().padStart(2, '0')}`,
|
||||
title: "Urgent Fix Required",
|
||||
type: "error" as const
|
||||
},
|
||||
// Multiple events on one day
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 1).toString().padStart(2, '0')}-${(today.getDate() + 7).toString().padStart(2, '0')}`,
|
||||
title: "Multiple Events Today",
|
||||
type: "info" as const,
|
||||
count: 5
|
||||
},
|
||||
// Next month event
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 2).toString().padStart(2, '0')}-15`,
|
||||
title: "Future Planning Session",
|
||||
type: "info" as const
|
||||
}
|
||||
];
|
||||
|
||||
const picker = elementArg.querySelector('dees-input-datepicker');
|
||||
if (picker) {
|
||||
picker.events = sampleEvents;
|
||||
console.log('Calendar events loaded:', sampleEvents);
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Calendar with Events'} .subtitle=${'Visual feedback for scheduled events'}>
|
||||
<dees-input-datepicker
|
||||
label="Event Calendar"
|
||||
description="Days with colored dots have events. Hover to see details."
|
||||
></dees-input-datepicker>
|
||||
|
||||
<div class="demo-output" style="margin-top: 16px;">
|
||||
<strong>Event Legend:</strong><br>
|
||||
<span style="color: #0969da;">● Info</span> |
|
||||
<span style="color: #d29922;">● Warning</span> |
|
||||
<span style="color: #2ea043;">● Success</span> |
|
||||
<span style="color: #cf222e;">● Error</span><br>
|
||||
<em>Days with more than 3 events show a count badge</em>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Interactive event demonstration
|
||||
const picker = elementArg.querySelector('dees-input-datepicker');
|
||||
const output = elementArg.querySelector('#event-output');
|
||||
|
||||
if (picker && output) {
|
||||
picker.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputDatepicker;
|
||||
const value = target.value;
|
||||
if (value) {
|
||||
const date = new Date(value);
|
||||
// Get the formatted value from the input element
|
||||
const input = target.shadowRoot?.querySelector('.date-input') as HTMLInputElement;
|
||||
const formattedValue = input?.value || 'N/A';
|
||||
output.innerHTML = `
|
||||
<strong>Event triggered!</strong><br>
|
||||
ISO Value: ${value}<br>
|
||||
Formatted: ${formattedValue}<br>
|
||||
Date object: ${date.toLocaleString()}
|
||||
`;
|
||||
} else {
|
||||
output.innerHTML = '<em>Date cleared</em>';
|
||||
}
|
||||
});
|
||||
|
||||
picker.addEventListener('blur', () => {
|
||||
console.log('Datepicker lost focus');
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Event Handling'} .subtitle=${'Interactive demonstration of change events'}>
|
||||
<dees-input-datepicker
|
||||
label="Event Demo"
|
||||
description="Select a date to see the event details"
|
||||
></dees-input-datepicker>
|
||||
|
||||
<div id="event-output" class="demo-output">
|
||||
<em>Select a date to see event details...</em>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`;
|
1309
ts_web/elements/dees-input-datepicker.ts
Normal file
1309
ts_web/elements/dees-input-datepicker.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -5,45 +5,63 @@ import './dees-form.js';
|
||||
import './dees-form-submit.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;
|
||||
}
|
||||
<style>
|
||||
${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 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.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>
|
||||
.spacer {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="demo-container">
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate programmatic interaction with basic dropdowns
|
||||
const countryDropdown = elementArg.querySelector('dees-input-dropdown[label="Select Country"]');
|
||||
const roleDropdown = elementArg.querySelector('dees-input-dropdown[label="Select Role"]');
|
||||
|
||||
// Log when country changes
|
||||
if (countryDropdown) {
|
||||
countryDropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
console.log('Country selected:', event.detail);
|
||||
});
|
||||
}
|
||||
|
||||
// Log when role changes
|
||||
if (roleDropdown) {
|
||||
roleDropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
console.log('Role selected:', event.detail);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'1. Basic Dropdowns'} .subtitle=${'Standard dropdown with search functionality and various options'}>
|
||||
<dees-input-dropdown
|
||||
.label=${'Select Country'}
|
||||
@ -70,7 +88,18 @@ export const demoFunc = () => html`
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate simpler dropdown without search
|
||||
const priorityDropdown = elementArg.querySelector('dees-input-dropdown');
|
||||
|
||||
if (priorityDropdown) {
|
||||
priorityDropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
console.log(`Priority changed to: ${event.detail.option}`);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'2. Without Search'} .subtitle=${'Dropdown with search functionality disabled for simpler selection'}>
|
||||
<dees-input-dropdown
|
||||
.label=${'Priority Level'}
|
||||
@ -83,7 +112,20 @@ export const demoFunc = () => html`
|
||||
.selectedOption=${{ option: 'Medium', key: 'medium' }}
|
||||
></dees-input-dropdown>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate horizontal layout with multiple dropdowns
|
||||
const dropdowns = elementArg.querySelectorAll('dees-input-dropdown');
|
||||
|
||||
// Log all changes from horizontal dropdowns
|
||||
dropdowns.forEach((dropdown) => {
|
||||
dropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
const label = dropdown.getAttribute('label');
|
||||
console.log(`${label}: ${event.detail.option}`);
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'3. Horizontal Layout'} .subtitle=${'Multiple dropdowns in a horizontal layout for compact forms'}>
|
||||
<div class="horizontal-group">
|
||||
<dees-input-dropdown
|
||||
@ -120,7 +162,19 @@ export const demoFunc = () => html`
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate state handling
|
||||
const requiredDropdown = elementArg.querySelector('dees-input-dropdown[required]');
|
||||
|
||||
if (requiredDropdown) {
|
||||
// Show validation state changes
|
||||
requiredDropdown.addEventListener('blur', () => {
|
||||
console.log('Required dropdown lost focus');
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'4. States'} .subtitle=${'Different states and configurations'}>
|
||||
<dees-input-dropdown
|
||||
.label=${'Required Field'}
|
||||
@ -141,11 +195,25 @@ export const demoFunc = () => html`
|
||||
.selectedOption=${{ option: 'Cannot Select', key: 'disabled' }}
|
||||
></dees-input-dropdown>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<div class="spacer">
|
||||
(Spacer to test dropdown positioning)
|
||||
</div>
|
||||
<div class="spacer">
|
||||
(Spacer to test dropdown positioning)
|
||||
</div>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// This dropdown demonstrates automatic positioning
|
||||
const dropdown = elementArg.querySelector('dees-input-dropdown');
|
||||
|
||||
if (dropdown) {
|
||||
dropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
console.log('Bottom dropdown selected:', event.detail);
|
||||
});
|
||||
|
||||
// Note: The dropdown automatically detects available space
|
||||
// and opens upward when near the bottom of the viewport
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'5. Bottom Positioning'} .subtitle=${'Dropdown that opens upward when near bottom of viewport'}>
|
||||
<dees-input-dropdown
|
||||
.label=${'Opens Upward'}
|
||||
@ -158,7 +226,30 @@ export const demoFunc = () => html`
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Setup the interactive payload display
|
||||
const dropdown = elementArg.querySelector('dees-input-dropdown');
|
||||
const output = elementArg.querySelector('#selection-output');
|
||||
|
||||
if (dropdown && output) {
|
||||
// Initialize output
|
||||
output.innerHTML = '<em>Select a product to see details...</em>';
|
||||
|
||||
// Handle dropdown changes
|
||||
dropdown.addEventListener('change', (event: CustomEvent) => {
|
||||
if (event.detail.value) {
|
||||
output.innerHTML = `
|
||||
<strong>Selected:</strong> ${event.detail.value.option}<br>
|
||||
<strong>Key:</strong> ${event.detail.value.key}<br>
|
||||
<strong>Price:</strong> $${event.detail.value.payload?.price || 'N/A'}<br>
|
||||
<strong>Features:</strong> ${event.detail.value.payload?.features?.join(', ') || 'N/A'}
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'6. Event Handling & Payload'} .subtitle=${'Dropdown with payload data and change event handling'}>
|
||||
<dees-input-dropdown
|
||||
.label=${'Select Product'}
|
||||
@ -167,24 +258,35 @@ export const demoFunc = () => html`
|
||||
{ option: 'Pro Plan', key: 'pro', payload: { price: 19.99, features: ['Feature A', 'Feature B'] } },
|
||||
{ option: 'Enterprise Plan', key: 'enterprise', payload: { price: 49.99, features: ['Feature A', 'Feature B', 'Feature C'] } }
|
||||
]}
|
||||
@change=${(e: CustomEvent) => {
|
||||
const output = document.querySelector('#selection-output');
|
||||
if (output && e.detail.value) {
|
||||
output.innerHTML = `
|
||||
<strong>Selected:</strong> ${e.detail.value.option}<br>
|
||||
<strong>Key:</strong> ${e.detail.value.key}<br>
|
||||
<strong>Price:</strong> $${e.detail.value.payload?.price || 'N/A'}<br>
|
||||
<strong>Features:</strong> ${e.detail.value.payload?.features?.join(', ') || 'N/A'}
|
||||
`;
|
||||
}
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<div id="selection-output" style="margin-top: 16px; padding: 12px; background: rgba(0, 105, 242, 0.1); border-radius: 4px; font-size: 14px;">
|
||||
<em>Select a product to see details...</em>
|
||||
</div>
|
||||
<div id="selection-output" style="margin-top: 16px; padding: 12px; background: rgba(0, 105, 242, 0.1); border-radius: 4px; font-size: 14px;"></div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate form integration and validation
|
||||
const form = elementArg.querySelector('dees-form');
|
||||
const projectTypeDropdown = elementArg.querySelector('dees-input-dropdown[key="projectType"]');
|
||||
const frameworkDropdown = elementArg.querySelector('dees-input-dropdown[key="framework"]');
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('formData', (event: CustomEvent) => {
|
||||
console.log('Form submitted with data:', event.detail.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (projectTypeDropdown && frameworkDropdown) {
|
||||
// Filter frameworks based on project type
|
||||
projectTypeDropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
const selectedType = event.detail.key;
|
||||
console.log(`Project type changed to: ${selectedType}`);
|
||||
|
||||
// In a real app, you could filter the framework options based on project type
|
||||
// For demo purposes, we just log the change
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'7. Form Integration'} .subtitle=${'Dropdown working within a form with validation'}>
|
||||
<dees-form>
|
||||
<dees-input-dropdown
|
||||
@ -216,6 +318,6 @@ export const demoFunc = () => html`
|
||||
<dees-form-submit .text=${'Create Project'}></dees-form-submit>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`
|
@ -37,6 +37,9 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
@property()
|
||||
public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle';
|
||||
|
||||
@property({ type: Boolean })
|
||||
private isLoading: boolean = false;
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
@ -317,6 +320,53 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Loading state styles */
|
||||
.uploadButton.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.uploadButton .button-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadButton.loading {
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@ -353,7 +403,7 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
${isImage && this.canShowPreview(fileArg) ? html`
|
||||
<img class="image-preview" src="${URL.createObjectURL(fileArg)}" alt="${fileArg.name}">
|
||||
` : html`
|
||||
<dees-icon .iconName=${this.getFileIcon(fileArg)}></dees-icon>
|
||||
<dees-icon .icon=${this.getFileIcon(fileArg)}></dees-icon>
|
||||
`}
|
||||
</div>
|
||||
<div class="info">
|
||||
@ -366,7 +416,7 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
@click=${() => this.removeFile(fileArg)}
|
||||
title="Remove file"
|
||||
>
|
||||
<dees-icon .iconName=${'lucide:x'}></dees-icon>
|
||||
<dees-icon .icon=${'lucide:x'}></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -375,13 +425,20 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
</div>
|
||||
` : html`
|
||||
<div class="drop-hint">
|
||||
<dees-icon .iconName=${'lucide:cloud-upload'}></dees-icon>
|
||||
<dees-icon .icon=${'lucide:cloud-upload'}></dees-icon>
|
||||
<div>Drag files here or click to browse</div>
|
||||
</div>
|
||||
`}
|
||||
<div class="uploadButton" @click=${this.openFileSelector}>
|
||||
<dees-icon .iconName=${'lucide:upload'}></dees-icon>
|
||||
${this.buttonText}
|
||||
<div class="uploadButton ${this.isLoading ? 'loading' : ''}" @click=${this.openFileSelector}>
|
||||
<div class="button-content">
|
||||
${this.isLoading ? html`
|
||||
<div class="loading-spinner"></div>
|
||||
<span>Opening...</span>
|
||||
` : html`
|
||||
<dees-icon .icon=${'lucide:upload'}></dees-icon>
|
||||
${this.buttonText}
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${this.description ? html`
|
||||
@ -482,8 +539,25 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
}
|
||||
|
||||
public async openFileSelector() {
|
||||
if (this.disabled) return;
|
||||
if (this.disabled || this.isLoading) return;
|
||||
|
||||
// Set loading state
|
||||
this.isLoading = true;
|
||||
|
||||
const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]');
|
||||
|
||||
// Set up a focus handler to detect when the dialog is closed without selection
|
||||
const handleFocus = () => {
|
||||
setTimeout(() => {
|
||||
// Check if no file was selected
|
||||
if (!inputFile.files || inputFile.files.length === 0) {
|
||||
this.isLoading = false;
|
||||
}
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
inputFile.click();
|
||||
}
|
||||
|
||||
@ -516,6 +590,10 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
inputFile.addEventListener('change', async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newFiles = Array.from(target.files);
|
||||
|
||||
// Always reset loading state when file dialog interaction completes
|
||||
this.isLoading = false;
|
||||
|
||||
await this.addFiles(newFiles);
|
||||
// Reset the input value to allow selecting the same file again if needed
|
||||
target.value = '';
|
||||
|
@ -1,67 +1,87 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import './dees-panel.js';
|
||||
import type { DeesInputText } from './dees-input-text.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;
|
||||
}
|
||||
<style>
|
||||
${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 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.interactive-section {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.interactive-section {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.output-text {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(210 40% 80%)')};
|
||||
padding: 8px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(215 20.2% 11.8%)')};
|
||||
border-radius: 4px;
|
||||
min-height: 24px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
.output-text {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(210 40% 80%)')};
|
||||
padding: 8px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(215 20.2% 11.8%)')};
|
||||
border-radius: 4px;
|
||||
min-height: 24px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demo-container">
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate basic text input functionality
|
||||
const inputs = elementArg.querySelectorAll('dees-input-text');
|
||||
|
||||
<div class="demo-container">
|
||||
inputs.forEach((input: DeesInputText) => {
|
||||
input.addEventListener('changeSubject', (event: CustomEvent) => {
|
||||
console.log(`Input "${input.label}" changed to:`, input.getValue());
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
console.log(`Input "${input.label}" lost focus`);
|
||||
});
|
||||
});
|
||||
|
||||
// Show password visibility toggle
|
||||
const passwordInput = elementArg.querySelector('dees-input-text[key="password"]') as DeesInputText;
|
||||
if (passwordInput) {
|
||||
console.log('Password input includes visibility toggle');
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Basic Text Inputs'} .subtitle=${'Standard text inputs with labels and descriptions'}>
|
||||
<dees-input-text
|
||||
.label=${'Username'}
|
||||
@ -83,7 +103,33 @@ export const demoFunc = () => html`
|
||||
.key=${'password'}
|
||||
></dees-input-text>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate horizontal layout behavior
|
||||
const horizontalInputs = elementArg.querySelectorAll('dees-input-text');
|
||||
|
||||
// Check that inputs are properly spaced horizontally
|
||||
horizontalInputs.forEach((input: DeesInputText) => {
|
||||
const computedStyle = window.getComputedStyle(input);
|
||||
console.log(`Horizontal input "${input.label}" display:`, computedStyle.display);
|
||||
});
|
||||
|
||||
// Track value changes
|
||||
const firstNameInput = elementArg.querySelector('dees-input-text[key="firstName"]');
|
||||
const lastNameInput = elementArg.querySelector('dees-input-text[key="lastName"]');
|
||||
|
||||
if (firstNameInput && lastNameInput) {
|
||||
const updateFullName = () => {
|
||||
const firstName = (firstNameInput as DeesInputText).getValue();
|
||||
const lastName = (lastNameInput as DeesInputText).getValue();
|
||||
console.log(`Full name: ${firstName} ${lastName}`);
|
||||
};
|
||||
|
||||
firstNameInput.addEventListener('changeSubject', updateFullName);
|
||||
lastNameInput.addEventListener('changeSubject', updateFullName);
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Horizontal Layout'} .subtitle=${'Multiple inputs arranged horizontally for compact forms'}>
|
||||
<div class="horizontal-group">
|
||||
<dees-input-text
|
||||
@ -108,7 +154,23 @@ export const demoFunc = () => html`
|
||||
></dees-input-text>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate different label positions
|
||||
const inputs = elementArg.querySelectorAll('dees-input-text');
|
||||
|
||||
inputs.forEach((input: DeesInputText) => {
|
||||
const position = input.labelPosition;
|
||||
console.log(`Input "${input.label}" has label position: ${position}`);
|
||||
});
|
||||
|
||||
// Show how label position affects layout
|
||||
const leftLabelInputs = elementArg.querySelectorAll('dees-input-text[labelPosition="left"]');
|
||||
if (leftLabelInputs.length > 0) {
|
||||
console.log(`${leftLabelInputs.length} inputs have left-aligned labels for inline layout`);
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Label Positions'} .subtitle=${'Different label positioning options for various layouts'}>
|
||||
<dees-input-text
|
||||
.label=${'Label on Top (Default)'}
|
||||
@ -136,7 +198,41 @@ export const demoFunc = () => html`
|
||||
></dees-input-text>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate validation states
|
||||
const requiredInput = elementArg.querySelector('dees-input-text[required]') as DeesInputText;
|
||||
const disabledInput = elementArg.querySelector('dees-input-text[disabled]') as DeesInputText;
|
||||
const errorInput = elementArg.querySelector('dees-input-text[validationState="invalid"]') as DeesInputText;
|
||||
|
||||
if (requiredInput) {
|
||||
// Show validation on blur for empty required field
|
||||
requiredInput.addEventListener('blur', () => {
|
||||
if (!requiredInput.getValue()) {
|
||||
console.log('Required field is empty!');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (disabledInput) {
|
||||
console.log('Disabled input cannot be edited');
|
||||
}
|
||||
|
||||
if (errorInput) {
|
||||
console.log('Error input shows validation message:', errorInput.validationText);
|
||||
|
||||
// Simulate fixing the error
|
||||
errorInput.addEventListener('changeSubject', () => {
|
||||
const value = errorInput.getValue();
|
||||
if (value.includes('@') && value.includes('.')) {
|
||||
errorInput.validationState = 'valid';
|
||||
errorInput.validationText = '';
|
||||
console.log('Email validation passed!');
|
||||
}
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Validation & States'} .subtitle=${'Different validation states and input configurations'}>
|
||||
<dees-input-text
|
||||
.label=${'Required Field'}
|
||||
@ -157,7 +253,31 @@ export const demoFunc = () => html`
|
||||
.validationState=${'invalid'}
|
||||
></dees-input-text>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Track password visibility toggles
|
||||
const passwordInputs = elementArg.querySelectorAll('dees-input-text[isPasswordBool]');
|
||||
|
||||
passwordInputs.forEach((input: DeesInputText) => {
|
||||
// Monitor for toggle button clicks within shadow DOM
|
||||
const checkToggle = () => {
|
||||
const inputEl = input.shadowRoot?.querySelector('input');
|
||||
if (inputEl) {
|
||||
console.log(`Password field "${input.label}" type:`, inputEl.type);
|
||||
}
|
||||
};
|
||||
|
||||
// Use MutationObserver to detect changes
|
||||
if (input.shadowRoot) {
|
||||
const observer = new MutationObserver(checkToggle);
|
||||
const inputEl = input.shadowRoot.querySelector('input');
|
||||
if (inputEl) {
|
||||
observer.observe(inputEl, { attributes: true, attributeFilter: ['type'] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'Advanced Features'} .subtitle=${'Password visibility toggle and other advanced features'}>
|
||||
<dees-input-text
|
||||
.label=${'Password with Toggle'}
|
||||
@ -173,23 +293,47 @@ export const demoFunc = () => html`
|
||||
.description=${'Keep this key secure and never share it'}
|
||||
></dees-input-text>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Set up interactive example
|
||||
const dynamicInput = elementArg.querySelector('dees-input-text');
|
||||
const output = elementArg.querySelector('#text-input-output');
|
||||
|
||||
if (dynamicInput && output) {
|
||||
// Update output on every change
|
||||
dynamicInput.addEventListener('changeSubject', (event: CustomEvent) => {
|
||||
const value = (event.detail as DeesInputText).getValue();
|
||||
output.textContent = `Current value: "${value}"`;
|
||||
});
|
||||
|
||||
// Also track focus/blur events
|
||||
dynamicInput.addEventListener('focus', () => {
|
||||
console.log('Input focused');
|
||||
});
|
||||
|
||||
dynamicInput.addEventListener('blur', () => {
|
||||
console.log('Input blurred');
|
||||
});
|
||||
|
||||
// Track keypress events
|
||||
let keypressCount = 0;
|
||||
dynamicInput.addEventListener('keydown', () => {
|
||||
keypressCount++;
|
||||
console.log(`Keypress count: ${keypressCount}`);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Interactive Example'} .subtitle=${'Try typing in the inputs to see real-time value changes'}>
|
||||
<dees-input-text
|
||||
.label=${'Dynamic Input'}
|
||||
.placeholder=${'Type something here...'}
|
||||
@changeSubject=${(event) => {
|
||||
const output = document.querySelector('#text-input-output');
|
||||
if (output && event.detail) {
|
||||
output.textContent = `Current value: "${event.detail.getValue()}"`;
|
||||
}
|
||||
}}
|
||||
></dees-input-text>
|
||||
|
||||
<div class="interactive-section">
|
||||
<div id="text-input-output" class="output-text">Current value: ""</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`;
|
@ -231,7 +231,7 @@ export class DeesInputText extends DeesInputBase {
|
||||
${this.isPasswordBool
|
||||
? html`
|
||||
<div class="showPassword" @click=${this.togglePasswordView}>
|
||||
<dees-icon .iconName=${this.showPasswordBool ? 'lucideEye' : 'lucideEyeOff'}></dees-icon>
|
||||
<dees-icon .icon=${this.showPasswordBool ? 'lucide:eye' : 'lucide:eye-off'}></dees-icon>
|
||||
</div>
|
||||
`
|
||||
: html``}
|
||||
|
@ -82,7 +82,7 @@ export class DeesLabel extends DeesElement {
|
||||
${this.required ? html`<span class="required">*</span>` : ''}
|
||||
${this.description
|
||||
? html`
|
||||
<dees-icon .iconName=${'lucideInfo'}></dees-icon>
|
||||
<dees-icon .icon=${'lucide:info'}></dees-icon>
|
||||
<dees-speechbubble .text=${this.description}></dees-speechbubble>
|
||||
`
|
||||
: html``}
|
||||
|
215
ts_web/elements/dees-mobilenavigation.demo.ts
Normal file
215
ts_web/elements/dees-mobilenavigation.demo.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import './dees-button.js';
|
||||
import './dees-panel.js';
|
||||
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: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'Mobile Navigation'} .subtitle=${'Shadcn-style slide-in navigation menu with icons'}>
|
||||
<div class="demo-buttons">
|
||||
<dees-button
|
||||
@click=${async () => {
|
||||
const { DeesMobilenavigation } = await import('./dees-mobilenavigation.js');
|
||||
DeesMobilenavigation.createAndShow([
|
||||
{
|
||||
name: 'Dashboard',
|
||||
iconName: 'lucide:layout-dashboard',
|
||||
action: async (nav) => {
|
||||
console.log('Navigate to dashboard');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Profile',
|
||||
iconName: 'lucide:user',
|
||||
action: async (nav) => {
|
||||
console.log('Navigate to profile');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Messages',
|
||||
iconName: 'lucide:mail',
|
||||
action: async (nav) => {
|
||||
console.log('Navigate to messages');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
iconName: 'lucide:settings',
|
||||
action: async (nav) => {
|
||||
console.log('Navigate to settings');
|
||||
},
|
||||
},
|
||||
{ divider: true } as any,
|
||||
{
|
||||
name: 'Help & Support',
|
||||
iconName: 'lucide:help-circle',
|
||||
action: async (nav) => {
|
||||
console.log('Show help');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Sign Out',
|
||||
iconName: 'lucide:log-out',
|
||||
action: async (nav) => {
|
||||
console.log('Sign out');
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Open Navigation Menu
|
||||
</dees-button>
|
||||
|
||||
<dees-button
|
||||
type="secondary"
|
||||
@click=${async () => {
|
||||
const { DeesMobilenavigation } = await import('./dees-mobilenavigation.js');
|
||||
const nav = await DeesMobilenavigation.createAndShow([
|
||||
{
|
||||
name: 'New Document',
|
||||
iconName: 'lucide:file-plus',
|
||||
action: async () => console.log('New document'),
|
||||
},
|
||||
{
|
||||
name: 'Upload File',
|
||||
iconName: 'lucide:upload',
|
||||
action: async () => console.log('Upload file'),
|
||||
},
|
||||
{
|
||||
name: 'Download',
|
||||
iconName: 'lucide:download',
|
||||
action: async () => console.log('Download'),
|
||||
},
|
||||
{ divider: true } as any,
|
||||
{
|
||||
name: 'Share',
|
||||
iconName: 'lucide:share-2',
|
||||
action: async () => console.log('Share'),
|
||||
},
|
||||
{
|
||||
name: 'Export',
|
||||
iconName: 'lucide:export',
|
||||
action: async () => console.log('Export'),
|
||||
},
|
||||
]);
|
||||
nav.heading = 'File Actions';
|
||||
}}
|
||||
>
|
||||
File Actions Menu
|
||||
</dees-button>
|
||||
|
||||
<dees-button
|
||||
type="outline"
|
||||
@click=${async () => {
|
||||
const { DeesMobilenavigation } = await import('./dees-mobilenavigation.js');
|
||||
const nav = await DeesMobilenavigation.createAndShow([
|
||||
{
|
||||
name: 'Cut',
|
||||
iconName: 'lucide:scissors',
|
||||
action: async () => console.log('Cut'),
|
||||
},
|
||||
{
|
||||
name: 'Copy',
|
||||
iconName: 'lucide:copy',
|
||||
action: async () => console.log('Copy'),
|
||||
},
|
||||
{
|
||||
name: 'Paste',
|
||||
iconName: 'lucide:clipboard',
|
||||
action: async () => console.log('Paste'),
|
||||
},
|
||||
{ divider: true } as any,
|
||||
{
|
||||
name: 'Select All',
|
||||
iconName: 'lucide:square-check',
|
||||
action: async () => console.log('Select all'),
|
||||
},
|
||||
{
|
||||
name: 'Find',
|
||||
iconName: 'lucide:search',
|
||||
action: async () => console.log('Find'),
|
||||
},
|
||||
{
|
||||
name: 'Replace',
|
||||
iconName: 'lucide:replace',
|
||||
action: async () => console.log('Replace'),
|
||||
},
|
||||
]);
|
||||
nav.heading = 'Edit';
|
||||
}}
|
||||
>
|
||||
Edit Menu
|
||||
</dees-button>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Features'} .subtitle=${'Modern shadcn-inspired mobile navigation'}>
|
||||
<div style="padding: 16px;">
|
||||
<ul style="margin: 0; padding-left: 24px; display: flex; flex-direction: column; gap: 8px;">
|
||||
<li>Smooth slide-in animation from the right</li>
|
||||
<li>Z-index registry integration for proper stacking</li>
|
||||
<li>Backdrop blur with window layer</li>
|
||||
<li>Support for icons using Lucide icons</li>
|
||||
<li>Menu item dividers for grouping</li>
|
||||
<li>Staggered animation for menu items</li>
|
||||
<li>Responsive design that adapts to mobile screens</li>
|
||||
<li>Clean, modern shadcn-style aesthetics</li>
|
||||
<li>Dark/light theme support</li>
|
||||
<li>Singleton pattern ensures only one instance</li>
|
||||
</ul>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Code Example'} .subtitle=${'How to use the mobile navigation'}>
|
||||
<div style="padding: 16px; background: var(--background-secondary); border-radius: 8px;">
|
||||
<pre style="margin: 0; font-family: monospace; font-size: 13px; line-height: 1.6;"><code>import { DeesMobilenavigation } from '@design.estate/dees-catalog';
|
||||
|
||||
DeesMobilenavigation.createAndShow([
|
||||
{
|
||||
name: 'Dashboard',
|
||||
iconName: 'lucide:layout-dashboard',
|
||||
action: async (nav) => {
|
||||
console.log('Navigate to dashboard');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
iconName: 'lucide:settings',
|
||||
action: async (nav) => {
|
||||
console.log('Navigate to settings');
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Sign Out',
|
||||
iconName: 'lucide:log-out',
|
||||
action: async (nav) => {
|
||||
console.log('Sign out');
|
||||
},
|
||||
},
|
||||
]);</code></pre>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
@ -1,5 +1,6 @@
|
||||
import * as plugins from './00plugins.js';
|
||||
import { zIndexLayers } from './00zindex.js';
|
||||
import { zIndexRegistry } from './00zindex.js';
|
||||
import { cssGeistFontFamily } from './00fonts.js';
|
||||
import {
|
||||
cssManager,
|
||||
css,
|
||||
@ -9,8 +10,10 @@ import {
|
||||
domtools,
|
||||
html,
|
||||
property,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesWindowLayer } from './dees-windowlayer.js';
|
||||
import './dees-icon.js';
|
||||
|
||||
@customElement('dees-mobilenavigation')
|
||||
export class DeesMobilenavigation extends DeesElement {
|
||||
@ -19,14 +22,48 @@ export class DeesMobilenavigation extends DeesElement {
|
||||
<dees-button @click=${() => {
|
||||
DeesMobilenavigation.createAndShow([
|
||||
{
|
||||
name: 'Test',
|
||||
name: 'Dashboard',
|
||||
iconName: 'lucide:layout-dashboard',
|
||||
action: async (deesMobileNav) => {
|
||||
alert('test');
|
||||
console.log('Navigate to dashboard');
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Profile',
|
||||
iconName: 'lucide:user',
|
||||
action: async (deesMobileNav) => {
|
||||
console.log('Navigate to profile');
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
iconName: 'lucide:settings',
|
||||
action: async (deesMobileNav) => {
|
||||
console.log('Navigate to settings');
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{ divider: true } as any,
|
||||
{
|
||||
name: 'Help',
|
||||
iconName: 'lucide:help-circle',
|
||||
action: async (deesMobileNav) => {
|
||||
console.log('Show help');
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Sign Out',
|
||||
iconName: 'lucide:log-out',
|
||||
action: async (deesMobileNav) => {
|
||||
console.log('Sign out');
|
||||
return null;
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}></dees-button>
|
||||
}}>Open Mobile Navigation</dees-button>
|
||||
`;
|
||||
|
||||
private static singletonRef: DeesMobilenavigation;
|
||||
@ -44,15 +81,18 @@ export class DeesMobilenavigation extends DeesElement {
|
||||
|
||||
// INSTANCE
|
||||
@property({
|
||||
type: Array,
|
||||
type: String,
|
||||
})
|
||||
public heading: string = `MENU`;
|
||||
public heading: string = `Menu`;
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public menuItems: plugins.tsclass.website.IMenuItem[] = [];
|
||||
|
||||
@state()
|
||||
private mobileNavZIndex: number = 1000;
|
||||
|
||||
readyDeferred: plugins.smartpromise.Deferred<any> = domtools.plugins.smartpromise.defer();
|
||||
|
||||
constructor() {
|
||||
@ -74,25 +114,32 @@ export class DeesMobilenavigation extends DeesElement {
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
font-family: ${cssGeistFontFamily};
|
||||
}
|
||||
|
||||
.main {
|
||||
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
min-width: 280px;
|
||||
transform: translateX(200px);
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
z-index: ${zIndexLayers.fixed.mobileNav};
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
transform: translateX(100%);
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
z-index: var(--z-index);
|
||||
opacity: 0;
|
||||
padding: 16px 32px;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
background: ${cssManager.bdTheme('#eeeeeb', '#000')};
|
||||
border-left: 1px solid ${cssManager.bdTheme('#eeeeeb', '#222')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border-left: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
pointer-events: none;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'-20px 0 25px -5px rgba(0, 0, 0, 0.1), -10px 0 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
'-20px 0 25px -5px rgba(0, 0, 0, 0.3), -10px 0 10px -5px rgba(0, 0, 0, 0.2)'
|
||||
)};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main.show {
|
||||
@ -101,47 +148,151 @@ export class DeesMobilenavigation extends DeesElement {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.menuItem:hover {
|
||||
background: ${cssManager.bdTheme('#CCC', '#333')};;
|
||||
.header {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
}
|
||||
|
||||
.heading {
|
||||
text-align: left;
|
||||
font-size: 24px;
|
||||
padding: 8px 0px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
font-weight: 300;
|
||||
border-bottom: 1px dashed #444;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.menuItem:active {
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.menuItem dees-icon {
|
||||
flex-shrink: 0;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.menuItem:hover dees-icon {
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.menuItem-text {
|
||||
flex: 1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.menuItem-divider {
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
margin: 8px 16px;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 400px) {
|
||||
.main {
|
||||
max-width: 100vw;
|
||||
width: 85vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for menu items */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.main.show .menuItem {
|
||||
animation: slideInRight 0.3s ease-out forwards;
|
||||
animation-delay: calc(var(--item-index, 0) * 0.05s);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.menu-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.menu-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.menu-container::-webkit-scrollbar-thumb {
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.menu-container::-webkit-scrollbar-thumb:hover {
|
||||
background: ${cssManager.bdTheme('#d1d5db', '#52525b')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<style>
|
||||
.main {
|
||||
--z-index: ${this.mobileNavZIndex};
|
||||
}
|
||||
</style>
|
||||
<div class="main">
|
||||
<div class="heading">${this.heading}</div>
|
||||
${this.menuItems.map((menuItem) => {
|
||||
return html`
|
||||
<div
|
||||
class="menuItem"
|
||||
@click="${() => {
|
||||
this.hide();
|
||||
menuItem.action(this);
|
||||
}}"
|
||||
>
|
||||
${menuItem.name}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<div class="header">
|
||||
<h2 class="heading">${this.heading}</h2>
|
||||
</div>
|
||||
<div class="menu-container">
|
||||
${this.menuItems.map((menuItem, index) => {
|
||||
if ('divider' in menuItem && menuItem.divider) {
|
||||
return html`<div class="menuItem-divider"></div>`;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
class="menuItem"
|
||||
style="--item-index: ${index}"
|
||||
@click="${() => {
|
||||
this.hide();
|
||||
menuItem.action(this);
|
||||
}}"
|
||||
>
|
||||
${menuItem.iconName ? html`
|
||||
<dees-icon .icon=${menuItem.iconName} size="20"></dees-icon>
|
||||
` : ''}
|
||||
<span class="menuItem-text">${menuItem.name}</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -154,18 +305,25 @@ export class DeesMobilenavigation extends DeesElement {
|
||||
public async show() {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
const main = this.shadowRoot.querySelector('.main');
|
||||
|
||||
// Create window layer first (it will get its own z-index)
|
||||
if (!this.windowLayer) {
|
||||
this.windowLayer = new DeesWindowLayer();
|
||||
this.windowLayer.options.blur = true;
|
||||
this.windowLayer = await DeesWindowLayer.createAndShow({
|
||||
blur: true,
|
||||
});
|
||||
this.windowLayer.addEventListener('click', () => {
|
||||
this.hide();
|
||||
});
|
||||
} else {
|
||||
document.body.append(this.windowLayer);
|
||||
await this.windowLayer.show();
|
||||
}
|
||||
document.body.append(this.windowLayer);
|
||||
await domtools.convenience.smartdelay.delayFor(0);
|
||||
this.windowLayer.show();
|
||||
|
||||
await domtools.convenience.smartdelay.delayFor(0);
|
||||
// Get z-index for mobile nav (will be above window layer)
|
||||
this.mobileNavZIndex = zIndexRegistry.getNextZIndex();
|
||||
zIndexRegistry.register(this, this.mobileNavZIndex);
|
||||
|
||||
await domtools.convenience.smartdelay.delayFor(10);
|
||||
main.classList.add('show');
|
||||
}
|
||||
|
||||
@ -176,10 +334,23 @@ export class DeesMobilenavigation extends DeesElement {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
const main = this.shadowRoot.querySelector('.main');
|
||||
main.classList.remove('show');
|
||||
this.windowLayer.hide();
|
||||
|
||||
// Unregister from z-index registry
|
||||
zIndexRegistry.unregister(this);
|
||||
|
||||
if (this.windowLayer) {
|
||||
await this.windowLayer.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
document.body.removeChild(this.windowLayer);
|
||||
super.disconnectedCallback();
|
||||
|
||||
// Cleanup
|
||||
zIndexRegistry.unregister(this);
|
||||
|
||||
if (this.windowLayer) {
|
||||
await this.windowLayer.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -252,7 +252,7 @@ export class DeesShoppingProductcard extends DeesElement {
|
||||
${imageUrl ? html`
|
||||
<img src="${imageUrl}" alt="${name}">
|
||||
` : html`
|
||||
<dees-icon .iconName=${iconName}></dees-icon>
|
||||
<dees-icon .icon=${iconName}></dees-icon>
|
||||
`}
|
||||
${this.selectable ? html`
|
||||
<div
|
||||
@ -262,7 +262,7 @@ export class DeesShoppingProductcard extends DeesElement {
|
||||
this.handleSelectionToggle();
|
||||
}}
|
||||
>
|
||||
<dees-icon .iconName=${'lucide:check'}></dees-icon>
|
||||
<dees-icon .icon=${'lucide:check'}></dees-icon>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@ -275,7 +275,7 @@ export class DeesShoppingProductcard extends DeesElement {
|
||||
<div class="product-description">${description}</div>
|
||||
` : ''}
|
||||
<div class="stock-status ${inStock ? 'in-stock' : 'out-of-stock'}">
|
||||
<dees-icon .iconName=${inStock ? 'lucide:check-circle' : 'lucide:x-circle'}></dees-icon>
|
||||
<dees-icon .icon=${inStock ? 'lucide:check-circle' : 'lucide:x-circle'}></dees-icon>
|
||||
${stockText}
|
||||
</div>
|
||||
<div class="product-footer">
|
||||
|
@ -120,9 +120,9 @@ export class DeesSpinner extends DeesElement {
|
||||
<div class="${this.status}" id="loading">
|
||||
${(() => {
|
||||
if (this.status === 'success') {
|
||||
return html`<dees-icon style="transform: translateX(1%) translateY(3%);" .iconFA=${'circleCheck' as any}></dees-icon>`;
|
||||
return html`<dees-icon style="transform: translateX(1%) translateY(3%);" .icon=${'fa:circle-check'}></dees-icon>`;
|
||||
} else if (this.status === 'error') {
|
||||
return html`<dees-icon .iconFA=${'circleXmark' as any}></dees-icon>`;
|
||||
return html`<dees-icon .icon=${'fa:circle-xmark'}></dees-icon>`;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
|
@ -391,7 +391,7 @@ export class DeesStatsGrid extends DeesElement {
|
||||
type="outline"
|
||||
size="sm"
|
||||
>
|
||||
${action.iconName ? html`<dees-icon .iconFA=${action.iconName} size="small"></dees-icon>` : ''}
|
||||
${action.iconName ? html`<dees-icon .icon=${action.iconName} size="small"></dees-icon>` : ''}
|
||||
${action.name}
|
||||
</dees-button>
|
||||
`)}
|
||||
@ -427,7 +427,7 @@ export class DeesStatsGrid extends DeesElement {
|
||||
<div class="tile-header">
|
||||
<h3 class="tile-title">${tile.title}</h3>
|
||||
${tile.icon ? html`
|
||||
<dees-icon class="tile-icon" .iconFA=${tile.icon} size="small"></dees-icon>
|
||||
<dees-icon class="tile-icon" .icon=${tile.icon} size="small"></dees-icon>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
|
@ -568,7 +568,7 @@ export class DeesTable<T> extends DeesElement {
|
||||
}}
|
||||
>
|
||||
${action.iconName
|
||||
? html`<dees-icon .iconSize=${14} .iconFA=${action.iconName}></dees-icon>
|
||||
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
|
||||
${action.name}`
|
||||
: action.name}
|
||||
</div>`
|
||||
@ -743,7 +743,7 @@ export class DeesTable<T> extends DeesElement {
|
||||
${actionArg.iconName
|
||||
? html`
|
||||
<dees-icon
|
||||
.iconFA=${actionArg.iconName}
|
||||
.icon=${actionArg.iconName}
|
||||
></dees-icon>
|
||||
`
|
||||
: actionArg.name}
|
||||
@ -785,7 +785,7 @@ export class DeesTable<T> extends DeesElement {
|
||||
}}
|
||||
>
|
||||
${action.iconName
|
||||
? html`<dees-icon .iconSize=${14} .iconFA=${action.iconName}></dees-icon>
|
||||
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
|
||||
${action.name}`
|
||||
: action.name}
|
||||
</div>`
|
||||
|
@ -18,6 +18,7 @@ export * from './dees-chips.js';
|
||||
export * from './dees-contextmenu.js';
|
||||
export * from './dees-dataview-codebox.js';
|
||||
export * from './dees-dataview-statusobject.js';
|
||||
export * from './dees-dashboardgrid.js';
|
||||
export * from './dees-editor.js';
|
||||
export * from './dees-editor-markdown.js';
|
||||
export * from './dees-editor-markdownoutlet.js';
|
||||
@ -27,9 +28,11 @@ export * from './dees-heading.js';
|
||||
export * from './dees-hint.js';
|
||||
export * from './dees-icon.js';
|
||||
export * from './dees-input-checkbox.js';
|
||||
export * from './dees-input-datepicker.js';
|
||||
export * from './dees-input-dropdown.js';
|
||||
export * from './dees-input-fileupload.js';
|
||||
export * from './dees-input-iban.js';
|
||||
export * from './profilepicture/dees-input-profilepicture.js';
|
||||
export * from './dees-input-typelist.js';
|
||||
export * from './dees-input-phone.js';
|
||||
export * from './dees-input-wysiwyg.js';
|
||||
|
208
ts_web/elements/profilepicture/dees-input-profilepicture.demo.ts
Normal file
208
ts_web/elements/profilepicture/dees-input-profilepicture.demo.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import '../dees-panel.js';
|
||||
import './dees-input-profilepicture.js';
|
||||
import type { DeesInputProfilePicture } from './dees-input-profilepicture.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.demo-row {
|
||||
display: flex;
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.demo-output {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 105, 242, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
margin-top: 16px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Basic demo with round profile picture
|
||||
const roundProfile = elementArg.querySelector('dees-input-profilepicture[shape="round"]');
|
||||
|
||||
if (roundProfile) {
|
||||
roundProfile.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputProfilePicture;
|
||||
console.log('Round profile picture changed:', target.value?.substring(0, 50) + '...');
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Profile Picture Input'} .subtitle=${'Basic usage with round and square shapes'}>
|
||||
<div class="demo-row">
|
||||
<dees-input-profilepicture
|
||||
label="Profile Picture (Round)"
|
||||
description="Click to upload or drag & drop an image"
|
||||
shape="round"
|
||||
size="120"
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
label="Profile Picture (Square)"
|
||||
description="Supports JPEG, PNG, and WebP formats"
|
||||
shape="square"
|
||||
size="120"
|
||||
></dees-input-profilepicture>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Different sizes demo
|
||||
const profiles = elementArg.querySelectorAll('dees-input-profilepicture');
|
||||
profiles.forEach((profile) => {
|
||||
profile.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputProfilePicture;
|
||||
console.log(`Profile (size ${target.size}) changed`);
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'Size Variations'} .subtitle=${'Profile pictures in different sizes'}>
|
||||
<div class="demo-row">
|
||||
<dees-input-profilepicture
|
||||
label="Small (80px)"
|
||||
shape="round"
|
||||
size="80"
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
label="Medium (120px)"
|
||||
shape="round"
|
||||
size="120"
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
label="Large (160px)"
|
||||
shape="round"
|
||||
size="160"
|
||||
></dees-input-profilepicture>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Pre-filled profile with placeholder
|
||||
const sampleImageUrl = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8ZGVmcz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iZ3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjNjY3ZWVhIiAvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM3NjRiYTIiIC8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogIDwvZGVmcz4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0idXJsKCNncmFkaWVudCkiIC8+CiAgPHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSI4MCIgZmlsbD0id2hpdGUiPkpEPC90ZXh0Pgo8L3N2Zz4=';
|
||||
|
||||
const prefilledProfile = elementArg.querySelector('#prefilled-profile') as DeesInputProfilePicture;
|
||||
if (prefilledProfile) {
|
||||
prefilledProfile.value = sampleImageUrl;
|
||||
|
||||
prefilledProfile.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputProfilePicture;
|
||||
const output = elementArg.querySelector('#prefilled-output');
|
||||
if (output) {
|
||||
output.textContent = target.value ?
|
||||
`Image data: ${target.value.substring(0, 80)}...` :
|
||||
'No image selected';
|
||||
}
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Pre-filled and Value Binding'} .subtitle=${'Profile picture with initial value and change tracking'}>
|
||||
<dees-input-profilepicture
|
||||
id="prefilled-profile"
|
||||
label="Edit Existing Profile"
|
||||
description="Click the edit button to change or delete to remove"
|
||||
shape="round"
|
||||
size="150"
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<div id="prefilled-output" class="demo-output">
|
||||
Image data will appear here when changed
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Disabled state demo
|
||||
const disabledProfile = elementArg.querySelector('#disabled-profile') as DeesInputProfilePicture;
|
||||
if (disabledProfile) {
|
||||
disabledProfile.value = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2NjY2NjYyIgLz4KICA8dGV4dCB4PSI1MCUiIHk9IjUwJSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjYwIiBmaWxsPSJ3aGl0ZSI+TkE8L3RleHQ+Cjwvc3ZnPg==';
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Form States'} .subtitle=${'Different states and configurations'}>
|
||||
<div class="demo-row">
|
||||
<dees-input-profilepicture
|
||||
label="Required Field"
|
||||
description="This field is required"
|
||||
shape="round"
|
||||
.required=${true}
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
id="disabled-profile"
|
||||
label="Disabled State"
|
||||
description="Cannot be edited"
|
||||
shape="square"
|
||||
.disabled=${true}
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
label="Upload Only"
|
||||
description="Delete not allowed"
|
||||
shape="round"
|
||||
.allowDelete=${false}
|
||||
></dees-input-profilepicture>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper>
|
||||
<dees-panel .title=${'Features'} .subtitle=${'Complete feature set of the profile picture input'}>
|
||||
<ul class="feature-list">
|
||||
<li><strong>Image Upload:</strong> Click to upload or drag & drop images</li>
|
||||
<li><strong>Image Cropping:</strong> Interactive crop tool with resize handles</li>
|
||||
<li><strong>Shape Support:</strong> Round or square profile pictures</li>
|
||||
<li><strong>Size Customization:</strong> Adjustable dimensions</li>
|
||||
<li><strong>Preview & Edit:</strong> Hover overlay with edit and delete options</li>
|
||||
<li><strong>File Validation:</strong> Format and size restrictions</li>
|
||||
<li><strong>Responsive Design:</strong> Works on desktop and mobile devices</li>
|
||||
<li><strong>Form Integration:</strong> Standard form value binding and validation</li>
|
||||
<li><strong>Accessibility:</strong> Keyboard navigation and screen reader support</li>
|
||||
<li><strong>Z-Index Management:</strong> Proper modal stacking with registry</li>
|
||||
</ul>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<strong>Supported Formats:</strong> JPEG, PNG, WebP<br>
|
||||
<strong>Max File Size:</strong> 5MB (configurable)<br>
|
||||
<strong>Output Format:</strong> Base64 encoded JPEG
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`;
|
455
ts_web/elements/profilepicture/dees-input-profilepicture.ts
Normal file
455
ts_web/elements/profilepicture/dees-input-profilepicture.ts
Normal file
@ -0,0 +1,455 @@
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base.js';
|
||||
import '../dees-icon.js';
|
||||
import '../dees-label.js';
|
||||
import { ProfilePictureModal } from './profilepicture.modal.js';
|
||||
import { demoFunc } from './dees-input-profilepicture.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-profilepicture': DeesInputProfilePicture;
|
||||
}
|
||||
}
|
||||
|
||||
export type ProfileShape = 'square' | 'round';
|
||||
|
||||
@customElement('dees-input-profilepicture')
|
||||
export class DeesInputProfilePicture extends DeesInputBase<DeesInputProfilePicture> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({ type: String })
|
||||
public value: string = ''; // Base64 encoded image or URL
|
||||
|
||||
@property({ type: String })
|
||||
public shape: ProfileShape = 'round';
|
||||
|
||||
@property({ type: Number })
|
||||
public size: number = 120;
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public allowUpload: boolean = true;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public allowDelete: boolean = true;
|
||||
|
||||
@property({ type: Number })
|
||||
public maxFileSize: number = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
@property({ type: Array })
|
||||
public acceptedFormats: string[] = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
@property({ type: Number })
|
||||
public outputSize: number = 800; // Output resolution in pixels
|
||||
|
||||
@property({ type: Number })
|
||||
public outputQuality: number = 0.95; // 0-1 quality for JPEG
|
||||
|
||||
@state()
|
||||
private isHovered: boolean = false;
|
||||
|
||||
@state()
|
||||
private isDragging: boolean = false;
|
||||
|
||||
@state()
|
||||
private isLoading: boolean = false;
|
||||
|
||||
private modalInstance: ProfilePictureModal | null = null;
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.profile-container:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.profile-picture {
|
||||
width: var(--size, 120px);
|
||||
height: var(--size, 120px);
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#18181b')};
|
||||
border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.profile-picture.round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.profile-picture.square {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.profile-picture.dragging {
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
box-shadow: 0 0 0 4px ${cssManager.bdTheme('rgba(59, 130, 246, 0.15)', 'rgba(96, 165, 250, 0.15)')};
|
||||
}
|
||||
|
||||
.profile-picture:hover {
|
||||
border-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
}
|
||||
|
||||
.profile-picture:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.profile-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.profile-container:hover .overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overlay-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.95)', 'rgba(39, 39, 42, 0.95)')};
|
||||
border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.overlay-button:hover {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#3f3f46')};
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.overlay-button.delete {
|
||||
background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.9)', 'rgba(220, 38, 38, 0.9)')};
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.overlay-button.delete:hover {
|
||||
background: ${cssManager.bdTheme('#ef4444', '#dc2626')};
|
||||
}
|
||||
|
||||
.drop-zone-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.8)', 'rgba(0, 0, 0, 0.8)')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.loading-overlay.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-picture.clicking {
|
||||
animation: pulse 0.3s ease-out;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>
|
||||
|
||||
<div
|
||||
class="profile-container"
|
||||
@click=${this.handleClick}
|
||||
@dragover=${this.handleDragOver}
|
||||
@dragleave=${this.handleDragLeave}
|
||||
@drop=${this.handleDrop}
|
||||
style="--size: ${this.size}px"
|
||||
>
|
||||
<div class="profile-picture ${this.shape} ${this.isDragging ? 'dragging' : ''} ${this.isLoading && !this.value ? 'clicking' : ''}">
|
||||
${this.value ? html`
|
||||
<img class="profile-image" src="${this.value}" alt="Profile picture" />
|
||||
` : html`
|
||||
<dees-icon class="placeholder-icon" icon="lucide:user" iconSize="${this.size * 0.5}"></dees-icon>
|
||||
`}
|
||||
|
||||
${this.isDragging ? html`
|
||||
<div class="overlay" style="opacity: 1">
|
||||
<div class="drop-zone-text">
|
||||
Drop image here
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.value && !this.disabled ? html`
|
||||
<div class="overlay">
|
||||
<div class="overlay-content">
|
||||
${this.allowUpload ? html`
|
||||
<button class="overlay-button" @click=${(e: Event) => { e.stopPropagation(); this.openModal(); }} title="Change picture">
|
||||
<dees-icon icon="lucide:pencil" iconSize="20"></dees-icon>
|
||||
</button>
|
||||
` : ''}
|
||||
${this.allowDelete ? html`
|
||||
<button class="overlay-button delete" @click=${(e: Event) => { e.stopPropagation(); this.deletePicture(); }} title="Delete picture">
|
||||
<dees-icon icon="lucide:trash2" iconSize="20"></dees-icon>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.isLoading && !this.value ? html`
|
||||
<div class="loading-overlay show">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
class="hidden-input"
|
||||
accept="${this.acceptedFormats.join(',')}"
|
||||
@change=${this.handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleClick(): void {
|
||||
if (this.disabled || !this.allowUpload) return;
|
||||
|
||||
if (!this.value) {
|
||||
// If no image, open file picker
|
||||
this.isLoading = true;
|
||||
const input = this.shadowRoot!.querySelector('.hidden-input') as HTMLInputElement;
|
||||
|
||||
// Set up a focus handler to detect when the dialog is closed without selection
|
||||
const handleFocus = () => {
|
||||
setTimeout(() => {
|
||||
// Check if no file was selected
|
||||
if (!input.files || input.files.length === 0) {
|
||||
this.isLoading = false;
|
||||
}
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
|
||||
private handleFileSelect(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
// Always reset loading state when file dialog interaction completes
|
||||
this.isLoading = false;
|
||||
|
||||
if (file) {
|
||||
this.processFile(file);
|
||||
}
|
||||
|
||||
// Reset input to allow selecting the same file again
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
private handleDragOver(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
if (!this.disabled && this.allowUpload) {
|
||||
this.isDragging = true;
|
||||
}
|
||||
}
|
||||
|
||||
private handleDragLeave(): void {
|
||||
this.isDragging = false;
|
||||
}
|
||||
|
||||
private handleDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
this.isDragging = false;
|
||||
|
||||
if (this.disabled || !this.allowUpload) return;
|
||||
|
||||
const file = event.dataTransfer?.files[0];
|
||||
if (file) {
|
||||
this.processFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
private async processFile(file: File): Promise<void> {
|
||||
// Validate file type
|
||||
if (!this.acceptedFormats.includes(file.type)) {
|
||||
console.error('Invalid file type:', file.type);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > this.maxFileSize) {
|
||||
console.error('File too large:', file.size);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read file as base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const base64 = e.target?.result as string;
|
||||
|
||||
// Open modal for cropping
|
||||
await this.openModal(base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
private async openModal(initialImage?: string): Promise<void> {
|
||||
const imageToEdit = initialImage || this.value;
|
||||
|
||||
if (!imageToEdit) {
|
||||
// If no image provided, open file picker
|
||||
const input = this.shadowRoot!.querySelector('.hidden-input') as HTMLInputElement;
|
||||
input.click();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and show modal
|
||||
this.modalInstance = new ProfilePictureModal();
|
||||
this.modalInstance.shape = this.shape;
|
||||
this.modalInstance.initialImage = imageToEdit;
|
||||
this.modalInstance.outputSize = this.outputSize;
|
||||
this.modalInstance.outputQuality = this.outputQuality;
|
||||
|
||||
this.modalInstance.addEventListener('save', (event: CustomEvent) => {
|
||||
this.value = event.detail.croppedImage;
|
||||
this.changeSubject.next(this);
|
||||
});
|
||||
|
||||
document.body.appendChild(this.modalInstance);
|
||||
}
|
||||
|
||||
private deletePicture(): void {
|
||||
this.value = '';
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
3
ts_web/elements/profilepicture/index.ts
Normal file
3
ts_web/elements/profilepicture/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './dees-input-profilepicture.js';
|
||||
export * from './profilepicture.modal.js';
|
||||
export * from './profilepicture.cropper.js';
|
456
ts_web/elements/profilepicture/profilepicture.cropper.ts
Normal file
456
ts_web/elements/profilepicture/profilepicture.cropper.ts
Normal file
@ -0,0 +1,456 @@
|
||||
import type { ProfileShape } from './dees-input-profilepicture.js';
|
||||
|
||||
export interface CropperOptions {
|
||||
container: HTMLElement;
|
||||
image: string;
|
||||
shape: ProfileShape;
|
||||
aspectRatio: number;
|
||||
minSize?: number;
|
||||
outputSize?: number;
|
||||
outputQuality?: number;
|
||||
}
|
||||
|
||||
export class ImageCropper {
|
||||
private options: CropperOptions;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private img: HTMLImageElement;
|
||||
private overlayCanvas: HTMLCanvasElement;
|
||||
private overlayCtx: CanvasRenderingContext2D;
|
||||
|
||||
// Crop area properties
|
||||
private cropX: number = 0;
|
||||
private cropY: number = 0;
|
||||
private cropSize: number = 200;
|
||||
private minCropSize: number = 50;
|
||||
|
||||
// Interaction state
|
||||
private isDragging: boolean = false;
|
||||
private isResizing: boolean = false;
|
||||
private dragStartX: number = 0;
|
||||
private dragStartY: number = 0;
|
||||
private resizeHandle: string = '';
|
||||
|
||||
// Image properties
|
||||
private imageScale: number = 1;
|
||||
private imageOffsetX: number = 0;
|
||||
private imageOffsetY: number = 0;
|
||||
|
||||
constructor(options: CropperOptions) {
|
||||
this.options = {
|
||||
minSize: 50,
|
||||
outputSize: 800, // Higher default resolution
|
||||
outputQuality: 0.95, // Higher quality
|
||||
...options
|
||||
};
|
||||
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.ctx = this.canvas.getContext('2d')!;
|
||||
|
||||
this.overlayCanvas = document.createElement('canvas');
|
||||
this.overlayCtx = this.overlayCanvas.getContext('2d')!;
|
||||
|
||||
this.img = new Image();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Load image
|
||||
await this.loadImage();
|
||||
|
||||
// Setup canvases
|
||||
this.setupCanvases();
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadImage(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.img.onload = () => resolve();
|
||||
this.img.onerror = reject;
|
||||
this.img.src = this.options.image;
|
||||
});
|
||||
}
|
||||
|
||||
private setupCanvases(): void {
|
||||
const container = this.options.container;
|
||||
const containerSize = Math.min(container.clientWidth, container.clientHeight);
|
||||
|
||||
// Set canvas sizes
|
||||
this.canvas.width = containerSize;
|
||||
this.canvas.height = containerSize;
|
||||
this.canvas.style.width = '100%';
|
||||
this.canvas.style.height = '100%';
|
||||
this.canvas.style.position = 'absolute';
|
||||
this.canvas.style.top = '0';
|
||||
this.canvas.style.left = '0';
|
||||
|
||||
this.overlayCanvas.width = containerSize;
|
||||
this.overlayCanvas.height = containerSize;
|
||||
this.overlayCanvas.style.width = '100%';
|
||||
this.overlayCanvas.style.height = '100%';
|
||||
this.overlayCanvas.style.position = 'absolute';
|
||||
this.overlayCanvas.style.top = '0';
|
||||
this.overlayCanvas.style.left = '0';
|
||||
this.overlayCanvas.style.cursor = 'move';
|
||||
|
||||
container.appendChild(this.canvas);
|
||||
container.appendChild(this.overlayCanvas);
|
||||
|
||||
// Calculate image scale to fit within container (not fill)
|
||||
const scale = Math.min(
|
||||
containerSize / this.img.width,
|
||||
containerSize / this.img.height
|
||||
);
|
||||
|
||||
this.imageScale = scale;
|
||||
this.imageOffsetX = (containerSize - this.img.width * scale) / 2;
|
||||
this.imageOffsetY = (containerSize - this.img.height * scale) / 2;
|
||||
|
||||
// Initialize crop area
|
||||
// Make the crop area fit within the actual image bounds
|
||||
const scaledImageWidth = this.img.width * scale;
|
||||
const scaledImageHeight = this.img.height * scale;
|
||||
const maxCropSize = Math.min(scaledImageWidth, scaledImageHeight, containerSize * 0.8);
|
||||
|
||||
this.cropSize = maxCropSize * 0.8; // Start at 80% of max possible size
|
||||
this.cropX = (containerSize - this.cropSize) / 2;
|
||||
this.cropY = (containerSize - this.cropSize) / 2;
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.overlayCanvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
||||
this.overlayCanvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
this.overlayCanvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
||||
this.overlayCanvas.addEventListener('mouseleave', this.handleMouseUp.bind(this));
|
||||
|
||||
// Touch events
|
||||
this.overlayCanvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
|
||||
this.overlayCanvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
|
||||
this.overlayCanvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
||||
}
|
||||
|
||||
private handleMouseDown(e: MouseEvent): void {
|
||||
const rect = this.overlayCanvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) * (this.overlayCanvas.width / rect.width);
|
||||
const y = (e.clientY - rect.top) * (this.overlayCanvas.height / rect.height);
|
||||
|
||||
const handle = this.getResizeHandle(x, y);
|
||||
|
||||
if (handle) {
|
||||
this.isResizing = true;
|
||||
this.resizeHandle = handle;
|
||||
} else if (this.isInsideCropArea(x, y)) {
|
||||
this.isDragging = true;
|
||||
}
|
||||
|
||||
this.dragStartX = x;
|
||||
this.dragStartY = y;
|
||||
}
|
||||
|
||||
private handleMouseMove(e: MouseEvent): void {
|
||||
const rect = this.overlayCanvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) * (this.overlayCanvas.width / rect.width);
|
||||
const y = (e.clientY - rect.top) * (this.overlayCanvas.height / rect.height);
|
||||
|
||||
// Update cursor
|
||||
const handle = this.getResizeHandle(x, y);
|
||||
if (handle) {
|
||||
this.overlayCanvas.style.cursor = this.getResizeCursor(handle);
|
||||
} else if (this.isInsideCropArea(x, y)) {
|
||||
this.overlayCanvas.style.cursor = 'move';
|
||||
} else {
|
||||
this.overlayCanvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
// Handle dragging
|
||||
if (this.isDragging) {
|
||||
const dx = x - this.dragStartX;
|
||||
const dy = y - this.dragStartY;
|
||||
|
||||
// Constrain crop area to image bounds
|
||||
const minX = this.imageOffsetX;
|
||||
const maxX = this.imageOffsetX + this.img.width * this.imageScale - this.cropSize;
|
||||
const minY = this.imageOffsetY;
|
||||
const maxY = this.imageOffsetY + this.img.height * this.imageScale - this.cropSize;
|
||||
|
||||
this.cropX = Math.max(minX, Math.min(maxX, this.cropX + dx));
|
||||
this.cropY = Math.max(minY, Math.min(maxY, this.cropY + dy));
|
||||
|
||||
this.dragStartX = x;
|
||||
this.dragStartY = y;
|
||||
this.render();
|
||||
}
|
||||
|
||||
// Handle resizing
|
||||
if (this.isResizing) {
|
||||
this.handleResize(x, y);
|
||||
this.dragStartX = x;
|
||||
this.dragStartY = y;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseUp(): void {
|
||||
this.isDragging = false;
|
||||
this.isResizing = false;
|
||||
this.resizeHandle = '';
|
||||
}
|
||||
|
||||
private handleTouchStart(e: TouchEvent): void {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const mouseEvent = new MouseEvent('mousedown', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY
|
||||
});
|
||||
this.handleMouseDown(mouseEvent);
|
||||
}
|
||||
|
||||
private handleTouchMove(e: TouchEvent): void {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const mouseEvent = new MouseEvent('mousemove', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY
|
||||
});
|
||||
this.handleMouseMove(mouseEvent);
|
||||
}
|
||||
|
||||
private handleTouchEnd(e: TouchEvent): void {
|
||||
e.preventDefault();
|
||||
this.handleMouseUp();
|
||||
}
|
||||
|
||||
private getResizeHandle(x: number, y: number): string {
|
||||
const handleSize = 20;
|
||||
const handles = {
|
||||
'nw': { x: this.cropX, y: this.cropY },
|
||||
'ne': { x: this.cropX + this.cropSize, y: this.cropY },
|
||||
'sw': { x: this.cropX, y: this.cropY + this.cropSize },
|
||||
'se': { x: this.cropX + this.cropSize, y: this.cropY + this.cropSize }
|
||||
};
|
||||
|
||||
for (const [key, pos] of Object.entries(handles)) {
|
||||
if (Math.abs(x - pos.x) < handleSize && Math.abs(y - pos.y) < handleSize) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private getResizeCursor(handle: string): string {
|
||||
const cursors: Record<string, string> = {
|
||||
'nw': 'nw-resize',
|
||||
'ne': 'ne-resize',
|
||||
'sw': 'sw-resize',
|
||||
'se': 'se-resize'
|
||||
};
|
||||
return cursors[handle] || 'default';
|
||||
}
|
||||
|
||||
private isInsideCropArea(x: number, y: number): boolean {
|
||||
return x >= this.cropX && x <= this.cropX + this.cropSize &&
|
||||
y >= this.cropY && y <= this.cropY + this.cropSize;
|
||||
}
|
||||
|
||||
private handleResize(x: number, y: number): void {
|
||||
const dx = x - this.dragStartX;
|
||||
const dy = y - this.dragStartY;
|
||||
|
||||
// Get image bounds
|
||||
const imgLeft = this.imageOffsetX;
|
||||
const imgTop = this.imageOffsetY;
|
||||
const imgRight = this.imageOffsetX + this.img.width * this.imageScale;
|
||||
const imgBottom = this.imageOffsetY + this.img.height * this.imageScale;
|
||||
|
||||
switch (this.resizeHandle) {
|
||||
case 'se':
|
||||
this.cropSize = Math.max(this.minCropSize, Math.min(
|
||||
this.cropSize + Math.max(dx, dy),
|
||||
Math.min(
|
||||
imgRight - this.cropX,
|
||||
imgBottom - this.cropY
|
||||
)
|
||||
));
|
||||
break;
|
||||
case 'nw':
|
||||
const newSize = Math.max(this.minCropSize, this.cropSize - Math.max(dx, dy));
|
||||
const sizeDiff = this.cropSize - newSize;
|
||||
const newX = this.cropX + sizeDiff;
|
||||
const newY = this.cropY + sizeDiff;
|
||||
if (newX >= imgLeft && newY >= imgTop) {
|
||||
this.cropX = newX;
|
||||
this.cropY = newY;
|
||||
this.cropSize = newSize;
|
||||
}
|
||||
break;
|
||||
case 'ne':
|
||||
const neSizeDx = Math.max(dx, -dy);
|
||||
const neNewSize = Math.max(this.minCropSize, this.cropSize + neSizeDx);
|
||||
const neSizeDiff = neNewSize - this.cropSize;
|
||||
const neNewY = this.cropY - neSizeDiff;
|
||||
if (neNewY >= imgTop && this.cropX + neNewSize <= imgRight) {
|
||||
this.cropY = neNewY;
|
||||
this.cropSize = neNewSize;
|
||||
}
|
||||
break;
|
||||
case 'sw':
|
||||
const swSizeDx = Math.max(-dx, dy);
|
||||
const swNewSize = Math.max(this.minCropSize, this.cropSize + swSizeDx);
|
||||
const swSizeDiff = swNewSize - this.cropSize;
|
||||
const swNewX = this.cropX - swSizeDiff;
|
||||
if (swNewX >= imgLeft && this.cropY + swNewSize <= imgBottom) {
|
||||
this.cropX = swNewX;
|
||||
this.cropSize = swNewSize;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
// Clear canvases
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
|
||||
|
||||
// Fill background
|
||||
this.ctx.fillStyle = '#000000';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Draw image
|
||||
this.ctx.drawImage(
|
||||
this.img,
|
||||
this.imageOffsetX,
|
||||
this.imageOffsetY,
|
||||
this.img.width * this.imageScale,
|
||||
this.img.height * this.imageScale
|
||||
);
|
||||
|
||||
// Draw overlay only over the image area
|
||||
this.overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
this.overlayCtx.fillRect(
|
||||
this.imageOffsetX,
|
||||
this.imageOffsetY,
|
||||
this.img.width * this.imageScale,
|
||||
this.img.height * this.imageScale
|
||||
);
|
||||
|
||||
// Clear crop area
|
||||
this.overlayCtx.save();
|
||||
|
||||
if (this.options.shape === 'round') {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.arc(
|
||||
this.cropX + this.cropSize / 2,
|
||||
this.cropY + this.cropSize / 2,
|
||||
this.cropSize / 2,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
this.overlayCtx.clip();
|
||||
} else {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.rect(this.cropX, this.cropY, this.cropSize, this.cropSize);
|
||||
this.overlayCtx.clip();
|
||||
}
|
||||
|
||||
this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
|
||||
this.overlayCtx.restore();
|
||||
|
||||
// Draw crop border
|
||||
this.overlayCtx.strokeStyle = 'white';
|
||||
this.overlayCtx.lineWidth = 2;
|
||||
|
||||
if (this.options.shape === 'round') {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.arc(
|
||||
this.cropX + this.cropSize / 2,
|
||||
this.cropY + this.cropSize / 2,
|
||||
this.cropSize / 2,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
this.overlayCtx.stroke();
|
||||
} else {
|
||||
this.overlayCtx.strokeRect(this.cropX, this.cropY, this.cropSize, this.cropSize);
|
||||
}
|
||||
|
||||
// Draw resize handles
|
||||
this.drawResizeHandles();
|
||||
}
|
||||
|
||||
private drawResizeHandles(): void {
|
||||
const handleSize = 8;
|
||||
const handles = [
|
||||
{ x: this.cropX, y: this.cropY },
|
||||
{ x: this.cropX + this.cropSize, y: this.cropY },
|
||||
{ x: this.cropX, y: this.cropY + this.cropSize },
|
||||
{ x: this.cropX + this.cropSize, y: this.cropY + this.cropSize }
|
||||
];
|
||||
|
||||
this.overlayCtx.fillStyle = 'white';
|
||||
|
||||
handles.forEach(handle => {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.arc(handle.x, handle.y, handleSize, 0, Math.PI * 2);
|
||||
this.overlayCtx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
async getCroppedImage(): Promise<string> {
|
||||
const cropCanvas = document.createElement('canvas');
|
||||
const cropCtx = cropCanvas.getContext('2d')!;
|
||||
|
||||
// Calculate the actual crop size in original image pixels
|
||||
const scale = 1 / this.imageScale;
|
||||
const originalCropSize = this.cropSize * scale;
|
||||
|
||||
// Use requested output size, but warn if upscaling
|
||||
const outputSize = this.options.outputSize!;
|
||||
|
||||
if (outputSize > originalCropSize) {
|
||||
console.info(`Profile picture: Upscaling from ${Math.round(originalCropSize)}px to ${outputSize}px`);
|
||||
}
|
||||
|
||||
cropCanvas.width = outputSize;
|
||||
cropCanvas.height = outputSize;
|
||||
|
||||
// Calculate source coordinates
|
||||
const sx = (this.cropX - this.imageOffsetX) * scale;
|
||||
const sy = (this.cropY - this.imageOffsetY) * scale;
|
||||
const sSize = this.cropSize * scale;
|
||||
|
||||
// Apply shape mask if round
|
||||
if (this.options.shape === 'round') {
|
||||
cropCtx.beginPath();
|
||||
cropCtx.arc(outputSize / 2, outputSize / 2, outputSize / 2, 0, Math.PI * 2);
|
||||
cropCtx.clip();
|
||||
}
|
||||
|
||||
// Enable image smoothing for quality
|
||||
cropCtx.imageSmoothingEnabled = true;
|
||||
cropCtx.imageSmoothingQuality = 'high';
|
||||
|
||||
// Draw cropped image
|
||||
cropCtx.drawImage(
|
||||
this.img,
|
||||
sx, sy, sSize, sSize,
|
||||
0, 0, outputSize, outputSize
|
||||
);
|
||||
|
||||
// Detect format from original image
|
||||
const isPng = this.options.image.includes('image/png');
|
||||
const format = isPng ? 'image/png' : 'image/jpeg';
|
||||
|
||||
return cropCanvas.toDataURL(format, this.options.outputQuality);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.canvas.remove();
|
||||
this.overlayCanvas.remove();
|
||||
}
|
||||
}
|
395
ts_web/elements/profilepicture/profilepicture.modal.ts
Normal file
395
ts_web/elements/profilepicture/profilepicture.modal.ts
Normal file
@ -0,0 +1,395 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as colors from '../00colors.js';
|
||||
import { cssGeistFontFamily } from '../00fonts.js';
|
||||
import { zIndexRegistry } from '../00zindex.js';
|
||||
import '../dees-icon.js';
|
||||
import '../dees-button.js';
|
||||
import '../dees-windowlayer.js';
|
||||
import { DeesWindowLayer } from '../dees-windowlayer.js';
|
||||
import { ImageCropper } from './profilepicture.cropper.js';
|
||||
import type { ProfileShape } from './dees-input-profilepicture.js';
|
||||
|
||||
@customElement('dees-profilepicture-modal')
|
||||
export class ProfilePictureModal extends DeesElement {
|
||||
@property({ type: String })
|
||||
public initialImage: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public shape: ProfileShape = 'round';
|
||||
|
||||
@property({ type: Number })
|
||||
public outputSize: number = 800;
|
||||
|
||||
@property({ type: Number })
|
||||
public outputQuality: number = 0.95;
|
||||
|
||||
@state()
|
||||
private currentStep: 'crop' | 'preview' = 'crop';
|
||||
|
||||
@state()
|
||||
private croppedImage: string = '';
|
||||
|
||||
@state()
|
||||
private isProcessing: boolean = false;
|
||||
|
||||
private cropper: ImageCropper | null = null;
|
||||
private windowLayer: any;
|
||||
private zIndex: number = 0;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
font-family: ${cssGeistFontFamily};
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-index);
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
|
||||
border-radius: 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.08)')};
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
'0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2)'
|
||||
)};
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
opacity: 0;
|
||||
animation: modalShow 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes modalShow {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
height: 52px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.06)')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.close-button:active {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.08)')};
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
aspect-ratio: 1;
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#000000', '#000000')};
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'inset 0 2px 4px rgba(0, 0, 0, 0.06)',
|
||||
'inset 0 2px 4px rgba(0, 0, 0, 0.2)'
|
||||
)};
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
border: 4px solid ${cssManager.bdTheme('#ffffff', '#18181b')};
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
'0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2)'
|
||||
)};
|
||||
}
|
||||
|
||||
.preview-image.round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.preview-image.square {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
background: ${cssManager.bdTheme('#10b981', '#10b981')};
|
||||
color: white;
|
||||
border-radius: 100px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
animation: successPulse 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes successPulse {
|
||||
0% { transform: scale(0.9); opacity: 0; }
|
||||
50% { transform: scale(1.02); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 20px 24px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.06)')};
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
text-align: center;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal-container {
|
||||
width: calc(100vw - 32px);
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Create window layer first (it will get its own z-index)
|
||||
this.windowLayer = await DeesWindowLayer.createAndShow({
|
||||
blur: true,
|
||||
});
|
||||
this.windowLayer.addEventListener('click', () => this.close());
|
||||
|
||||
// Now get z-index for modal (will be above window layer)
|
||||
this.zIndex = zIndexRegistry.getNextZIndex();
|
||||
this.style.setProperty('--z-index', this.zIndex.toString());
|
||||
|
||||
// Register with z-index registry
|
||||
zIndexRegistry.register(this, this.zIndex);
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
// Cleanup
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy();
|
||||
}
|
||||
|
||||
if (this.windowLayer) {
|
||||
await this.windowLayer.destroy();
|
||||
}
|
||||
|
||||
// Unregister from z-index registry
|
||||
zIndexRegistry.unregister(this);
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div class="modal-container" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">
|
||||
${this.currentStep === 'crop' ? 'Adjust Image' : 'Success'}
|
||||
</h3>
|
||||
<button class="close-button" @click=${this.close} title="Close">
|
||||
<dees-icon icon="lucide:x" iconSize="16"></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
${this.currentStep === 'crop' ? html`
|
||||
<div class="instructions">
|
||||
Position and resize the square to select your profile area
|
||||
</div>
|
||||
<div class="cropper-container" id="cropperContainer"></div>
|
||||
` : html`
|
||||
<div class="preview-container">
|
||||
${this.isProcessing ? html`
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="instructions">Saving...</div>
|
||||
` : html`
|
||||
<img
|
||||
class="preview-image ${this.shape}"
|
||||
src="${this.croppedImage}"
|
||||
alt="Cropped preview"
|
||||
/>
|
||||
<div class="success-message">
|
||||
<dees-icon icon="lucide:check" iconSize="16"></dees-icon>
|
||||
<span>Looking good!</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
${this.currentStep === 'crop' ? html`
|
||||
<dees-button type="destructive" size="sm" @click=${this.close}>
|
||||
Cancel
|
||||
</dees-button>
|
||||
<dees-button type="default" size="sm" @click=${this.handleCrop}>
|
||||
Save
|
||||
</dees-button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
if (this.currentStep === 'crop') {
|
||||
await this.initializeCropper();
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeCropper(): Promise<void> {
|
||||
await this.updateComplete;
|
||||
|
||||
const container = this.shadowRoot!.getElementById('cropperContainer');
|
||||
if (!container) return;
|
||||
|
||||
this.cropper = new ImageCropper({
|
||||
container,
|
||||
image: this.initialImage,
|
||||
shape: this.shape,
|
||||
aspectRatio: 1,
|
||||
outputSize: this.outputSize,
|
||||
outputQuality: this.outputQuality,
|
||||
});
|
||||
|
||||
await this.cropper.initialize();
|
||||
}
|
||||
|
||||
private async handleCrop(): Promise<void> {
|
||||
if (!this.cropper) return;
|
||||
|
||||
try {
|
||||
this.isProcessing = true;
|
||||
this.currentStep = 'preview';
|
||||
await this.updateComplete;
|
||||
|
||||
// Get cropped image
|
||||
const croppedData = await this.cropper.getCroppedImage();
|
||||
this.croppedImage = croppedData;
|
||||
|
||||
// Simulate processing time for better UX
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
this.isProcessing = false;
|
||||
|
||||
// Emit save event
|
||||
this.dispatchEvent(new CustomEvent('save', {
|
||||
detail: { croppedImage: this.croppedImage },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
|
||||
// Auto close after showing success
|
||||
setTimeout(() => {
|
||||
this.close();
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error cropping image:', error);
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
this.remove();
|
||||
}
|
||||
}
|
@ -11,6 +11,8 @@ export class WysiwygDragDropHandler {
|
||||
private initialBlockY: number = 0;
|
||||
private draggedBlockElement: HTMLElement | null = null;
|
||||
private draggedBlockHeight: number = 0;
|
||||
private draggedBlockContentHeight: number = 0;
|
||||
private draggedBlockMarginTop: number = 0;
|
||||
private lastUpdateTime: number = 0;
|
||||
private updateThrottle: number = 80; // milliseconds
|
||||
|
||||
@ -48,11 +50,33 @@ export class WysiwygDragDropHandler {
|
||||
this.initialMouseY = e.clientY;
|
||||
this.draggedBlockElement = this.component.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
|
||||
|
||||
|
||||
if (this.draggedBlockElement) {
|
||||
// Get the wrapper rect for measurements
|
||||
const rect = this.draggedBlockElement.getBoundingClientRect();
|
||||
this.draggedBlockHeight = rect.height;
|
||||
this.initialBlockY = rect.top;
|
||||
|
||||
// Get the inner block element for proper measurements
|
||||
const innerBlock = this.draggedBlockElement.querySelector('.block');
|
||||
if (innerBlock) {
|
||||
const innerRect = innerBlock.getBoundingClientRect();
|
||||
const computedStyle = window.getComputedStyle(innerBlock);
|
||||
this.draggedBlockMarginTop = parseInt(computedStyle.marginTop) || 0;
|
||||
this.draggedBlockContentHeight = innerRect.height;
|
||||
}
|
||||
|
||||
// The drop indicator should match the wrapper height exactly
|
||||
// The wrapper already includes all the space the block occupies
|
||||
this.draggedBlockHeight = rect.height;
|
||||
|
||||
console.log('Drag measurements:', {
|
||||
wrapperHeight: rect.height,
|
||||
marginTop: this.draggedBlockMarginTop,
|
||||
dropIndicatorHeight: this.draggedBlockHeight,
|
||||
contentHeight: this.draggedBlockContentHeight,
|
||||
blockId: block.id
|
||||
});
|
||||
|
||||
// Create drop indicator
|
||||
this.createDropIndicator();
|
||||
|
||||
@ -98,6 +122,8 @@ export class WysiwygDragDropHandler {
|
||||
this.dragOverPosition = null;
|
||||
this.draggedBlockElement = null;
|
||||
this.draggedBlockHeight = 0;
|
||||
this.draggedBlockContentHeight = 0;
|
||||
this.draggedBlockMarginTop = 0;
|
||||
this.initialBlockY = 0;
|
||||
|
||||
// Update component state
|
||||
@ -284,34 +310,93 @@ export class WysiwygDragDropHandler {
|
||||
if (!this.dropIndicator || !this.draggedBlockElement) return;
|
||||
|
||||
this.dropIndicator.style.display = 'block';
|
||||
this.dropIndicator.style.height = `${this.draggedBlockHeight}px`;
|
||||
|
||||
const containerRect = this.component.editorContentRef.getBoundingClientRect();
|
||||
// Calculate where the block will actually land
|
||||
let topPosition = 0;
|
||||
|
||||
if (targetIndex === 0) {
|
||||
// Before first block
|
||||
topPosition = 0;
|
||||
} else {
|
||||
// After a specific block
|
||||
const prevIndex = targetIndex - 1;
|
||||
let blockCount = 0;
|
||||
// Build array of visual block positions (excluding dragged block)
|
||||
const visualBlocks: { index: number, top: number, bottom: number }[] = [];
|
||||
|
||||
// Find the visual position of the block that will be before our dropped block
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
if (i === draggedIndex) continue; // Skip the dragged block
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
if (i === draggedIndex) continue; // Skip the dragged block
|
||||
|
||||
if (blockCount === prevIndex) {
|
||||
const rect = blocks[i].getBoundingClientRect();
|
||||
topPosition = rect.bottom - containerRect.top + 16; // 16px gap
|
||||
break;
|
||||
const block = blocks[i];
|
||||
const rect = block.getBoundingClientRect();
|
||||
let top = rect.top - containerRect.top;
|
||||
let bottom = rect.bottom - containerRect.top;
|
||||
|
||||
// Account for any transforms
|
||||
const transform = window.getComputedStyle(block).transform;
|
||||
if (transform && transform !== 'none') {
|
||||
const matrix = new DOMMatrix(transform);
|
||||
const yOffset = matrix.m42;
|
||||
top += yOffset;
|
||||
bottom += yOffset;
|
||||
}
|
||||
|
||||
visualBlocks.push({ index: i, top, bottom });
|
||||
}
|
||||
|
||||
// Sort by visual position
|
||||
visualBlocks.sort((a, b) => a.top - b.top);
|
||||
|
||||
// Adjust targetIndex to account for excluded dragged block
|
||||
let adjustedTargetIndex = targetIndex;
|
||||
if (targetIndex > draggedIndex) {
|
||||
adjustedTargetIndex--; // Reduce by 1 since dragged block is not in visualBlocks
|
||||
}
|
||||
|
||||
// Calculate drop position
|
||||
// Get the margin that will be applied based on the dragged block type
|
||||
let blockMargin = 16; // default margin
|
||||
if (this.draggedBlockElement) {
|
||||
const draggedBlock = this.component.blocks.find(b => b.id === this.draggedBlockId);
|
||||
if (draggedBlock) {
|
||||
const blockType = draggedBlock.type;
|
||||
if (blockType === 'heading-1' || blockType === 'heading-2' || blockType === 'heading-3') {
|
||||
blockMargin = 24;
|
||||
} else if (blockType === 'code' || blockType === 'quote') {
|
||||
blockMargin = 20;
|
||||
}
|
||||
blockCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.dropIndicator.style.top = `${topPosition}px`;
|
||||
if (adjustedTargetIndex === 0) {
|
||||
// Insert at the very top - no margin needed for first block
|
||||
topPosition = 0;
|
||||
} else if (adjustedTargetIndex >= visualBlocks.length) {
|
||||
// Insert at the end
|
||||
const lastBlock = visualBlocks[visualBlocks.length - 1];
|
||||
if (lastBlock) {
|
||||
topPosition = lastBlock.bottom;
|
||||
// Add margin that will be applied to the dropped block
|
||||
topPosition += blockMargin;
|
||||
}
|
||||
} else {
|
||||
// Insert between blocks
|
||||
const blockBefore = visualBlocks[adjustedTargetIndex - 1];
|
||||
if (blockBefore) {
|
||||
topPosition = blockBefore.bottom;
|
||||
// Add margin that will be applied to the dropped block
|
||||
topPosition += blockMargin;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the indicator height to match the dragged block
|
||||
this.dropIndicator.style.height = `${this.draggedBlockHeight}px`;
|
||||
|
||||
// Set position
|
||||
this.dropIndicator.style.top = `${Math.max(0, topPosition)}px`;
|
||||
|
||||
console.log('Drop indicator update:', {
|
||||
targetIndex,
|
||||
adjustedTargetIndex,
|
||||
draggedIndex,
|
||||
topPosition,
|
||||
height: this.draggedBlockHeight,
|
||||
blockMargin,
|
||||
visualBlocks: visualBlocks.map(b => ({ index: b.index, top: b.top, bottom: b.bottom }))
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -443,6 +443,32 @@ export const inputShowcase = () => html`
|
||||
Specialized input components for specific data types like phone numbers, IBAN, and file uploads.
|
||||
</p>
|
||||
|
||||
<dees-panel .title=${'Date & Time Picker'} .subtitle=${'Calendar-based date selection'}>
|
||||
<div class="demo-grid">
|
||||
<dees-input-datepicker
|
||||
.label=${'Event Date'}
|
||||
.placeholder=${'Select date'}
|
||||
.description=${'Choose a date from the calendar'}
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
.label=${'Appointment Time'}
|
||||
.enableTime=${true}
|
||||
.timeFormat=${'12h'}
|
||||
.description=${'Date and time with AM/PM'}
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
.label=${'Deadline'}
|
||||
.enableTime=${true}
|
||||
.timeFormat=${'24h'}
|
||||
.minuteIncrement=${15}
|
||||
.minDate=${new Date().toISOString()}
|
||||
.description=${'Future dates only, 15 min increments'}
|
||||
></dees-input-datepicker>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Phone & IBAN'}>
|
||||
<div class="demo-grid">
|
||||
<dees-input-phone
|
||||
@ -474,6 +500,31 @@ export const inputShowcase = () => html`
|
||||
.accept=${'image/*'}
|
||||
></dees-input-fileupload>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Profile Picture Input'} .subtitle=${'Image upload with cropping'}>
|
||||
<div class="demo-grid">
|
||||
<dees-input-profilepicture
|
||||
.label=${'User Avatar'}
|
||||
.description=${'Round profile picture'}
|
||||
.shape=${'round'}
|
||||
.size=${120}
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
.label=${'Company Logo'}
|
||||
.description=${'Square format'}
|
||||
.shape=${'square'}
|
||||
.size=${120}
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
.label=${'Team Member'}
|
||||
.description=${'Larger profile image'}
|
||||
.shape=${'round'}
|
||||
.size=${150}
|
||||
></dees-input-profilepicture>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</section>
|
||||
|
||||
<!-- Rich Editors Section -->
|
||||
|
Reference in New Issue
Block a user