Compare commits

...

54 Commits

Author SHA1 Message Date
a212536cfa 1.11.6
Some checks failed
Default (tags) / security (push) Failing after 21s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-16 16:29:52 +00:00
18297d54c4 fix(dees-table): Improve Lucene range comparisons, pin monaco-editor to 0.52.2, and add local dev metadata 2025-09-16 16:29:52 +00:00
f790ca38d0 1.11.5
Some checks failed
Default (tags) / security (push) Failing after 21s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-16 16:22:25 +00:00
ce2b42ecd5 fix(ci): Add local Claude agent settings for CI tooling 2025-09-16 16:22:25 +00:00
09e299bc2e feat(styles): enhance table scrollbar behavior for sticky and non-sticky headers 2025-09-16 16:20:35 +00:00
bbc7dfe29a feat(demo): add demo sections for wide properties and scrollable table with actions 2025-09-16 16:17:03 +00:00
49b9e833e8 feat(styles): enhance actions column with sticky positioning and responsive layout adjustments 2025-09-16 16:12:13 +00:00
f739bb608e feat: enhance DeesTable with server-side search and Lucene filtering capabilities 2025-09-16 15:46:44 +00:00
286a6f9088 feat(styles): adjust searchGrid layout for content-based sizing 2025-09-16 15:28:12 +00:00
e32b9589a5 feat(styles): update searchGrid layout for improved responsiveness and control width 2025-09-16 15:25:04 +00:00
6427510c98 feat: add per-column filtering and sticky header support to DeesTable component 2025-09-16 15:17:33 +00:00
cf92a423cf Refactor DeesTable component: modularize data handling and styles
- Moved column computation and data retrieval logic to a new data.ts file for better separation of concerns.
- Created a styles.ts file to encapsulate all CSS styles related to the DeesTable component.
- Updated the DeesTable class to utilize the new data handling functions and styles.
- Introduced selection and filtering features, allowing for single and multi-row selection.
- Enhanced rendering logic to accommodate selection checkboxes and filtering capabilities.
- Re-exported types from types.ts for better type management and clarity.
2025-09-16 14:53:59 +00:00
3f3677ebaa feat: implement DeesTable component with schema-first columns API, data actions, and customizable styles
- Added DeesTable class extending DeesElement
- Introduced properties for headings, data, actions, and columns
- Implemented rendering logic for table headers, rows, and cells
- Added support for sorting, searching, and context menus
- Included customizable styles for table layout and appearance
- Integrated editable fields and drag-and-drop file handling
- Enhanced accessibility with ARIA attributes for sorting
2025-09-14 19:57:50 +00:00
edc15a727c 1.11.4
Some checks failed
Default (tags) / security (push) Failing after 24s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-14 19:23:23 +00:00
960085145d fix(readme): Rewrite and expand README with Quick Start, feature highlights, demos and usage examples; add local Claude settings file 2025-09-14 19:23:23 +00:00
7fdb4f19a8 1.11.3
Some checks failed
Default (tags) / security (push) Failing after 23s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-09 11:18:56 +00:00
e21fb79731 fix(dees-input-list): Prevent list animations from affecting scroll bounds and fix content-visibility issues in dees-input-list; add local developer settings 2025-09-09 11:18:56 +00:00
05f669a7bd feat(dees-input-list): add new input list component with demo and validation features 2025-09-08 19:21:37 +00:00
8137d79e18 1.11.2
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-07 09:02:25 +00:00
3b474b7dcc fix(DeesFormSubmit): Make form submit robust by locating nearest dees-form via closest(); add local CLAUDE settings 2025-09-07 09:02:25 +00:00
e449b413d1 1.11.1
Some checks failed
Default (tags) / security (push) Failing after 23s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-06 13:29:39 +00:00
8918dc94bd fix(dees-input-text): Normalize Lucide icon names for password toggle 2025-09-06 13:29:38 +00:00
2c595bf803 1.11.0
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-05 15:37:31 +00:00
75f31a6cec feat(dees-icon): Add full icon list and improve dees-icon demo with copy-all functionality and UI tweaks 2025-09-05 15:37:31 +00:00
b211c0d068 1.10.12
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-05 15:26:19 +00:00
911159ee55 fix(dees-simple-appdash): Fix icon rendering in dees-simple-appdash to respect provided icon strings 2025-09-05 15:26:19 +00:00
c0dbc3c0d0 1.10.11
Some checks failed
Default (tags) / security (push) Failing after 24s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-05 15:00:00 +00:00
7eea21c9d4 fix(dees-simple-appdash): Bump deps and fix dees-simple-appdash icon binding and terminal sizing 2025-09-05 15:00:00 +00:00
2f17dea480 feat(playbook): add PlayBook 2025-07-04 18:42:53 +00:00
ce33aff843 1.10.10
Some checks failed
Default (tags) / security (push) Failing after 13s
Default (tags) / test (push) Failing after 57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-30 13:07:16 +00:00
09eea844d7 feat(dees-mobilenavigation): update to use zindex registry and shadcn-like design
- Replace old zIndexLayers with new zIndexRegistry system
- Update design to match shadcn aesthetic with clean borders and shadows
- Add support for icons in menu items using Lucide icons
- Improve animations with staggered item appearance
- Better typography using Geist font family
- Add divider support for menu item grouping
- Improve hover and active states
- Add custom scrollbar styling
- Create comprehensive demo showcasing all features
- Ensure proper cleanup in disconnectedCallback
2025-06-30 13:04:19 +00:00
956edf0d63 fix(icons): update icon usage across components
- Replace .iconName property with .icon for dees-icon component
- Fix incorrect lucide icon names to use proper prefix and kebab-case
- Replace deprecated .iconFA property with .icon
- Add loading animation to dees-input-fileupload button
- Maintain compatibility with external interfaces expecting iconName
2025-06-30 12:57:13 +00:00
1db74177b3 update 2025-06-30 12:02:02 +00:00
1c25554c38 update 2025-06-30 11:35:38 +00:00
7d1e06701b update 2025-06-30 11:24:38 +00:00
aae4427281 update 2025-06-30 11:18:30 +00:00
911c51d078 update 2025-06-30 11:08:14 +00:00
2c12c22666 update 2025-06-30 10:58:31 +00:00
60a811fd18 update 2025-06-30 10:53:22 +00:00
9a9aea56da add datepicker 2025-06-30 10:40:23 +00:00
49ad998b2c update 2025-06-29 14:00:55 +00:00
5066681b3a update 2025-06-28 12:34:35 +00:00
ee22879c00 update 2025-06-28 12:27:35 +00:00
9b0ff2d856 1.10.9
Some checks failed
Default (tags) / security (push) Failing after 59s
Default (tags) / test (push) Failing after 19s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-28 10:05:09 +00:00
7e14645ed7 update 2025-06-27 23:48:39 +00:00
811737adcd update 2025-06-27 22:55:20 +00:00
7b6c135cd3 update 2025-06-27 22:47:24 +00:00
46065b2424 1.10.8
Some checks failed
Default (tags) / security (push) Failing after 55s
Default (tags) / test (push) Failing after 17s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-27 21:19:43 +00:00
e76a6c3632 update 2025-06-27 21:19:14 +00:00
896bc2bbb1 update 2025-06-27 21:16:52 +00:00
296d254ba2 update 2025-06-27 21:07:47 +00:00
ecad05098f update 2025-06-27 21:05:28 +00:00
956964f5b9 update dees-chips 2025-06-27 21:01:12 +00:00
ed73e16bbb update dees-modal 2025-06-27 19:48:32 +00:00
78 changed files with 16876 additions and 5599 deletions

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

View File

@@ -0,0 +1,6 @@
Before finishing a task:
- Run `pnpm run build` to ensure TypeScript compile + bundling succeed.
- Verify `dist_ts_web/` and `dist_bundle/bundle.js` updated.
- Optionally run `pnpm run test` and inspect failures.
- Avoid changing public APIs unless required; keep changes scoped.
- Update readme or inline docs only if user-facing behavior changes.

View File

@@ -0,0 +1,11 @@
Project: @design.estate/dees-catalog
Purpose: A component library of dynamic Web Components (TypeScript) for building modern web apps.
Tech stack: TypeScript (ES2022, NodeNext), decorators, custom elements via @design.estate/dees-element (Lit-style), bundling with esbuild via @git.zone/tsbundle, TypeScript building via @git.zone/tsbuild (tsfolders), tests with @git.zone/tstest, various UI libs (tiptap, apexcharts, monaco-editor runtime via CDN), DOM helpers via @design.estate/dees-domtools.
Structure:
- ts_web/: source of web components and pages
- dist_ts_web/: transpiled TS output
- dist_bundle/: production bundle (bundle.js + map)
- test/: tests
- html/: static demo assets
Key configs: tsconfig.json sets ES2022, NodeNext module/resolution, decorators enabled, skipLibCheck enabled to avoid third-party d.ts issues.
Entrypoints: ts_web/index.ts for bundling; custom elements annotated with @customElement.

View File

@@ -0,0 +1,5 @@
Language: TypeScript, ES2022 target, NodeNext module + resolution.
Patterns: Web Components with @customElement decorators; class-based components extending DeesElement; styles via css/cssManager; template render via html tagged literal.
Typing: Prefer explicit types where practical; tolerate `any` for external browser-injected libs (e.g., monaco) to keep build healthy.
Config: skipLibCheck enabled to avoid third-party d.ts breakages; exclude built declaration outputs.
Formatting/Linting: Not explicitly configured; follow existing style (2-space indents, single quotes often, semicolons present).

View File

@@ -0,0 +1,6 @@
Build: pnpm run build
Watch: pnpm run watch
Test: pnpm run test
Docs: pnpm run buildDocs
Inspect bundle size: ls -lh dist_bundle/bundle.js
Open demo (if applicable): serve static `html/` with any web server

67
.serena/project.yml Normal file
View File

@@ -0,0 +1,67 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: typescript
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "dees-catalog"

View File

@@ -1,5 +1,141 @@
# Changelog
## 2025-09-16 - 1.11.6 - fix(dees-table)
Improve Lucene range comparisons, pin monaco-editor to 0.52.2, and add local dev metadata
- Fix lucene inRange behavior to correctly compare homogeneous types (strings, numbers, dates) and fall back to string comparison when needed (ts_web/elements/dees-table/lucene.ts).
- Pin monaco-editor to 0.52.2 in package.json to avoid a breaking upgrade regression observed with ^0.53.0.
- Add local development/tooling metadata and conveniences: .claude/settings.local.json (tool permissions) and .serena/ memory files (done_checklist, project_overview, style_and_conventions, suggested_commands).
- Minor housekeeping: update project dev docs / memories to capture build/test/checklist guidance.
## 2025-09-16 - 1.11.5 - fix(ci)
Add local Claude agent settings for CI tooling
- Add .claude/settings.local.json to configure local Claude agent permissions
- Allow Bash commands matching pnpm run:* and the mcp__zen__chat permission for development tooling
## 2025-09-10 - 1.11.4 - fix(readme)
Rewrite and expand README with Quick Start, feature highlights, demos and usage examples; add local Claude settings file
- Completely rewritten and reorganized README: added Quick Start, component highlights, usage examples, demos, development workflow, troubleshooting and links.
- Added .claude/settings.local.json with local Claude permission configuration.
## 2025-09-08 - 1.11.3 - fix(dees-input-list)
Prevent list animations from affecting scroll bounds and fix content-visibility issues in dees-input-list; add local developer settings
- dees-input-list: add overflow:hidden to list items to prevent animations from altering scroll bounds and causing visual/scroll glitches
- dees-input-list: force content-visibility/contain to visible/none to avoid unexpected scrolling/layout issues when items animate
- Add .claude/settings.local.json with local developer permissions (allows running pnpm scripts via Claude-local tooling)
## 2025-09-07 - 1.11.2 - fix(DeesFormSubmit)
Make form submit robust by locating nearest dees-form via closest(); add local CLAUDE settings
- Fix: DeesFormSubmit.submit now walks up the DOM with closest('dees-form') to find and call gatherAndDispatch on the parent form. This fixes cases where the submit button is slotted or not a direct child of the form.
- Chore: Add .claude/settings.local.json to permit running pnpm scripts in the local CLAUDE environment (allows Bash(pnpm run:*)).
## 2025-09-06 - 1.11.1 - fix(dees-input-text)
Normalize Lucide icon names for password toggle
- Updated password visibility toggle icons in dees-input-text from 'lucide:eye'/'lucide:eye-off' to 'lucide:Eye'/'lucide:EyeOff' to match Lucide exports and avoid missing icon rendering.
## 2025-09-05 - 1.11.0 - feat(dees-icon)
Add full icon list and improve dees-icon demo with copy-all functionality and UI tweaks
- Added readme.icons.md containing 1900+ icon identifiers (FontAwesome + Lucide) for easy reference and tooling
- Enhanced ts_web/elements/dees-icon.demo.ts: added a 'Copy All Icon Names' button that copies prefixed icon names (fa:..., lucide:...) to the clipboard and shows temporary feedback
- Updated demo presentation: prefixed displayed icon names (fa: / lucide:), improved search-container spacing and added button styling for better UX
- Changes are documentation/demo only — no production runtime component logic changed
## 2025-09-05 - 1.10.12 - fix(dees-simple-appdash)
Fix icon rendering in dees-simple-appdash to respect provided icon strings
- dees-simple-appdash: stop forcing a 'lucide:' prefix when rendering view icons — use the icon string as provided.
- Prevents incorrect/missing icons when the iconName already includes a library prefix (e.g. 'fa:' or 'lucide:').
## 2025-09-05 - 1.10.11 - fix(dees-simple-appdash)
Bump deps and fix dees-simple-appdash icon binding and terminal sizing
- Updated runtime dependencies: @design.estate/dees-element -> ^2.1.2, @design.estate/dees-wcctools -> ^1.1.1, @fortawesome/* -> ^7.0.1, apexcharts -> ^5.3.4, lucide -> ^0.542.0 (compatibility/security/stability updates)
- Updated dev tooling: @git.zone/tsbuild -> ^2.6.8, @git.zone/tstest -> ^2.3.6, @git.zone/tswatch -> ^2.2.1
- Fix: dees-simple-appdash — use proper string interpolation for lucide icon properties (prevents incorrect icon rendering)
- Fix: dees-simple-appdash — enforce terminal maxWidth/maxHeight to avoid overflow and improve layout stability
- Cosmetic: small style/behavior tweaks to dees-simple-appdash (logout/terminal/wifi icon bindings corrected)
## 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
- Updated dees-modal with shadcn colors, borders, and subtle shadows
- Updated dees-chips with shadcn styling and fixed selection logic bug
- Updated dees-dataview-codebox with shadcn syntax highlighting colors and responsive label layout
- Updated dees-input-multitoggle with transparent blue indicator and smooth animations
- Updated dees-appui-tabs with animated sliding indicator for both horizontal and vertical layouts
- Fixed indicator positioning to be perfectly centered on tab content
- Indicator width is content width + 8px for minimal visual padding
- Fixed tab content centering by using consistent padding (12px → 16px on all sides)
- Fixed icon rendering by correcting property name from .iconName to .icon
- Added visual separators between tabs for better distinction
- Added subtle hover backgrounds for improved interactivity
- Refactored tabs component code for better maintainability and elegance
- Updated dees-appui-activitylog with shadcn-aligned styling:
- Updated background and text colors to match shadcn palette
- Enhanced topbar with better spacing and typography
- Improved activity entries with subtle hover states and better spacing
- Added activity type icons with color-coded backgrounds (login, logout, view, create, update)
- Added date separators ("Today", "Yesterday") for better temporal organization
- Enhanced streaming indicators with animated pulse effect
- Redesigned searchbox with modern input styling, search icon, and focus states
- Added custom scrollbar styling for consistency
- Updated timestamps to be more subtle with tabular number formatting
- Refined shadow effects for better visual hierarchy
- Added subtle box shadow to component for depth
- Added fade-in animation for new activity entries
- Improved user name highlighting with better typography
- Updated context menu with more relevant actions
- Improved overall spacing and visual consistency across components
## 2025-06-27 - 1.10.1 - fix(modal)
Improve modal overscroll behavior by adding 'overscroll-behavior: contain' to content container

View File

@@ -1,6 +1,6 @@
{
"name": "@design.estate/dees-catalog",
"version": "1.10.7",
"version": "1.11.6",
"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",
@@ -16,15 +16,15 @@
"license": "MIT",
"dependencies": {
"@design.estate/dees-domtools": "^2.3.3",
"@design.estate/dees-element": "^2.0.45",
"@design.estate/dees-wcctools": "^1.0.101",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@design.estate/dees-element": "^2.1.2",
"@design.estate/dees-wcctools": "^1.1.1",
"@fortawesome/fontawesome-svg-core": "^7.0.1",
"@fortawesome/free-brands-svg-icons": "^7.0.1",
"@fortawesome/free-regular-svg-icons": "^7.0.1",
"@fortawesome/free-solid-svg-icons": "^7.0.1",
"@push.rocks/smarti18n": "^1.0.4",
"@push.rocks/smartpromise": "^4.2.0",
"@push.rocks/smartstring": "^4.0.15",
"@push.rocks/smartstring": "^4.1.0",
"@tiptap/core": "^2.23.0",
"@tiptap/extension-link": "^2.23.0",
"@tiptap/extension-text-align": "^2.23.0",
@@ -33,20 +33,20 @@
"@tiptap/starter-kit": "^2.23.0",
"@tsclass/tsclass": "^9.2.0",
"@webcontainer/api": "1.2.0",
"apexcharts": "^4.7.0",
"apexcharts": "^5.3.5",
"highlight.js": "11.11.1",
"ibantools": "^4.5.1",
"lucide": "^0.523.0",
"monaco-editor": "^0.52.2",
"lucide": "^0.544.0",
"monaco-editor": "0.52.2",
"pdfjs-dist": "^4.10.38",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.6.4",
"@git.zone/tsbuild": "^2.6.8",
"@git.zone/tsbundle": "^2.5.1",
"@git.zone/tstest": "^2.3.1",
"@git.zone/tswatch": "^2.1.2",
"@git.zone/tstest": "^2.3.8",
"@git.zone/tswatch": "^2.2.1",
"@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^22.0.0"

3328
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- puppeteer

View File

@@ -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

1906
readme.icons.md Normal file

File diff suppressed because it is too large Load Diff

2089
readme.md

File diff suppressed because it is too large Load Diff

784
readme.playbook.md Normal file
View 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.

View File

@@ -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.

View File

@@ -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

View File

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

View File

@@ -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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View File

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

View File

@@ -11,27 +11,46 @@ import {
import * as domtools from '@design.estate/dees-domtools';
import { DeesContextmenu } from './dees-contextmenu.js';
import './dees-icon.js';
@customElement('dees-appui-activitylog')
export class DeesAppuiActivitylog extends DeesElement {
// STATIC
public static demo = () => html`<dees-appui-activitylog></dees-appui-activitylog>`;
public static demo = () => html`
<style>
.demo-container {
display: flex;
justify-content: center;
align-items: center;
height: 600px;
background: ${cssManager.bdTheme('#f4f4f5', '#09090b')};
padding: 32px;
}
</style>
<div class="demo-container">
<dees-appui-activitylog></dees-appui-activitylog>
</div>
`;
// INSTANCE
public static styles = [
cssManager.defaultStyles,
css`
:host {
color: ${cssManager.bdTheme('#333', '#fff')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
position: relative;
display: block;
width: 100%;
max-width: 300px;
max-width: 320px;
height: 100%;
background: ${cssManager.bdTheme('#f8f8f8', '#111c28')};
font-family: 'Intel One Mono', sans-serif;
border-left: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
font-family: 'Geist Mono', monospace;
border-left: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
cursor: default;
box-shadow: ${cssManager.bdTheme(
'-4px 0 12px rgba(0, 0, 0, 0.02)',
'-4px 0 12px rgba(0, 0, 0, 0.2)'
)};
}
.maincontainer {
position: absolute;
@@ -44,108 +63,265 @@ export class DeesAppuiActivitylog extends DeesElement {
.topbar {
position: absolute;
top: 0px;
height: 32px;
height: 40px;
width: 100%;
padding: 0px 12px 0px 12px;
background: ${cssManager.bdTheme('#ffffff', '#0e151f')};
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
padding: 0px 16px;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
display: flex;
align-items: center;
}
.topbar .heading {
text-align: left;
line-height: 24px;
padding-top: 8px;
font-weight: 500;
font-weight: 600;
font-size: 14px;
font-family: 'Geist Sans', sans-serif;
color: ${cssManager.bdTheme('#666', '#ccc')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.activityContainer {
position: absolute;
top: 32px;
bottom: 40px;
top: 40px;
bottom: 48px;
width: 100%;
padding: 8px 0px;
overflow-y: scroll;
padding: 12px 0px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: ${cssManager.bdTheme('#e5e7eb', '#27272a')} transparent;
}
.activityContainer::-webkit-scrollbar {
width: 6px;
}
.activityContainer::-webkit-scrollbar-track {
background: transparent;
}
.activityContainer::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 3px;
}
.activityContainer::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
}
.streamingIndicator {
font-size: 12px;
font-size: 11px;
text-align: center;
padding-top: 16px;
color: ${cssManager.bdTheme('#666', '#888')}
padding: 16px;
color: ${cssManager.bdTheme('#71717a', '#71717a')};
font-family: 'Geist Sans', sans-serif;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.streamingIndicator::before {
content: '';
width: 6px;
height: 6px;
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
.streamingIndicator.bottom {
padding-top: 0px;
padding-top: 8px;
padding-bottom: 16px;
}
.activityentry {
min-height: 30px;
font-size: 12px;
padding: 8px 16px;
border-bottom: 1px dotted ${cssManager.bdTheme('#00000020', '#ffffff20')};
min-height: 36px;
font-size: 13px;
padding: 10px 16px;
border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#18181b')};
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 8px;
line-height: 1.4;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.activityentry:last-of-type {
border-bottom: 1px solid transparent;
border-bottom: none;
}
.activityentry:hover {
background: ${cssManager.bdTheme('#00000005', '#00000080')};
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
}
.timestamp {
color: ${cssManager.bdTheme('#e57373', '#ff8787')};
color: ${cssManager.bdTheme('#71717a', '#71717a')};
font-weight: 500;
font-size: 12px;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
min-width: 45px;
}
.activity-icon {
width: 28px;
height: 28px;
border-radius: 6px;
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
}
.activity-icon.login {
background: ${cssManager.bdTheme('rgba(34, 197, 94, 0.1)', 'rgba(34, 197, 94, 0.1)')};
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
}
.activity-icon.logout {
background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.1)', 'rgba(239, 68, 68, 0.1)')};
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
}
.activity-icon.view {
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
color: ${cssManager.bdTheme('#2563eb', '#3b82f6')};
}
.activity-icon.create {
background: ${cssManager.bdTheme('rgba(168, 85, 247, 0.1)', 'rgba(168, 85, 247, 0.1)')};
color: ${cssManager.bdTheme('#9333ea', '#a855f7')};
}
.activity-icon.update {
background: ${cssManager.bdTheme('rgba(251, 146, 60, 0.1)', 'rgba(251, 146, 60, 0.1)')};
color: ${cssManager.bdTheme('#ea580c', '#fb923c')};
}
.activity-text {
flex: 1;
color: ${cssManager.bdTheme('#18181b', '#e4e4e7')};
}
.activity-user {
font-weight: 600;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.date-separator {
padding: 12px 16px 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${cssManager.bdTheme('#71717a', '#71717a')};
background: ${cssManager.bdTheme('#f9fafb', '#09090b')};
border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#18181b')};
position: sticky;
top: 0;
z-index: 1;
}
.searchbox {
position: absolute;
bottom: 0px;
width: 100%;
height: 40px;
background: ${cssManager.bdTheme('#ffffff', '#0e151f')};
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
height: 48px;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border-top: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
padding: 8px;
}
.searchbox input {
color: ${cssManager.bdTheme('#333', '#fff')};
background: none;
.search-wrapper {
position: relative;
width: 100%;
height: 40px;
line-height: 32px;
border: none;
padding: 4px 12px;
font-family: 'Intel One Mono', sans-serif;
height: 32px;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: ${cssManager.bdTheme('#71717a', '#71717a')};
font-size: 14px;
pointer-events: none;
transition: color 0.15s ease;
}
.searchbox input {
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
width: 100%;
height: 100%;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 6px;
padding: 0 12px 0 36px;
font-family: 'Geist Sans', sans-serif;
font-size: 13px;
transition: all 0.15s ease;
}
.searchbox input::placeholder {
color: ${cssManager.bdTheme('#71717a', '#71717a')};
}
.searchbox input:focus {
outline: none;
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
}
.searchbox input:focus ~ .search-icon,
.search-wrapper:has(input:focus) .search-icon {
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
}
.bottomShadow {
position: absolute;
width: 100%;
height: 32px;
bottom: 40px;
height: 24px;
bottom: 48px;
background: ${cssManager.bdTheme(
'linear-gradient(180deg, #f8f8f800 0%, #ffffff 100%)',
'linear-gradient(180deg, #111c2800 0%, #0e151f 100%)'
'linear-gradient(180deg, transparent 0%, #fafafa 100%)',
'linear-gradient(180deg, transparent 0%, #0a0a0a 100%)'
)};
pointer-events: none;
opacity: 0.8;
}
.topShadow {
position: absolute;
width: 100%;
height: 32px;
top: 32px;
height: 24px;
top: 40px;
background: ${cssManager.bdTheme(
'linear-gradient(0deg, #f8f8f800 0%, #ffffff 100%)',
'linear-gradient(0deg, #111c2800 0%, #0e151f 100%)'
'linear-gradient(0deg, transparent 0%, #fafafa 100%)',
'linear-gradient(0deg, transparent 0%, #0a0a0a 100%)'
)};
pointer-events: none;
opacity: 0.8;
}
`,
];
@@ -159,86 +335,174 @@ export class DeesAppuiActivitylog extends DeesElement {
<div class="heading">Activity Log</div>
</div>
<div class="activityContainer">
<div class="streamingIndicator">streaming...</div>
<div class="streamingIndicator">Live Updates</div>
<div class="date-separator">Today</div>
<div class="activityentry" @contextmenu=${async eventArg => {
DeesContextmenu.openContextMenuWithOptions(eventArg, [
{
name: 'app settings',
name: 'Copy activity',
action: async () => {},
},
{
name: 'account settings',
name: 'View details',
action: async () => {},
},
{
name: 'logout',
name: 'Filter by user',
action: async () => {},
},
]);
}}>
<span class="timestamp">22:01:</span> Max Mustermann logged in
<span class="timestamp">22:20</span>
<div class="activity-icon logout">
<dees-icon .icon=${'lucide:logOut'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> logged out
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:02:</span> Max Mustermann viewed an invoice
<span class="timestamp">22:19</span>
<div class="activity-icon update">
<dees-icon .icon=${'lucide:checkCircle'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> approved a payment
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:03:</span> Max Mustermann added a new contact
<span class="timestamp">22:18</span>
<div class="activity-icon view">
<dees-icon .icon=${'lucide:archive'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> archived an invoice
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:04:</span> Max Mustermann updated account settings
<span class="timestamp">22:17</span>
<div class="activity-icon login">
<dees-icon .icon=${'lucide:logIn'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> logged in
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:05:</span> Max Mustermann logged out
<span class="timestamp">22:16</span>
<div class="activity-icon logout">
<dees-icon .icon=${'lucide:logOut'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> logged out
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:06:</span> Max Mustermann logged in
<span class="timestamp">22:15</span>
<div class="activity-icon update">
<dees-icon .icon=${'lucide:key'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> changed password
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:07:</span> Max Mustermann created a new invoice
<span class="timestamp">22:14</span>
<div class="activity-icon create">
<dees-icon .icon=${'lucide:userPlus'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> added a new user
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:08:</span> Max Mustermann sent an invoice
<span class="timestamp">22:13</span>
<div class="activity-icon view">
<dees-icon .icon=${'lucide:messageCircle'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> contacted support
</div>
</div>
<div class="date-separator">Yesterday</div>
<div class="activityentry">
<span class="timestamp">22:09:</span> Max Mustermann viewed reports
<span class="timestamp">18:45</span>
<div class="activity-icon update">
<dees-icon .icon=${'lucide:trash2'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> deleted an invoice
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:10:</span> Max Mustermann logged out
<span class="timestamp">17:30</span>
<div class="activity-icon login">
<dees-icon .icon=${'lucide:logIn'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> logged in
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:11:</span> Max Mustermann logged in
<span class="timestamp">16:15</span>
<div class="activity-icon logout">
<dees-icon .icon=${'lucide:logOut'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> logged out
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:12:</span> Max Mustermann deleted an invoice
<span class="timestamp">14:20</span>
<div class="activity-icon view">
<dees-icon .icon=${'lucide:barChart'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> viewed reports
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:13:</span> Max Mustermann contacted support
<span class="timestamp">13:45</span>
<div class="activity-icon create">
<dees-icon .icon=${'lucide:send'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> sent an invoice
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:14:</span> Max Mustermann added a new user
<span class="timestamp">13:30</span>
<div class="activity-icon create">
<dees-icon .icon=${'lucide:filePlus'}></dees-icon>
</div>
<div class="activity-text">
<span class="activity-user">Max Mustermann</span> created a new invoice
</div>
</div>
<div class="activityentry">
<span class="timestamp">22:15:</span> Max Mustermann changed password
</div>
<div class="activityentry">
<span class="timestamp">22:16:</span> Max Mustermann logged out
</div>
<div class="activityentry">
<span class="timestamp">22:17:</span> Max Mustermann logged in
</div>
<div class="activityentry">
<span class="timestamp">22:18:</span> Max Mustermann archived an invoice
</div>
<div class="activityentry">
<span class="timestamp">22:19:</span> Max Mustermann approved a payment
</div>
<div class="activityentry">
<span class="timestamp">22:20:</span> Max Mustermann logged out
</div>
<div class="streamingIndicator bottom">loading more...</div>
<div class="streamingIndicator bottom">Loading History</div>
</div>
<div class="searchbox">
<input type="text" placeholder="Search" />
<div class="search-wrapper">
<dees-icon class="search-icon" .icon=${'lucide:search'}></dees-icon>
<input type="text" placeholder="Search activities, users..." />
</div>
</div>
<div class="topShadow"></div>
<div class="bottomShadow"></div>

View File

@@ -65,10 +65,10 @@ export const demoFunc = () => {
// Main menu tabs (left sidebar)
const mainMenuTabs: ITab[] = [
{ key: 'dashboard', iconName: 'home', action: () => console.log('Dashboard selected') },
{ key: 'projects', iconName: 'folder', action: () => console.log('Projects selected') },
{ key: 'analytics', iconName: 'lineChart', action: () => console.log('Analytics selected') },
{ key: 'settings', iconName: 'settings', action: () => console.log('Settings selected') },
{ key: 'dashboard', iconName: 'lucide:home', action: () => console.log('Dashboard selected') },
{ key: 'projects', iconName: 'lucide:folder', action: () => console.log('Projects selected') },
{ key: 'analytics', iconName: 'lucide:lineChart', action: () => console.log('Analytics selected') },
{ key: 'settings', iconName: 'lucide:settings', action: () => console.log('Settings selected') },
];
// Selector options (second sidebar)
@@ -83,9 +83,9 @@ export const demoFunc = () => {
// Main content tabs
const mainContentTabs: ITab[] = [
{ key: 'Details', iconName: 'file', action: () => console.log('Details tab') },
{ key: 'Logs', iconName: 'list', action: () => console.log('Logs tab') },
{ key: 'Metrics', iconName: 'lineChart', action: () => console.log('Metrics tab') },
{ key: 'Details', iconName: 'lucide:file', action: () => console.log('Details tab') },
{ key: 'Logs', iconName: 'lucide:list', action: () => console.log('Logs tab') },
{ key: 'Metrics', iconName: 'lucide:lineChart', action: () => console.log('Metrics tab') },
];
// Profile menu items

View File

@@ -19,9 +19,9 @@ export class DeesAppuiMaincontent extends DeesElement {
public static demo = () => html`
<dees-appui-maincontent
.tabs=${[
{ key: 'Overview', iconName: 'home', action: () => console.log('Overview') },
{ key: 'Details', iconName: 'file', action: () => console.log('Details') },
{ key: 'Settings', iconName: 'cog', action: () => console.log('Settings') },
{ key: 'Overview', iconName: 'lucide:home', action: () => console.log('Overview') },
{ key: 'Details', iconName: 'lucide:file', action: () => console.log('Details') },
{ key: 'Settings', iconName: 'lucide:settings', action: () => console.log('Settings') },
]}
>
<div slot="content" style="padding: 40px; color: #ccc;">

View File

@@ -22,10 +22,10 @@ export class DeesAppuiMainmenu extends DeesElement {
public static demo = () => html`
<dees-appui-mainmenu
.tabs=${[
{ key: 'Dashboard', iconName: 'home', action: () => console.log('Dashboard') },
{ key: 'Projects', iconName: 'folder', action: () => console.log('Projects') },
{ key: 'Analytics', iconName: 'lineChart', action: () => console.log('Analytics') },
{ key: 'Settings', iconName: 'settings', action: () => console.log('Settings') },
{ key: 'Dashboard', iconName: 'lucide:home', action: () => console.log('Dashboard') },
{ key: 'Projects', iconName: 'lucide:folder', action: () => console.log('Projects') },
{ key: 'Analytics', iconName: 'lucide:lineChart', action: () => console.log('Analytics') },
{ key: 'Settings', iconName: 'lucide:settings', action: () => console.log('Settings') },
]}
></dees-appui-mainmenu>
`;
@@ -35,7 +35,7 @@ export class DeesAppuiMainmenu extends DeesElement {
// INSTANCE
@property({ type: Array })
public tabs: interfaces.ITab[] = [
{ key: '⚠️ Please set tabs', iconName: 'alertTriangle', action: () => console.warn('No tabs configured for mainmenu') },
{ key: '⚠️ Please set tabs', iconName: 'lucide:alertTriangle', action: () => console.warn('No tabs configured for mainmenu') },
];
@property()
@@ -112,7 +112,7 @@ export class DeesAppuiMainmenu extends DeesElement {
this.updateTab(tabArg);
}}"
>
<dees-icon .icon="${tabArg.iconName ? `lucide:${tabArg.iconName}` : ''}"></dees-icon>
<dees-icon .icon="${tabArg.iconName || ''}"></dees-icon>
</div>
`;
})}

View File

@@ -14,15 +14,94 @@ import * as domtools from '@design.estate/dees-domtools';
@customElement('dees-appui-tabs')
export class DeesAppuiTabs extends DeesElement {
public static demo = () => html`
<dees-appui-tabs
.tabs=${[
{ key: 'Tab 1', action: () => console.log('Tab 1 clicked') },
{ key: 'Tab 2', action: () => console.log('Tab 2 clicked') },
{ key: 'Tab 3', action: () => console.log('Tab 3 clicked') },
]}
></dees-appui-tabs>
`;
public static demo = () => {
const horizontalTabs: interfaces.ITab[] = [
{ key: 'Home', iconName: 'lucide:home', action: () => console.log('Home clicked') },
{ key: 'Analytics Dashboard', iconName: 'lucide:lineChart', action: () => console.log('Analytics clicked') },
{ key: 'Reports', iconName: 'lucide:fileText', action: () => console.log('Reports clicked') },
{ key: 'User Settings', iconName: 'lucide:settings', action: () => console.log('Settings clicked') },
{ key: 'Help', iconName: 'lucide:helpCircle', action: () => console.log('Help clicked') },
];
const verticalTabs: interfaces.ITab[] = [
{ key: 'Profile', iconName: 'lucide:user', action: () => console.log('Profile clicked') },
{ key: 'Security', iconName: 'lucide:shield', action: () => console.log('Security clicked') },
{ key: 'Notifications', iconName: 'lucide:bell', action: () => console.log('Notifications clicked') },
{ key: 'Integrations', iconName: 'lucide:link', action: () => console.log('Integrations clicked') },
{ key: 'Advanced', iconName: 'lucide:code', action: () => console.log('Advanced clicked') },
];
const noIndicatorTabs: interfaces.ITab[] = [
{ key: 'All', action: () => console.log('All clicked') },
{ key: 'Active', action: () => console.log('Active clicked') },
{ key: 'Completed', action: () => console.log('Completed clicked') },
{ key: 'Archived', action: () => console.log('Archived clicked') },
];
const demoContent = (text: string) => html`
<div style="padding: 24px; color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};">
${text}
</div>
`;
return html`
<style>
.demo-container {
display: flex;
flex-direction: column;
gap: 32px;
padding: 48px;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
min-height: 100vh;
}
.section {
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 8px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.two-column {
display: grid;
grid-template-columns: 200px 1fr;
gap: 24px;
align-items: start;
}
</style>
<div class="demo-container">
<div class="section">
<div class="section-title">Horizontal Tabs with Animated Indicator</div>
<dees-appui-tabs .tabs=${horizontalTabs}>
${demoContent('Select a tab to see the smooth sliding animation of the indicator. The indicator automatically adjusts its width to match the tab content with minimal padding.')}
</dees-appui-tabs>
</div>
<div class="section">
<div class="section-title">Vertical Tabs Layout</div>
<div class="two-column">
<dees-appui-tabs .tabStyle=${'vertical'} .tabs=${verticalTabs}></dees-appui-tabs>
${demoContent('Vertical tabs work great for settings pages and navigation menus. The animated indicator smoothly transitions between selections.')}
</div>
</div>
<div class="section">
<div class="section-title">Without Indicator</div>
<dees-appui-tabs .showTabIndicator=${false} .tabs=${noIndicatorTabs}>
${demoContent('Tabs can also be used without the animated indicator by setting showTabIndicator to false.')}
</dees-appui-tabs>
</div>
</div>
`;
};
// INSTANCE
@property({
@@ -50,148 +129,217 @@ export class DeesAppuiTabs extends DeesElement {
.tabs-wrapper {
position: relative;
background: ${cssManager.bdTheme('#f5f5f5', '#000000')};
height: 52px;
}
.tabs-wrapper.horizontal-wrapper {
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
}
.tabsContainer {
position: relative;
z-index: 1;
user-select: none;
}
.tabsContainer.horizontal {
display: grid;
padding-top: 20px;
padding-bottom: 0px;
margin-left: 24px;
display: flex;
align-items: center;
font-size: 14px;
overflow-x: auto;
scrollbar-width: none;
height: 48px;
padding: 0 16px;
gap: 4px;
}
.tabsContainer.horizontal::-webkit-scrollbar {
display: none;
}
.tabsContainer.vertical {
display: flex;
flex-direction: column;
padding: 20px;
padding: 8px;
font-size: 14px;
gap: 2px;
position: relative;
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
border-radius: 8px;
}
.tab {
color: ${cssManager.bdTheme('#666', '#a0a0a0')};
color: ${cssManager.bdTheme('#71717a', '#71717a')};
white-space: nowrap;
cursor: default;
transition: color 0.1s;
cursor: pointer;
transition: color 0.15s ease;
font-weight: 500;
position: relative;
z-index: 2;
}
.horizontal .tab {
margin-right: 30px;
padding-top: 4px;
padding-bottom: 12px;
padding: 0 16px;
height: 100%;
display: inline-flex;
align-items: center;
gap: 8px;
position: relative;
border-radius: 6px 6px 0 0;
transition: background-color 0.15s ease;
}
.vertical .tab {
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 4px;
width: 100%;
display: flex;
.horizontal .tab:not(:last-child)::after {
content: '';
position: absolute;
right: -2px;
top: 50%;
transform: translateY(-50%);
height: 20px;
width: 1px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
opacity: 0.5;
}
.horizontal .tab .tab-content {
display: inline-flex;
align-items: center;
gap: 8px;
}
.vertical .tab {
padding: 10px 16px;
border-radius: 6px;
width: 100%;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.15s ease;
}
.tab:hover {
color: ${cssManager.bdTheme('#000', '#ffffff')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.horizontal .tab:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.03)')};
}
.horizontal .tab:hover::after,
.horizontal .tab:hover + .tab::after {
opacity: 0;
}
.vertical .tab:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
background: ${cssManager.bdTheme('rgba(244, 244, 245, 0.5)', 'rgba(39, 39, 42, 0.5)')};
}
.tab.selectedTab {
color: ${cssManager.bdTheme('#333', '#e0e0e0')};
.horizontal .tab.selectedTab {
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.horizontal .tab.selectedTab::after,
.horizontal .tab.selectedTab + .tab::after {
opacity: 0;
}
.vertical .tab.selectedTab {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
color: ${cssManager.bdTheme('#000', '#ffffff')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.tab dees-icon {
font-size: 16px;
}
.tabs-wrapper .tabIndicator {
.tabIndicator {
position: absolute;
z-index: 0;
left: 40px;
bottom: 0px;
height: 40px;
width: 40px;
background: ${cssManager.bdTheme('#ffffff', '#161616')};
transition: all 0.1s;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444444')};
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
}
.tabIndicator.no-transition {
transition: none;
}
.vertical .tabIndicator {
display: none;
.tabs-wrapper .tabIndicator {
height: 3px;
bottom: 0;
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
border-radius: 3px 3px 0 0;
z-index: 3;
}
.vertical-wrapper {
position: relative;
}
.vertical-wrapper .tabIndicator {
left: 8px;
right: 8px;
border-radius: 6px;
background: ${cssManager.bdTheme('#ffffff', '#27272a')};
z-index: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.content {
margin-top: 20px;
padding: 32px 24px;
}
`,
];
public render(): TemplateResult {
return html`
${this.tabStyle === 'horizontal' ? html`
<style>
.tabsContainer.horizontal {
grid-template-columns: repeat(${this.tabs.length}, min-content);
}
</style>
<div class="tabs-wrapper">
<div class="tabsContainer horizontal">
${this.tabs.map((tabArg) => {
return html`
<div
class="tab ${tabArg === this.selectedTab ? 'selectedTab' : ''}"
@click="${() => this.selectTab(tabArg)}"
>
${tabArg.key}
</div>
`;
})}
</div>
${this.showTabIndicator ? html`
<div class="tabIndicator"></div>
` : ''}
</div>
` : html`
<div class="tabsContainer vertical">
${this.tabs.map((tabArg) => {
return html`
<div
class="tab ${tabArg === this.selectedTab ? 'selectedTab' : ''}"
@click="${() => this.selectTab(tabArg)}"
>
${tabArg.iconName ? html`<dees-icon .iconName=${tabArg.iconName}></dees-icon>` : ''}
${tabArg.key}
</div>
`;
})}
</div>
`}
${this.renderTabsWrapper()}
<div class="content">
<slot></slot>
</div>
`;
}
private renderTabsWrapper(): TemplateResult {
const isHorizontal = this.tabStyle === 'horizontal';
const wrapperClass = isHorizontal ? 'tabs-wrapper horizontal-wrapper' : 'vertical-wrapper';
const containerClass = `tabsContainer ${this.tabStyle}`;
return html`
<div class="${wrapperClass}">
<div class="${containerClass}">
${this.tabs.map(tab => this.renderTab(tab, isHorizontal))}
</div>
${this.showTabIndicator ? html`<div class="tabIndicator"></div>` : ''}
</div>
`;
}
private renderTab(tab: interfaces.ITab, isHorizontal: boolean): TemplateResult {
const isSelected = tab === this.selectedTab;
const classes = `tab ${isSelected ? 'selectedTab' : ''}`;
const content = isHorizontal ? html`
<span class="tab-content">
${this.renderTabIcon(tab)}
${tab.key}
</span>
` : html`
${this.renderTabIcon(tab)}
${tab.key}
`;
return html`
<div
class="${classes}"
@click="${() => this.selectTab(tab)}"
>
${content}
</div>
`;
}
private renderTabIcon(tab: interfaces.ITab): TemplateResult | '' {
return tab.iconName ? html`<dees-icon .icon=${tab.iconName}></dees-icon>` : '';
}
private selectTab(tabArg: interfaces.ITab) {
this.selectedTab = tabArg;
this.updateTabIndicator();
tabArg.action();
// Emit tab-select event
@@ -202,31 +350,6 @@ export class DeesAppuiTabs extends DeesElement {
}));
}
/**
* updates the indicator position
*/
private updateTabIndicator() {
if (!this.showTabIndicator || this.tabStyle !== 'horizontal' || !this.selectedTab) {
return;
}
const tabIndex = this.tabs.indexOf(this.selectedTab);
const selectedTabElement: HTMLElement = this.shadowRoot.querySelector(
`.tabs-wrapper .tabsContainer .tab:nth-child(${tabIndex + 1})`
);
if (!selectedTabElement) return;
const tabsContainer: HTMLElement = this.shadowRoot.querySelector('.tabs-wrapper .tabsContainer');
const marginLeft = parseInt(window.getComputedStyle(tabsContainer).getPropertyValue("margin-left"));
const tabIndicator: HTMLElement = this.shadowRoot.querySelector('.tabs-wrapper .tabIndicator');
if (tabIndicator) {
tabIndicator.style.width = selectedTabElement.clientWidth + 24 + 'px';
tabIndicator.style.left = selectedTabElement.offsetLeft + marginLeft - 12 + 'px';
}
}
firstUpdated() {
if (this.tabs && this.tabs.length > 0) {
this.selectTab(this.tabs[0]);
@@ -241,7 +364,88 @@ export class DeesAppuiTabs extends DeesElement {
}
if (changedProperties.has('selectedTab') || changedProperties.has('tabs')) {
this.updateTabIndicator();
await this.updateComplete;
// Wait for fonts to load on first update
if (!this.indicatorInitialized && document.fonts) {
await document.fonts.ready;
}
requestAnimationFrame(() => {
this.updateTabIndicator();
});
}
}
private indicatorInitialized = false;
private updateTabIndicator() {
if (!this.shouldShowIndicator()) return;
const selectedTabElement = this.getSelectedTabElement();
if (!selectedTabElement) return;
const indicator = this.getIndicatorElement();
if (!indicator) return;
this.handleInitialTransition(indicator);
if (this.tabStyle === 'horizontal') {
this.updateHorizontalIndicator(indicator, selectedTabElement);
} else {
this.updateVerticalIndicator(indicator, selectedTabElement);
}
indicator.style.opacity = '1';
}
private shouldShowIndicator(): boolean {
return this.selectedTab && this.showTabIndicator && this.tabs.includes(this.selectedTab);
}
private getSelectedTabElement(): HTMLElement | null {
const selectedIndex = this.tabs.indexOf(this.selectedTab);
const isHorizontal = this.tabStyle === 'horizontal';
const selector = isHorizontal
? `.tabs-wrapper .tabsContainer .tab:nth-child(${selectedIndex + 1})`
: `.vertical-wrapper .tabsContainer .tab:nth-child(${selectedIndex + 1})`;
return this.shadowRoot.querySelector(selector);
}
private getIndicatorElement(): HTMLElement | null {
return this.shadowRoot.querySelector('.tabIndicator');
}
private handleInitialTransition(indicator: HTMLElement): void {
if (!this.indicatorInitialized) {
indicator.classList.add('no-transition');
this.indicatorInitialized = true;
setTimeout(() => {
indicator.classList.remove('no-transition');
}, 50);
}
}
private updateHorizontalIndicator(indicator: HTMLElement, tabElement: HTMLElement): void {
const tabContent = tabElement.querySelector('.tab-content') as HTMLElement;
if (!tabContent) return;
const wrapperRect = indicator.parentElement.getBoundingClientRect();
const contentRect = tabContent.getBoundingClientRect();
const contentLeft = contentRect.left - wrapperRect.left;
const indicatorWidth = contentRect.width + 8;
const indicatorLeft = contentLeft - 4;
indicator.style.width = `${indicatorWidth}px`;
indicator.style.left = `${indicatorLeft}px`;
}
private updateVerticalIndicator(indicator: HTMLElement, tabElement: HTMLElement): void {
const tabsContainer = this.shadowRoot.querySelector('.vertical-wrapper .tabsContainer') as HTMLElement;
if (!tabsContainer) return;
indicator.style.top = `${tabElement.offsetTop + tabsContainer.offsetTop}px`;
indicator.style.height = `${tabElement.clientHeight}px`;
}
}

View File

@@ -35,17 +35,17 @@ export class DeesAppuiView extends DeesElement {
id: 'demo-view',
name: 'Demo View',
description: 'A demonstration view',
iconName: 'home',
iconName: 'lucide:home',
tabs: [
{
key: 'overview',
iconName: 'chart-line',
iconName: 'lucide:lineChart',
action: () => console.log('Overview tab'),
content: html`<div style="padding: 20px;">Overview Content</div>`
},
{
key: 'details',
iconName: 'file-alt',
iconName: 'lucide:fileText',
action: () => console.log('Details tab'),
content: html`<div style="padding: 20px;">Details Content</div>`
}

View File

@@ -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;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.button-group {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.vertical-group {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.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%)')};
}
.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>
<div class="demo-container">
<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;
}
.button-group {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.vertical-group {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.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%)')};
}
.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>
<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>
`;

View File

@@ -1,41 +1,112 @@
import { html } from '@design.estate/dees-element';
import { html, cssManager } from '@design.estate/dees-element';
export const demoFunc = () => html`
<style>
.demoContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
background: #222;
gap: 32px;
padding: 48px;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
min-height: 100vh;
}
.section {
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 8px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.section-description {
font-size: 14px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-bottom: 16px;
}
</style>
<div class="demoContainer">
<dees-chips
selectionMode="none"
.selectableChips=${[
{ key: 'account1', value: 'Payment Account 1' },
{ key: 'account2', value: 'PaymentAccount2' },
{ key: 'account3', value: 'Payment Account 3' },
]}
></dees-chips>
<dees-chips
selectionMode="single"
chipsAreRemovable
.selectableChips=${[
{ key: 'account1', value: 'Payment Account 1' },
{ key: 'account2', value: 'PaymentAccount2' },
{ key: 'account3', value: 'Payment Account 3' },
]}
></dees-chips>
<dees-chips
selectionMode="multiple"
.selectableChips=${[
{ key: 'account1', value: 'Payment Account 1' },
{ key: 'account2', value: 'PaymentAccount2' },
{ key: 'account3', value: 'Payment Account 3' },
]}
></dees-chips>
<div class="section">
<div class="section-title">Non-Selectable Chips</div>
<div class="section-description">Basic chips without selection capability. Use for display-only tags.</div>
<dees-chips
selectionMode="none"
.selectableChips=${[
{ key: 'status', value: 'Active' },
{ key: 'tier', value: 'Premium' },
{ key: 'region', value: 'EU-West' },
{ key: 'type', value: 'Enterprise' },
]}
></dees-chips>
</div>
<div class="section">
<div class="section-title">Single Selection Chips</div>
<div class="section-description">Click to select one chip at a time. Useful for filters and options.</div>
<dees-chips
selectionMode="single"
.selectableChips=${[
{ key: 'all', value: 'All Projects' },
{ key: 'active', value: 'Active' },
{ key: 'archived', value: 'Archived' },
{ key: 'drafts', value: 'Drafts' },
]}
></dees-chips>
</div>
<div class="section">
<div class="section-title">Multiple Selection Chips</div>
<div class="section-description">Select multiple chips simultaneously. Great for tag selection.</div>
<dees-chips
selectionMode="multiple"
.selectableChips=${[
{ key: 'js', value: 'JavaScript' },
{ key: 'ts', value: 'TypeScript' },
{ key: 'react', value: 'React' },
{ key: 'vue', value: 'Vue' },
{ key: 'angular', value: 'Angular' },
{ key: 'node', value: 'Node.js' },
]}
></dees-chips>
</div>
<div class="section">
<div class="section-title">Removable Chips with Keys</div>
<div class="section-description">Chips with remove buttons and key-value pairs. Perfect for dynamic lists.</div>
<dees-chips
selectionMode="single"
chipsAreRemovable
.selectableChips=${[
{ key: 'env', value: 'Production' },
{ key: 'version', value: '2.4.1' },
{ key: 'branch', value: 'main' },
{ key: 'author', value: 'John Doe' },
]}
></dees-chips>
</div>
<div class="section">
<div class="section-title">Mixed Content Example</div>
<div class="section-description">Combining different chip types for complex UIs.</div>
<dees-chips
selectionMode="multiple"
chipsAreRemovable
.selectableChips=${[
{ key: 'priority', value: 'High' },
{ key: 'status', value: 'In Progress' },
{ key: 'bug', value: 'Bug' },
{ key: 'feature', value: 'Feature' },
{ key: 'sprint', value: 'Sprint 23' },
{ key: 'assignee', value: 'Alice' },
]}
></dees-chips>
</div>
</div>
`;

View File

@@ -60,52 +60,93 @@ export class DeesChips extends DeesElement {
.mainbox {
user-select: none;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
border-top: ${cssManager.bdTheme('1px solid #CCC', '1px solid #444')};
background: #333333;
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
display: inline-flex;
height: 20px;
line-height: 20px;
padding: 0px 8px;
font-size: 12px;
color: #fff;
border-radius: 40px;
margin-right: 4px;
margin-bottom: 4px;
align-items: center;
height: 32px;
padding: 0px 12px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
border-radius: 6px;
position: relative;
overflow: hidden;
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: all 0.15s ease;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.chip:hover {
background: #666666;
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
border-color: ${cssManager.bdTheme('#d1d5db', '#52525b')};
}
.chip:active {
transform: scale(0.98);
}
.chip.selected {
background: #00a3ff;
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
color: #ffffff;
}
.chip.selected:hover {
background: ${cssManager.bdTheme('#2563eb', '#2563eb')};
border-color: ${cssManager.bdTheme('#2563eb', '#2563eb')};
}
.chipKey {
background: rgba(0, 0, 0, 0.3);
height: 100%;
display: inline-block;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')};
height: 20px;
line-height: 20px;
display: inline-flex;
align-items: center;
margin-left: -8px;
padding-left: 8px;
padding-right: 8px;
padding: 0px 8px;
margin-right: 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.chip.selected .chipKey {
background: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
}
dees-icon {
padding: 0px 6px 0px 4px;
margin-left: 4px;
margin-right: -8px;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 8px;
margin-right: -6px;
border-radius: 3px;
transition: all 0.15s ease;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.chip.selected dees-icon {
color: rgba(255, 255, 255, 0.8);
}
dees-icon:hover {
background: #e4002b;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
color: ${cssManager.bdTheme('#ef4444', '#ef4444')};
}
.chip.selected dees-icon:hover {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
`,
];
@@ -127,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``}
@@ -139,19 +180,25 @@ export class DeesChips extends DeesElement {
}
public async firstUpdated() {
if (!this.textContent) {
this.textContent = 'Button';
this.performUpdate();
}
// Component initialized
}
private isSelected(chip: Tag): boolean {
if (this.selectionMode === 'single') {
return this.selectedChip?.key === chip.key;
return this.selectedChip ? this.isSameChip(this.selectedChip, chip) : false;
} else {
return this.selectedChips.some((selected) => selected.key === chip.key);
return this.selectedChips.some((selected) => this.isSameChip(selected, chip));
}
}
private isSameChip(chip1: Tag, chip2: Tag): boolean {
// If both have keys, compare by key
if (chip1.key && chip2.key) {
return chip1.key === chip2.key;
}
// Otherwise compare by value (and key if present)
return chip1.value === chip2.value && chip1.key === chip2.key;
}
public async selectChip(chip: Tag) {
if (this.selectionMode === 'none') {
@@ -168,7 +215,7 @@ export class DeesChips extends DeesElement {
}
} else if (this.selectionMode === 'multiple') {
if (this.isSelected(chip)) {
this.selectedChips = this.selectedChips.filter((selected) => selected.key !== chip.key);
this.selectedChips = this.selectedChips.filter((selected) => !this.isSameChip(selected, chip));
} else {
this.selectedChips = [...this.selectedChips, chip];
}
@@ -179,13 +226,13 @@ export class DeesChips extends DeesElement {
public removeChip(chipToRemove: Tag): void {
// Remove the chip from selectableChips
this.selectableChips = this.selectableChips.filter((chip) => chip.key !== chipToRemove.key);
this.selectableChips = this.selectableChips.filter((chip) => !this.isSameChip(chip, chipToRemove));
// Remove the chip from selectedChips if present
this.selectedChips = this.selectedChips.filter((chip) => chip.key !== chipToRemove.key);
this.selectedChips = this.selectedChips.filter((chip) => !this.isSameChip(chip, chipToRemove));
// If the removed chip was the selectedChip, set selectedChip to null
if (this.selectedChip && this.selectedChip.key === chipToRemove.key) {
if (this.selectedChip && this.isSameChip(this.selectedChip, chipToRemove)) {
this.selectedChip = null;
}

View 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>
`;
};

View 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();
}
}

View File

@@ -1,18 +1,199 @@
import { html } from '@design.estate/dees-element';
import { html, cssManager } from '@design.estate/dees-element';
export const demoFunc = () => html` <style>
.demoWrapper {
box-sizing: border-box;
position: absolute;
width: 100%;
height: 100%;
padding: 20px;
background: none;
export const demoFunc = () => html`
<style>
.demoWrapper {
box-sizing: border-box;
position: relative;
width: 100%;
min-height: 100vh;
padding: 48px;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
display: flex;
flex-direction: column;
gap: 32px;
}
.section {
max-width: 900px;
width: 100%;
margin: 0 auto;
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.section-description {
font-size: 14px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-bottom: 16px;
}
</style>
<div class="demoWrapper">
<div class="section">
<div class="section-title">TypeScript Code Example</div>
<div class="section-description">A comprehensive TypeScript code example with various syntax highlighting.</div>
<dees-dataview-codebox proglang="typescript">
interface User {
id: number;
name: string;
email: string;
isActive: boolean;
}
</style>
<div class="demoWrapper">
<dees-dataview-codebox proglang="typescript">
import * as text from './hello'; const hiThere = 'nice'; const myFunction = async () => {
console.log('nice one'); }
</dees-dataview-codebox>
</div>`
class UserService {
private users: User[] = [];
constructor(private apiUrl: string) {
console.log('UserService initialized');
}
async getUsers(): Promise<User[]> {
try {
const response = await fetch(this.apiUrl);
const data = await response.json();
return data.users;
} catch (error) {
console.error('Failed to fetch users:', error);
return [];
}
}
addUser(user: User): void {
this.users.push(user);
}
}
// Usage example
const service = new UserService('https://api.example.com/users');
const users = await service.getUsers();
console.log('Found users:', users.length);
</dees-dataview-codebox>
</div>
<div class="section">
<div class="section-title">JavaScript Example</div>
<div class="section-description">Modern JavaScript with ES6+ features.</div>
<dees-dataview-codebox proglang="javascript">
// Array manipulation examples
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2);
const filtered = numbers.filter(n => n > 3);
// Object destructuring
const user = { name: 'John', age: 30, city: 'New York' };
const { name, age } = user;
// Promise handling
const fetchData = async (url) => {
const response = await fetch(url);
return response.json();
};
// Modern syntax
const greet = (name = 'World') => \`Hello, \${name}!\`;
console.log(greet('ShadCN'));
</dees-dataview-codebox>
</div>
<div class="section">
<div class="section-title">Python Example</div>
<div class="section-description">Python code with classes and type hints.</div>
<dees-dataview-codebox proglang="python">
from typing import List, Optional
import asyncio
class DataProcessor:
"""A simple data processor class"""
def __init__(self, name: str):
self.name = name
self.data: List[dict] = []
async def process_data(self, items: List[dict]) -> List[dict]:
"""Process data items asynchronously"""
results = []
for item in items:
# Simulate async processing
await asyncio.sleep(0.1)
results.append({
'id': item.get('id'),
'processed': True,
'processor': self.name
})
return results
def get_summary(self) -> dict:
return {
'processor': self.name,
'items_processed': len(self.data)
}
# Usage
processor = DataProcessor("Main")
data = await processor.process_data([{'id': 1}, {'id': 2}])
</dees-dataview-codebox>
</div>
<div class="section">
<div class="section-title">CSS Example</div>
<div class="section-description">Modern CSS with custom properties and animations. Note the shorter language label.</div>
<dees-dataview-codebox proglang="css">
/* Modern CSS with custom properties */
:root {
--primary-color: #3b82f6;
--secondary-color: #10b981;
--background: #ffffff;
--text-color: #09090b;
--border-radius: 6px;
}
.card {
background: var(--background);
border: 1px solid #e5e7eb;
border-radius: var(--border-radius);
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</dees-dataview-codebox>
</div>
<div class="section">
<div class="section-title">JSON Example</div>
<div class="section-description">JSON configuration with proper formatting.</div>
<dees-dataview-codebox proglang="json">
{
"name": "@design.estate/dees-catalog",
"version": "1.10.7",
"description": "A comprehensive catalog of web components",
"main": "dist_ts_web/index.js",
"type": "module",
"scripts": {
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
"watch": "tswatch element",
"test": "tstest test/ --web --verbose"
},
"dependencies": {
"@design.estate/dees-element": "^2.0.45",
"highlight.js": "^11.9.0"
}
}
</dees-dataview-codebox>
</div>
</div>
`

View File

@@ -53,23 +53,23 @@ export class DeesDataviewCodebox extends DeesElement {
}
.mainbox {
position: relative;
color: ${this.goBright ? '#333333' : '#ffffff'};
border-top: 1px solid ${this.goBright ? '#ffffff' : '#333333'};
box-shadow: 0px 0px 5px ${this.goBright ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.5)'};
background: ${this.goBright ? '#ffffff' : '#191919'};
border-radius: 16px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border-radius: 6px;
overflow: hidden;
}
.appbar {
position: relative;
color: ${cssManager.bdTheme('#333', '#ccc')};
background: ${cssManager.bdTheme('#ffffff', '#161616')};
border-bottom: 1px solid ${cssManager.bdTheme('#eeeeeb', '#222222')};
height: 24px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
height: 32px;
display: flex;
font-size: 12px;
line-height: 24px;
font-size: 13px;
line-height: 32px;
justify-content: center;
align-items: center;
}
@@ -82,31 +82,38 @@ export class DeesDataviewCodebox extends DeesElement {
}
.bottomBar {
color: ${cssManager.bdTheme('#333', '#ccc')};
background: ${cssManager.bdTheme('#ffffff', '#161616')};
border-top: 1px solid ${cssManager.bdTheme('#eeeeeb', '#222222')};
height: 24px;
position: relative;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
border-top: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
height: 28px;
font-size: 12px;
line-height: 24px;
text-align: right;
padding-right: 100px;
line-height: 28px;
display: flex;
justify-content: flex-end;
align-items: stretch;
overflow: hidden;
}
.spacesLabel {
padding: 0 16px;
display: flex;
align-items: center;
}
.languageLabel {
color: ${cssManager.bdTheme('#333', '#ccc')};
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
font-size: 12px;
line-height: 24px;
z-index: 10;
background: #6596ff20;
display: inline-block;
position: absolute;
bottom: 0px;
right: 0px;
padding: 0px 16px 0px 8px;
line-height: 28px;
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
padding: 0px 16px;
font-weight: 500;
display: flex;
align-items: center;
}
.hljs-keyword {
color: #ff65ec;
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.codegrid {
@@ -116,10 +123,10 @@ export class DeesDataviewCodebox extends DeesElement {
}
.lineNumbers {
color: ${this.goBright ? '#acacac' : '#666666'};
padding: 30px 16px 0px 0px;
color: ${cssManager.bdTheme('#71717a', '#52525b')};
padding: 24px 16px 0px 0px;
text-align: right;
border-right: 1px solid ${this.goBright ? '#eaeaea' : '#222222'};
border-right: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
}
.lineCounter:last-child {
@@ -129,11 +136,11 @@ export class DeesDataviewCodebox extends DeesElement {
pre {
overflow-x: auto;
margin: 0px;
padding: 30px 40px;
padding: 24px 24px;
}
code {
font-weight: ${this.goBright ? '400' : '300'};
font-weight: 400;
padding: 0px;
margin: 0px;
}
@@ -147,23 +154,39 @@ export class DeesDataviewCodebox extends DeesElement {
}
.hljs-string {
color: #ffa465;
color: ${cssManager.bdTheme('#059669', '#10b981')};
}
.hljs-built_in {
color: #65ff6a;
color: ${cssManager.bdTheme('#8b5cf6', '#a78bfa')};
}
.hljs-function {
color: ${this.goBright ? '#2765DF' : '#6596ff'};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
.hljs-params {
color: ${this.goBright ? '#3DB420' : '#65d5ff'};
color: ${cssManager.bdTheme('#0891b2', '#06b6d4')};
}
.hljs-comment {
color: ${this.goBright ? '#EF9300' : '#ffd765'};
color: ${cssManager.bdTheme('#71717a', '#71717a')};
}
.hljs-number {
color: ${cssManager.bdTheme('#ea580c', '#fb923c')};
}
.hljs-literal {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.hljs-attr {
color: ${cssManager.bdTheme('#8b5cf6', '#a78bfa')};
}
.hljs-variable {
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
</style>
<div
@@ -198,7 +221,7 @@ export class DeesDataviewCodebox extends DeesElement {
<pre><code></code></pre>
</div>
<div class="bottomBar">
Spaces: 2
<div class="spacesLabel">Spaces: 2</div>
<div class="languageLabel">${this.progLang}</div>
</div>
</div>

View File

@@ -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');
},

View File

@@ -57,9 +57,10 @@ export class DeesFormSubmit extends DeesElement {
if (this.disabled) {
return;
}
const parentElement: DeesForm = this.parentElement as DeesForm;
if (parentElement && parentElement.gatherAndDispatch) {
parentElement.gatherAndDispatch();
// Walk up the DOM tree to find the nearest dees-form element
const parentFormElement = this.closest('dees-form') as DeesForm;
if (parentFormElement && parentFormElement.gatherAndDispatch) {
parentFormElement.gatherAndDispatch();
}
}

View File

@@ -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:last-child {
margin-bottom: 0;
}
.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>';
});
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
`}
</style>
<div class="demo-container">
// 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>
<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>
`;

View File

@@ -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';
@@ -19,12 +20,13 @@ import { DeesInputMultitoggle } from './dees-input-multitoggle.js';
import { DeesInputPhone } from './dees-input-phone.js';
import { DeesInputTypelist } from './dees-input-typelist.js';
import { DeesFormSubmit } from './dees-form-submit.js';
import { DeesTable } from './dees-table.js';
import { DeesTable } from './dees-table/dees-table.js';
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

View File

@@ -40,6 +40,26 @@ export const demoFunc = () => {
}
// Define the functions in TS scope instead of script tags
const copyAllIconNames = () => {
// Generate complete list of all icon names with prefixes
const faIconsList = faIcons.map(name => `fa:${name}`);
const lucideIconsListPrefixed = lucideIconsList.map(name => `lucide:${name}`);
const allIcons = [...faIconsList, ...lucideIconsListPrefixed];
const textToCopy = allIcons.join('\n');
navigator.clipboard.writeText(textToCopy).then(() => {
// Show feedback
const currentEvent = window.event as MouseEvent;
const button = currentEvent.currentTarget as HTMLElement;
const originalText = button.textContent;
button.textContent = `✓ Copied ${allIcons.length} icon names!`;
setTimeout(() => {
button.textContent = originalText;
}, 2000);
});
};
const searchIcons = (event: InputEvent) => {
const searchTerm = (event.target as HTMLInputElement).value.toLowerCase().trim();
// Get the demo container first, then search within it
@@ -111,6 +131,7 @@ export const demoFunc = () => {
width: 100%;
margin-bottom: 20px;
display: flex;
gap: 10px;
}
#iconSearch {
@@ -129,6 +150,27 @@ export const demoFunc = () => {
border-color: #e4002b;
}
.copy-all-button {
padding: 12px 20px;
font-size: 16px;
border: none;
border-radius: 4px;
background: #e4002b;
color: #fff;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.copy-all-button:hover {
background: #c4001b;
transform: translateY(-1px);
}
.copy-all-button:active {
transform: translateY(0);
}
dees-icon {
transition: all 0.2s ease;
color: #ffffff;
@@ -239,6 +281,7 @@ export const demoFunc = () => {
<div class="demoContainer">
<div class="search-container">
<input type="text" id="iconSearch" placeholder="Search icons..." @input=${searchIcons}>
<button class="copy-all-button" @click=${copyAllIconNames}>📋 Copy All Icon Names</button>
</div>
<div class="api-note">
@@ -258,7 +301,7 @@ export const demoFunc = () => {
return html`
<div class="iconContainer fa-icon" data-name=${iconName.toLowerCase()} @click=${() => copyIconName(iconName, 'fa')}>
<dees-icon .icon=${prefixedName as IconWithPrefix} iconSize="24"></dees-icon>
<div class="iconName">${iconName}</div>
<div class="iconName">fa:${iconName}</div>
<span class="copy-tooltip">Click to copy</span>
</div>
`;
@@ -279,7 +322,7 @@ export const demoFunc = () => {
return html`
<div class="iconContainer lucide-icon" data-name=${iconName.toLowerCase()} @click=${() => copyIconName(iconName, 'lucide')}>
<dees-icon .icon=${prefixedName as IconWithPrefix} iconSize="24"></dees-icon>
<div class="iconName">${iconName}</div>
<div class="iconName">lucide:${iconName}</div>
<span class="copy-tooltip">Click to copy</span>
</div>
`;

View File

@@ -28,6 +28,9 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
})
public value: boolean = false;
@property({ type: Boolean })
public indeterminate: boolean = false;
constructor() {
super();
@@ -166,7 +169,15 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
</svg>
</span>
`
: html``}
: this.indeterminate
? html`
<span class="checkmark">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
`
: html``}
</div>
<div class="label-container">
${this.label ? html`<div class="checkbox-label">${this.label}</div>` : ''}

View 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>
`;

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.spacer {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
}
`}
</style>
<div class="demo-container">
<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;
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.spacer {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
}
`}
</style>
<div class="demo-container">
<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>
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
// This dropdown demonstrates automatic positioning
const dropdown = elementArg.querySelector('dees-input-dropdown');
<div class="spacer">
(Spacer to test dropdown positioning)
</div>
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>
`

View File

@@ -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 = '';

View File

@@ -0,0 +1,275 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
import './dees-form.js';
import './dees-input-text.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;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.grid-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 768px) {
.grid-layout {
grid-template-columns: 1fr;
}
}
.output-preview {
margin-top: 16px;
padding: 16px;
background: #f3f4f6;
border-radius: 4px;
font-size: 12px;
color: #374151;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
@media (prefers-color-scheme: dark) {
.output-preview {
background: #2c2c2c;
color: #e4e4e7;
}
}
.feature-note {
margin-top: 12px;
padding: 12px;
background: #eff6ff;
border-left: 3px solid #3b82f6;
border-radius: 4px;
font-size: 13px;
color: #1e40af;
}
@media (prefers-color-scheme: dark) {
.feature-note {
background: #1e3a5f;
color: #93c5fd;
}
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'1. Basic List Input'} .subtitle=${'Simple list management with add, edit, and delete'}>
<dees-input-list
.label=${'Shopping List'}
.placeholder=${'Add item to your list...'}
.value=${['Milk', 'Bread', 'Eggs', 'Cheese']}
.description=${'Double-click to edit items, or use the edit button'}
></dees-input-list>
<div class="feature-note">
💡 Double-click any item to quickly edit it inline
</div>
</dees-panel>
<dees-panel .title=${'2. Sortable List'} .subtitle=${'Drag and drop to reorder items'}>
<dees-input-list
.label=${'Task Priority'}
.placeholder=${'Add a task...'}
.sortable=${true}
.value=${[
'Review pull requests',
'Fix critical bug',
'Update documentation',
'Deploy to production',
'Team standup meeting'
]}
.description=${'Drag items using the handle to reorder them'}
></dees-input-list>
<div class="feature-note">
🔄 Drag the grip handle to reorder tasks by priority
</div>
</dees-panel>
<dees-panel .title=${'3. Validation & Constraints'} .subtitle=${'Lists with minimum/maximum items and duplicate prevention'}>
<div class="grid-layout">
<dees-input-list
.label=${'Team Members (Min 2, Max 5)'}
.placeholder=${'Add team member...'}
.minItems=${2}
.maxItems=${5}
.value=${['Alice', 'Bob']}
.required=${true}
.description=${'Add 2-5 team members'}
></dees-input-list>
<dees-input-list
.label=${'Unique Tags (No Duplicates)'}
.placeholder=${'Add unique tag...'}
.allowDuplicates=${false}
.value=${['frontend', 'backend', 'database']}
.description=${'Duplicate items are not allowed'}
></dees-input-list>
</div>
</dees-panel>
<dees-panel .title=${'4. Delete Confirmation'} .subtitle=${'Require confirmation before deleting items'}>
<dees-input-list
.label=${'Important Documents'}
.placeholder=${'Add document name...'}
.confirmDelete=${true}
.value=${[
'Contract_2024.pdf',
'Financial_Report_Q3.xlsx',
'Project_Proposal.docx',
'Meeting_Notes.txt'
]}
.description=${'Deletion requires confirmation for safety'}
></dees-input-list>
</dees-panel>
<dees-panel .title=${'5. Disabled State'} .subtitle=${'Read-only list display'}>
<dees-input-list
.label=${'System Defaults'}
.value=${['Default Setting 1', 'Default Setting 2', 'Default Setting 3']}
.disabled=${true}
.description=${'These items cannot be modified'}
></dees-input-list>
</dees-panel>
<dees-panel .title=${'6. Form Integration'} .subtitle=${'List input working within a form context'}>
<dees-form>
<dees-input-text
.label=${'Recipe Name'}
.placeholder=${'My Amazing Recipe'}
.required=${true}
.key=${'name'}
></dees-input-text>
<div class="grid-layout">
<dees-input-list
.label=${'Ingredients'}
.placeholder=${'Add ingredient...'}
.required=${true}
.minItems=${3}
.key=${'ingredients'}
.sortable=${true}
.value=${[
'2 cups flour',
'1 cup sugar',
'3 eggs'
]}
.description=${'Add at least 3 ingredients'}
></dees-input-list>
<dees-input-list
.label=${'Instructions'}
.placeholder=${'Add instruction step...'}
.required=${true}
.minItems=${2}
.key=${'instructions'}
.sortable=${true}
.value=${[
'Preheat oven to 350°F',
'Mix dry ingredients'
]}
.description=${'Add cooking instructions in order'}
></dees-input-list>
</div>
<dees-input-text
.label=${'Notes'}
.inputType=${'textarea'}
.placeholder=${'Any special notes or tips...'}
.key=${'notes'}
></dees-input-text>
<dees-form-submit .text=${'Save Recipe'}></dees-form-submit>
</dees-form>
</dees-panel>
<dees-panel .title=${'7. Interactive Demo'} .subtitle=${'Build your own feature list and see the data'}>
<dees-input-list
id="interactive-list"
.label=${'Product Features'}
.placeholder=${'Add a feature...'}
.sortable=${true}
.confirmDelete=${false}
.allowDuplicates=${false}
.maxItems=${10}
@change=${(e: CustomEvent) => {
const preview = document.querySelector('#list-json');
if (preview) {
const data = {
items: e.detail.value,
count: e.detail.value.length,
timestamp: new Date().toISOString()
};
preview.textContent = JSON.stringify(data, null, 2);
}
}}
></dees-input-list>
<div class="output-preview" id="list-json">
{
"items": [],
"count": 0,
"timestamp": "${new Date().toISOString()}"
}
</div>
<div class="feature-note">
✨ Add, edit, remove, and reorder items to see the JSON output update in real-time
</div>
</dees-panel>
<dees-panel .title=${'8. Advanced Configuration'} .subtitle=${'Combine all features for complex use cases'}>
<dees-input-list
.label=${'Project Milestones'}
.placeholder=${'Add milestone...'}
.value=${[
'Project Kickoff - Week 1',
'Requirements Gathering - Week 2-3',
'Design Phase - Week 4-6',
'Development Sprint 1 - Week 7-9',
'Testing & QA - Week 10-11',
'Deployment - Week 12'
]}
.sortable=${true}
.confirmDelete=${true}
.allowDuplicates=${false}
.minItems=${3}
.maxItems=${12}
.required=${true}
.description=${'Manage project milestones (3-12 items, sortable, no duplicates)'}
></dees-input-list>
</dees-panel>
<dees-panel .title=${'9. Empty State'} .subtitle=${'How the component looks with no items'}>
<dees-input-list
.label=${'Your Ideas'}
.placeholder=${'Share your ideas...'}
.value=${[]}
.description=${'Start adding items to build your list'}
></dees-input-list>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@@ -0,0 +1,622 @@
import {
customElement,
html,
css,
cssManager,
property,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { DeesInputBase } from './dees-input-base.js';
import './dees-icon.js';
import './dees-button.js';
import { demoFunc } from './dees-input-list.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-input-list': DeesInputList;
}
}
@customElement('dees-input-list')
export class DeesInputList extends DeesInputBase<DeesInputList> {
// STATIC
public static demo = demoFunc;
// INSTANCE
@property({ type: Array })
public value: string[] = [];
@property({ type: String })
public placeholder: string = 'Add new item...';
@property({ type: Number })
public maxItems: number = 0; // 0 means unlimited
@property({ type: Number })
public minItems: number = 0;
@property({ type: Boolean })
public allowDuplicates: boolean = false;
@property({ type: Boolean })
public sortable: boolean = false;
@property({ type: Boolean })
public confirmDelete: boolean = false;
@property({ type: String })
public validationText: string = '';
@state()
private inputValue: string = '';
@state()
private editingIndex: number = -1;
@state()
private editingValue: string = '';
@state()
private draggedIndex: number = -1;
@state()
private dragOverIndex: number = -1;
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
}
.input-wrapper {
width: 100%;
}
.list-container {
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
}
.list-container:hover:not(.disabled) {
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.list-container:focus-within {
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
}
.list-container.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.list-items {
max-height: 400px;
overflow-y: auto;
}
.list-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
transition: all 0.15s ease;
position: relative;
overflow: hidden; /* Prevent animation from affecting scroll bounds */
}
.list-item:last-of-type {
border-bottom: none;
}
.list-item:hover:not(.disabled) {
background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')};
}
.list-item.dragging {
opacity: 0.4;
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 10.8%)')};
}
.list-item.drag-over {
background: ${cssManager.bdTheme('hsl(210 40% 93.1%)', 'hsl(215 20.2% 13.8%)')};
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
}
.drag-handle {
display: flex;
align-items: center;
cursor: move;
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
transition: color 0.15s ease;
}
.drag-handle:hover {
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
}
.drag-handle dees-icon {
width: 16px;
height: 16px;
}
.item-content {
flex: 1;
display: flex;
align-items: center;
min-width: 0;
}
.item-text {
flex: 1;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
font-size: 14px;
line-height: 20px;
word-break: break-word;
}
.item-edit-input {
flex: 1;
padding: 4px 8px;
font-size: 14px;
font-family: inherit;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
border-radius: 4px;
outline: none;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.item-actions {
display: flex;
gap: 4px;
align-items: center;
}
.action-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
}
.action-button:hover {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.action-button.save {
color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
}
.action-button.save:hover {
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')};
}
.action-button.cancel {
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 50.6%)')};
}
.action-button.cancel:hover {
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 50.6% / 0.1)')};
}
.action-button.delete {
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 50.6%)')};
}
.action-button.delete:hover {
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 50.6% / 0.1)')};
}
.action-button dees-icon {
width: 14px;
height: 14px;
}
.add-item-container {
display: flex;
gap: 8px;
padding: 12px 16px;
background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')};
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.add-input {
flex: 1;
padding: 8px 12px;
font-size: 14px;
font-family: inherit;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 4px;
outline: none;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
transition: all 0.15s ease;
}
.add-input:focus {
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
}
.add-input::placeholder {
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
}
.add-input:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.add-button {
padding: 8px 16px;
}
.empty-state {
padding: 32px 16px;
text-align: center;
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
font-size: 14px;
font-style: italic;
}
.validation-message {
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
font-size: 13px;
margin-top: 6px;
line-height: 1.5;
}
.description {
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
font-size: 13px;
margin-top: 6px;
line-height: 1.5;
}
/* Scrollbar styling */
.list-items::-webkit-scrollbar {
width: 8px;
}
.list-items::-webkit-scrollbar-track {
background: transparent;
}
.list-items::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 24.9%)')};
border-radius: 4px;
}
.list-items::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 34.9%)')};
}
/* Animation for adding/removing items */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.list-item {
animation: slideIn 0.2s ease;
}
/* Override any inherited contain/content-visibility that might cause scrolling issues */
.list-items, .list-item {
content-visibility: visible !important;
contain: none !important;
contain-intrinsic-size: auto !important;
}
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
${this.label ? html`<dees-label .label=${this.label} .required=${this.required}></dees-label>` : ''}
<div class="list-container ${this.disabled ? 'disabled' : ''}">
<div class="list-items">
${this.value.length > 0 ? this.value.map((item, index) => html`
<div
class="list-item ${this.draggedIndex === index ? 'dragging' : ''} ${this.dragOverIndex === index ? 'drag-over' : ''}"
draggable="${this.sortable && !this.disabled}"
@dragstart=${(e: DragEvent) => this.handleDragStart(e, index)}
@dragend=${this.handleDragEnd}
@dragover=${(e: DragEvent) => this.handleDragOver(e, index)}
@dragleave=${this.handleDragLeave}
@drop=${(e: DragEvent) => this.handleDrop(e, index)}
>
${this.sortable && !this.disabled ? html`
<div class="drag-handle">
<dees-icon .icon=${'lucide:gripVertical'}></dees-icon>
</div>
` : ''}
<div class="item-content">
${this.editingIndex === index ? html`
<input
type="text"
class="item-edit-input"
.value=${this.editingValue}
@input=${(e: InputEvent) => this.editingValue = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => this.handleEditKeyDown(e, index)}
@blur=${() => this.saveEdit(index)}
/>
` : html`
<div class="item-text" @dblclick=${() => !this.disabled && this.startEdit(index)}>
${item}
</div>
`}
</div>
<div class="item-actions">
${this.editingIndex === index ? html`
<button class="action-button save" @click=${() => this.saveEdit(index)}>
<dees-icon .icon=${'lucide:check'}></dees-icon>
</button>
<button class="action-button cancel" @click=${() => this.cancelEdit()}>
<dees-icon .icon=${'lucide:x'}></dees-icon>
</button>
` : html`
${!this.disabled ? html`
<button class="action-button" @click=${() => this.startEdit(index)}>
<dees-icon .icon=${'lucide:pencil'}></dees-icon>
</button>
<button class="action-button delete" @click=${() => this.removeItem(index)}>
<dees-icon .icon=${'lucide:trash2'}></dees-icon>
</button>
` : ''}
`}
</div>
</div>
`) : html`
<div class="empty-state">
No items added yet
</div>
`}
</div>
${!this.disabled && (!this.maxItems || this.value.length < this.maxItems) ? html`
<div class="add-item-container">
<input
type="text"
class="add-input"
.placeholder=${this.placeholder}
.value=${this.inputValue}
@input=${this.handleInput}
@keydown=${this.handleAddKeyDown}
?disabled=${this.disabled}
/>
<dees-button
class="add-button"
@click=${this.addItem}
?disabled=${!this.inputValue.trim()}
>
<dees-icon .icon=${'lucide:plus'}></dees-icon> Add
</dees-button>
</div>
` : ''}
</div>
${this.validationText ? html`
<div class="validation-message">${this.validationText}</div>
` : ''}
${this.description ? html`
<div class="description">${this.description}</div>
` : ''}
</div>
`;
}
private handleInput(e: InputEvent) {
this.inputValue = (e.target as HTMLInputElement).value;
}
private handleAddKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && this.inputValue.trim()) {
e.preventDefault();
this.addItem();
}
}
private handleEditKeyDown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
this.saveEdit(index);
} else if (e.key === 'Escape') {
e.preventDefault();
this.cancelEdit();
}
}
private addItem() {
const trimmedValue = this.inputValue.trim();
if (!trimmedValue) return;
if (!this.allowDuplicates && this.value.includes(trimmedValue)) {
this.validationText = 'This item already exists in the list';
setTimeout(() => this.validationText = '', 3000);
return;
}
if (this.maxItems && this.value.length >= this.maxItems) {
this.validationText = `Maximum ${this.maxItems} items allowed`;
setTimeout(() => this.validationText = '', 3000);
return;
}
this.value = [...this.value, trimmedValue];
this.inputValue = '';
this.validationText = '';
// Clear the input
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
if (input) {
input.value = '';
input.focus();
}
this.emitChange();
}
private startEdit(index: number) {
this.editingIndex = index;
this.editingValue = this.value[index];
// Focus the input after render
this.updateComplete.then(() => {
const input = this.shadowRoot?.querySelector('.item-edit-input') as HTMLInputElement;
if (input) {
input.focus();
input.select();
}
});
}
private saveEdit(index: number) {
const trimmedValue = this.editingValue.trim();
if (!trimmedValue) {
this.cancelEdit();
return;
}
if (!this.allowDuplicates && trimmedValue !== this.value[index] && this.value.includes(trimmedValue)) {
this.validationText = 'This item already exists in the list';
setTimeout(() => this.validationText = '', 3000);
return;
}
const newValue = [...this.value];
newValue[index] = trimmedValue;
this.value = newValue;
this.editingIndex = -1;
this.editingValue = '';
this.validationText = '';
this.emitChange();
}
private cancelEdit() {
this.editingIndex = -1;
this.editingValue = '';
}
private async removeItem(index: number) {
if (this.confirmDelete) {
const confirmed = await this.showConfirmDialog(`Delete "${this.value[index]}"?`);
if (!confirmed) return;
}
this.value = this.value.filter((_, i) => i !== index);
this.emitChange();
}
private async showConfirmDialog(message: string): Promise<boolean> {
// For now, use native confirm. In production, this should use a proper modal
return confirm(message);
}
// Drag and drop handlers
private handleDragStart(e: DragEvent, index: number) {
if (!this.sortable || this.disabled) return;
this.draggedIndex = index;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', index.toString());
}
private handleDragEnd() {
this.draggedIndex = -1;
this.dragOverIndex = -1;
}
private handleDragOver(e: DragEvent, index: number) {
if (!this.sortable || this.disabled) return;
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
this.dragOverIndex = index;
}
private handleDragLeave() {
this.dragOverIndex = -1;
}
private handleDrop(e: DragEvent, dropIndex: number) {
if (!this.sortable || this.disabled) return;
e.preventDefault();
const draggedIndex = parseInt(e.dataTransfer!.getData('text/plain'));
if (draggedIndex !== dropIndex) {
const newValue = [...this.value];
const [draggedItem] = newValue.splice(draggedIndex, 1);
newValue.splice(dropIndex, 0, draggedItem);
this.value = newValue;
this.emitChange();
}
this.draggedIndex = -1;
this.dragOverIndex = -1;
}
private emitChange() {
this.dispatchEvent(new CustomEvent('change', {
detail: { value: this.value },
bubbles: true,
composed: true
}));
this.changeSubject.next(this);
}
public getValue(): string[] {
return this.value;
}
public setValue(value: string[]): void {
this.value = value || [];
}
public async validate(): Promise<boolean> {
if (this.required && (!this.value || this.value.length === 0)) {
this.validationText = 'At least one item is required';
return false;
}
if (this.minItems && this.value.length < this.minItems) {
this.validationText = `At least ${this.minItems} items required`;
return false;
}
this.validationText = '';
return true;
}
}

View File

@@ -1,4 +1,4 @@
import { html, css } from '@design.estate/dees-element';
import { html, css, cssManager } from '@design.estate/dees-element';
export const demoFunc = () => html`
<dees-demowrapper>
@@ -7,10 +7,31 @@ export const demoFunc = () => html`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
gap: 32px;
padding: 48px;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
min-height: 100vh;
}
.section {
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 8px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.section-description {
font-size: 14px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-bottom: 24px;
}
.settings-grid {
@@ -28,7 +49,10 @@ export const demoFunc = () => html`
</style>
<div class="demo-container">
<dees-panel .title=${'Multi-Option Toggle'} .subtitle=${'Select from multiple options with a sliding indicator'}>
<div class="section">
<div class="section-title">Multi-Option Toggle</div>
<div class="section-description">Select from multiple options with a smooth sliding indicator animation.</div>
<dees-input-multitoggle
.label=${'Display Mode'}
.description=${'Choose how content is displayed'}
@@ -36,15 +60,20 @@ export const demoFunc = () => html`
.selectedOption=${'Grid View'}
></dees-input-multitoggle>
<br><br>
<dees-input-multitoggle
.label=${'T-Shirt Size'}
.description=${'Select your preferred size'}
.options=${['XS', 'S', 'M', 'L', 'XL', 'XXL']}
.selectedOption=${'M'}
></dees-input-multitoggle>
</dees-panel>
</div>
<dees-panel .title=${'Boolean Toggle'} .subtitle=${'Simple on/off switches with custom labels'}>
<div class="section">
<div class="section-title">Boolean Toggle</div>
<div class="section-description">Simple on/off switches with customizable labels for clearer context.</div>
<dees-input-multitoggle
.label=${'Notifications'}
.description=${'Enable or disable push notifications'}
@@ -52,6 +81,8 @@ export const demoFunc = () => html`
.selectedOption=${'true'}
></dees-input-multitoggle>
<br><br>
<dees-input-multitoggle
.label=${'Theme Mode'}
.description=${'Switch between light and dark theme'}
@@ -60,13 +91,15 @@ export const demoFunc = () => html`
.booleanFalseName=${'Light'}
.selectedOption=${'Dark'}
></dees-input-multitoggle>
</dees-panel>
</div>
<dees-panel .title=${'Settings Panel'} .subtitle=${'Configuration options in a horizontal layout'}>
<div class="section">
<div class="section-title">Settings Grid</div>
<div class="section-description">Configuration options arranged in a responsive grid layout.</div>
<div class="settings-grid">
<dees-input-multitoggle
.label=${'Auto-Save'}
.layoutMode=${'horizontal'}
.type=${'boolean'}
.booleanTrueName=${'Enabled'}
.booleanFalseName=${'Disabled'}
@@ -75,30 +108,30 @@ export const demoFunc = () => html`
<dees-input-multitoggle
.label=${'Language'}
.layoutMode=${'horizontal'}
.options=${['English', 'German', 'French', 'Spanish']}
.selectedOption=${'English'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'Quality'}
.layoutMode=${'horizontal'}
.options=${['Low', 'Medium', 'High', 'Ultra']}
.selectedOption=${'High'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'Privacy'}
.layoutMode=${'horizontal'}
.type=${'boolean'}
.booleanTrueName=${'Private'}
.booleanFalseName=${'Public'}
.selectedOption=${'Private'}
></dees-input-multitoggle>
</div>
</dees-panel>
</div>
<dees-panel .title=${'States & Form Integration'} .subtitle=${'Disabled states and form usage'}>
<div class="section">
<div class="section-title">States & Form Integration</div>
<div class="section-description">Examples of disabled states and integration within forms.</div>
<dees-input-multitoggle
.label=${'Account Type'}
.description=${'This setting is locked'}
@@ -107,6 +140,8 @@ export const demoFunc = () => html`
.disabled=${true}
></dees-input-multitoggle>
<br><br>
<dees-form>
<dees-input-text .label=${'Project Name'} .required=${true}></dees-input-text>
<dees-input-multitoggle
@@ -122,7 +157,7 @@ export const demoFunc = () => html`
.selectedOption=${'MIT'}
></dees-input-multitoggle>
</dees-form>
</dees-panel>
</div>
</div>
</dees-demowrapper>
`;

View File

@@ -57,9 +57,12 @@ export class DeesInputMultitoggle extends DeesInputBase<DeesInputMultitoggle> {
} else {
this.selectedOption = val as string;
}
this.requestUpdate();
// Defer indicator update to next frame if component not yet updated
if (this.hasUpdated) {
this.setIndicator();
requestAnimationFrame(() => {
this.setIndicator();
});
}
}
@@ -68,59 +71,71 @@ export class DeesInputMultitoggle extends DeesInputBase<DeesInputMultitoggle> {
cssManager.defaultStyles,
css`
:host {
color: ${cssManager.bdTheme('#333', '#ccc')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
user-select: none;
}
.selections {
position: relative;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
background: ${cssManager.bdTheme('#fff', '#222')};
width: min-content;
border-radius: 20px;
height: 32px;
border-top: 1px solid ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.1)')};
display: inline-flex;
align-items: center;
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
padding: 4px;
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.option {
color: ${cssManager.bdTheme('#666', '#999')};
position: relative;
padding: 0px 16px;
line-height: 32px;
cursor: default;
width: min-content; /* Make the width as per the content */
white-space: nowrap; /* Prevent text wrapping */
transition: all 0.1s;
padding: 8px 20px;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
transition: color 0.2s ease;
font-size: 14px;
transform: translateY(-1px);
font-weight: 500;
color: ${cssManager.bdTheme('#71717a', '#71717a')};
line-height: 1;
z-index: 2;
}
.option:hover {
color: ${cssManager.bdTheme('#333', '#fff')};
color: ${cssManager.bdTheme('#18181b', '#e4e4e7')};
}
.option.selected {
color: ${cssManager.bdTheme('#fff', '#fff')};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
.indicator {
opacity: 0;
position: absolute;
height: 24px;
left: 4px;
top: 3px;
border-radius: 16px;
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
min-width: 24px;
transition: all 0.1s ease-in-out;
height: calc(100% - 8px);
top: 4px;
border-radius: 6px;
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.15)', 'rgba(59, 130, 246, 0.15)')};
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
}
.indicator.no-transition {
transition: none;
}
:host([disabled]) .selections {
opacity: 0.5;
cursor: not-allowed;
}
:host([disabled]) .option {
cursor: not-allowed;
pointer-events: none;
}
:host([disabled]) .indicator {
background: ${cssManager.bdTheme('rgba(113, 113, 122, 0.15)', 'rgba(113, 113, 122, 0.15)')};
}
`,
];
@@ -148,6 +163,14 @@ export class DeesInputMultitoggle extends DeesInputBase<DeesInputMultitoggle> {
// Initialize boolean options early
if (this.type === 'boolean' && this.options.length === 0) {
this.options = [this.booleanTrueName || 'true', this.booleanFalseName || 'false'];
// Set default selection for boolean if not set
if (!this.selectedOption) {
this.selectedOption = this.booleanFalseName || 'false';
}
}
// Set default selection to first option if not set
if (!this.selectedOption && this.options.length > 0) {
this.selectedOption = this.options[0];
}
}
@@ -159,16 +182,28 @@ export class DeesInputMultitoggle extends DeesInputBase<DeesInputMultitoggle> {
}
// Wait for the next frame to ensure DOM is fully rendered
await this.updateComplete;
requestAnimationFrame(() => {
this.setIndicator();
});
}
public async handleSelection(optionArg: string) {
this.selectedOption = optionArg;
// Wait for fonts to load
if (document.fonts) {
await document.fonts.ready;
}
// Wait one more frame after fonts are loaded
await new Promise(resolve => requestAnimationFrame(resolve));
// Now set the indicator
this.setIndicator();
}
public async handleSelection(optionArg: string) {
if (this.disabled) return;
this.selectedOption = optionArg;
this.requestUpdate();
this.changeSubject.next(this);
await this.updateComplete;
this.setIndicator();
}
private indicatorInitialized = false;
public async setIndicator() {
@@ -199,8 +234,8 @@ export class DeesInputMultitoggle extends DeesInputBase<DeesInputMultitoggle> {
}, 50);
}
indicator.style.width = `${option.clientWidth - 8}px`;
indicator.style.left = `${option.offsetLeft + 4}px`;
indicator.style.width = `${option.clientWidth}px`;
indicator.style.left = `${option.offsetLeft}px`;
indicator.style.opacity = '1';
}
}
@@ -218,8 +253,11 @@ export class DeesInputMultitoggle extends DeesInputBase<DeesInputMultitoggle> {
} else {
this.selectedOption = value as string;
}
this.requestUpdate();
if (this.hasUpdated) {
this.setIndicator();
requestAnimationFrame(() => {
this.setIndicator();
});
}
}
}

View File

@@ -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;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
<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;
}
.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;
}
}
.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>
<div class="demo-container">
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
// Demonstrate basic text input functionality
const inputs = elementArg.querySelectorAll('dees-input-text');
inputs.forEach((input: DeesInputText) => {
input.addEventListener('changeSubject', (event: CustomEvent) => {
console.log(`Input "${input.label}" changed to:`, input.getValue());
});
@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;
}
.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">
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>
`;

View File

@@ -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:EyeOff'}></dees-icon>
</div>
`
: html``}

View File

@@ -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``}

View 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>
`;

View File

@@ -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();
// 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(0);
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();
}
}
}

View File

@@ -139,12 +139,12 @@ export class DeesModal extends DeesElement {
opacity: 0;
min-height: 120px;
max-height: calc(100vh - 40px);
background: ${cssManager.bdTheme('#ffffff', '#111')};
border-radius: 8px;
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
transition: all 0.2s;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border-radius: 6px;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
transition: all 0.2s ease;
overflow: hidden;
box-shadow: ${cssManager.bdTheme('0px 2px 10px rgba(0, 0, 0, 0.1)', '0px 2px 5px rgba(0, 0, 0, 0.5)')};
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
margin: 20px;
display: flex;
flex-direction: column;
@@ -193,6 +193,7 @@ export class DeesModal extends DeesElement {
max-height: 100vh !important;
margin: 0;
border-radius: 0;
border: none;
}
}
@@ -214,7 +215,7 @@ export class DeesModal extends DeesElement {
align-items: center;
justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
position: relative;
flex-shrink: 0;
}
@@ -232,23 +233,23 @@ export class DeesModal extends DeesElement {
.modal .heading .header-button {
width: 28px;
height: 28px;
border-radius: 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
transition: all 0.15s ease;
background: transparent;
color: ${cssManager.bdTheme('#666', '#999')};
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.modal .heading .header-button:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.08)')};
color: ${cssManager.bdTheme('#333', '#fff')};
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.modal .heading .header-button:active {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.12)', 'rgba(255, 255, 255, 0.12)')};
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.modal .heading .header-button dees-icon {
@@ -264,6 +265,7 @@ export class DeesModal extends DeesElement {
font-size: 14px;
line-height: 40px;
padding: 0 40px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.modal .content {
@@ -276,7 +278,7 @@ export class DeesModal extends DeesElement {
.modal .bottomButtons {
display: flex;
flex-direction: row;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
border-top: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
justify-content: flex-end;
gap: 8px;
padding: 8px;
@@ -285,39 +287,43 @@ export class DeesModal extends DeesElement {
.modal .bottomButtons .bottomButton {
padding: 8px 16px;
border-radius: 6px;
border-radius: 4px;
line-height: 16px;
text-align: center;
font-size: 14px;
font-weight: 500;
cursor: pointer;
user-select: none;
transition: all 0.2s;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
transition: all 0.15s ease;
background: ${cssManager.bdTheme('#ffffff', '#27272a')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
white-space: nowrap;
}
.modal .bottomButtons .bottomButton:hover {
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
color: #ffffff;
background: ${cssManager.bdTheme('#f4f4f5', '#3f3f46')};
border-color: ${cssManager.bdTheme('#d1d5db', '#52525b')};
}
.modal .bottomButtons .bottomButton:active {
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
color: #ffffff;
background: ${cssManager.bdTheme('#e5e7eb', '#52525b')};
}
.modal .bottomButtons .bottomButton:last-child {
border-right: none;
}
.modal .bottomButtons .bottomButton.primary {
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
color: #ffffff;
}
.modal .bottomButtons .bottomButton.primary:hover {
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
background: ${cssManager.bdTheme('#2563eb', '#2563eb')};
border-color: ${cssManager.bdTheme('#2563eb', '#2563eb')};
}
.modal .bottomButtons .bottomButton.primary:active {
background: ${cssManager.bdTheme(colors.bright.blueMuted, colors.dark.blueMuted)};
background: ${cssManager.bdTheme('#1d4ed8', '#1d4ed8')};
border-color: ${cssManager.bdTheme('#1d4ed8', '#1d4ed8')};
}
`,
];

View File

@@ -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">

View File

@@ -238,7 +238,7 @@ export class DeesSimpleAppDash extends DeesElement {
<div class="appbar">
<div>
<div class="sidebar-header">
<dees-icon .icon="lucide:grid3x3" style="font-size: 18px;"></dees-icon>
<dees-icon .icon="${'lucide:grid3x3'}" style="font-size: 18px;"></dees-icon>
<div class="appName">${this.name}</div>
</div>
<div class="viewTabs-container">
@@ -250,7 +250,7 @@ export class DeesSimpleAppDash extends DeesElement {
@click=${() => this.loadView(view)}
>
${view.iconName ? html`
<dees-icon .icon="${`lucide:${view.iconName}`}"></dees-icon>
<dees-icon .icon="${`${view.iconName}`}"></dees-icon>
` : ''}
<span style="flex: 1;">${view.name}</span>
</div>
@@ -263,7 +263,7 @@ export class DeesSimpleAppDash extends DeesElement {
<div class="action" @click=${() => {
this.dispatchEvent(new CustomEvent('logout', { bubbles: true, composed: true }));
}}>
<dees-icon .icon="lucide:logOut"></dees-icon>
<dees-icon .icon="${'lucide:logOut'}"></dees-icon>
<span>Logout</span>
</div>
</div>
@@ -273,11 +273,11 @@ export class DeesSimpleAppDash extends DeesElement {
</div>
<div class="controlbar">
<div class="control">
<dees-icon .icon="lucide:wifi"></dees-icon>
<dees-icon .icon="${'lucide:wifi'}"></dees-icon>
<span>Connected</span>
</div>
<div class="control" @click=${this.launchTerminal}>
<dees-icon .icon="lucide:terminal"></dees-icon>
<dees-icon .icon="${'lucide:terminal'}"></dees-icon>
<span>Terminal</span>
</div>
</div>
@@ -319,6 +319,8 @@ export class DeesSimpleAppDash extends DeesElement {
terminal.style.transition = 'all 0.2s';
terminal.style.background = '#000';
terminal.style.boxShadow = '0 4px 20px rgba(0, 0, 0, 0.3)';
terminal.style.maxWidth = `calc(${maincontainer.clientWidth}px -240px)`;
terminal.style.maxHeight = `calc(${maincontainer.clientHeight}px - 24px)`;
// Add close button to terminal
terminal.addEventListener('close', () => this.closeTerminal());

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,432 +0,0 @@
import { type ITableAction } from './dees-table.js';
import * as plugins from './00plugins.js';
import { html, css, cssManager } from '@design.estate/dees-element';
interface ITableDemoData {
date: string;
amount: string;
description: string;
}
export const demoFunc = () => html`
<style>
${css`
.demoWrapper {
box-sizing: border-box;
position: absolute;
width: 100%;
height: 100%;
padding: 32px;
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 5%)')};
overflow-y: auto;
}
.demo-container {
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
margin-bottom: 48px;
}
.demo-title {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.demo-description {
font-size: 14px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
margin-bottom: 24px;
}
.theme-toggle {
position: fixed;
top: 16px;
right: 16px;
z-index: 1000;
}
`}
</style>
<div class="demoWrapper">
<dees-button class="theme-toggle" @click=${() => {
document.body.classList.toggle('bright');
document.body.classList.toggle('dark');
}}>Toggle Theme</dees-button>
<div class="demo-container">
<div class="demo-section">
<h2 class="demo-title">Basic Table with Actions</h2>
<p class="demo-description">A standard table with row actions, editable fields, and context menu support. Double-click on descriptions to edit. Grid lines are enabled by default.</p>
<dees-table
heading1="Current Account Statement"
heading2="Bunq - Payment Account 2 - April 2021"
.editableFields="${['description']}"
.data=${[
{
date: '2021-04-01',
amount: '2464.65 €',
description: 'Printing Paper (Office Supplies) - STAPLES BREMEN',
},
{
date: '2021-04-02',
amount: '165.65 €',
description: 'Logitech Mouse (Hardware) - logi.com OnlineShop',
},
{
date: '2021-04-03',
amount: '2999,00 €',
description: 'Macbook Pro 16inch (Hardware) - Apple.de OnlineShop',
},
{
date: '2021-04-01',
amount: '2464.65 €',
description: 'Office-Supplies - STAPLES BREMEN',
},
{
date: '2021-04-01',
amount: '2464.65 €',
description: 'Office-Supplies - STAPLES BREMEN',
},
]}
dataName="transactions"
.dataActions="${[
{
name: 'upload',
iconName: 'bell',
useTableBehaviour: 'upload',
type: ['inRow'],
actionFunc: async (optionsArg) => {
alert(optionsArg.item.amount);
},
},
{
name: 'visibility',
iconName: 'copy',
type: ['inRow'],
useTableBehaviour: 'preview',
actionFunc: async (itemArg: any) => {},
},
{
name: 'create new',
iconName: 'instagram',
type: ['header'],
useTableBehaviour: 'preview',
actionFunc: async (itemArg: any) => {},
},
{
name: 'to gallery',
iconName: 'message',
type: ['footer'],
useTableBehaviour: 'preview',
actionFunc: async (itemArg: any) => {},
},
{
name: 'copy',
iconName: 'copySolid',
type: ['contextmenu', 'inRow'],
action: async () => {
return null;
},
},
{
name: 'edit (from demo)',
iconName: 'penToSquare',
type: ['contextmenu'],
action: async () => {
return null;
},
},
{
name: 'paste',
iconName: 'pasteSolid',
type: ['contextmenu'],
action: async () => {
return null;
},
},
{
name: 'preview',
type: ['doubleClick', 'contextmenu'],
iconName: 'eye',
actionFunc: async (itemArg) => {
alert(itemArg.item.amount);
return null;
},
}
] as ITableAction[]}"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Table with Vertical Lines</h2>
<p class="demo-description">Enhanced column separation for better data tracking.</p>
<dees-table
heading1="Product Inventory"
heading2="Current stock levels across warehouses"
.showVerticalLines=${true}
.data=${[
{
product: 'MacBook Pro 16"',
warehouse_a: '45',
warehouse_b: '32',
warehouse_c: '28',
total: '105',
status: '✓ In Stock'
},
{
product: 'iPhone 15 Pro',
warehouse_a: '120',
warehouse_b: '89',
warehouse_c: '156',
total: '365',
status: '✓ In Stock'
},
{
product: 'AirPods Pro',
warehouse_a: '0',
warehouse_b: '12',
warehouse_c: '5',
total: '17',
status: '⚠ Low Stock'
},
{
product: 'iPad Air',
warehouse_a: '23',
warehouse_b: '45',
warehouse_c: '67',
total: '135',
status: '✓ In Stock'
}
]}
dataName="products"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Table with Full Grid</h2>
<p class="demo-description">Complete grid lines for maximum readability and structure.</p>
<dees-table
heading1="Server Monitoring Dashboard"
heading2="Real-time metrics across regions"
.showGrid=${true}
.data=${[
{
server: 'API-1',
region: 'US-East',
cpu: '45%',
memory: '62%',
disk: '78%',
latency: '12ms',
uptime: '99.9%',
status: '🟢 Healthy'
},
{
server: 'API-2',
region: 'EU-West',
cpu: '38%',
memory: '55%',
disk: '45%',
latency: '25ms',
uptime: '99.8%',
status: '🟢 Healthy'
},
{
server: 'DB-Master',
region: 'US-East',
cpu: '72%',
memory: '81%',
disk: '92%',
latency: '8ms',
uptime: '100%',
status: '🟡 Warning'
},
{
server: 'DB-Replica',
region: 'EU-West',
cpu: '23%',
memory: '34%',
disk: '45%',
latency: '15ms',
uptime: '99.7%',
status: '🟢 Healthy'
},
{
server: 'Cache-1',
region: 'AP-South',
cpu: '89%',
memory: '92%',
disk: '12%',
latency: '120ms',
uptime: '98.5%',
status: '🔴 Critical'
}
]}
dataName="servers"
.dataActions="${[
{
name: 'SSH Connect',
iconName: 'lucide:terminal',
type: ['inRow'],
actionFunc: async (optionsArg) => {
console.log('Connecting to:', optionsArg.item.server);
},
},
{
name: 'View Logs',
iconName: 'lucide:file-text',
type: ['inRow', 'contextmenu'],
actionFunc: async (optionsArg) => {
console.log('Viewing logs for:', optionsArg.item.server);
},
},
{
name: 'Restart Server',
iconName: 'lucide:refresh-cw',
type: ['contextmenu'],
actionFunc: async (optionsArg) => {
console.log('Restarting:', optionsArg.item.server);
},
}
] as ITableAction[]}"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Table with Horizontal Lines Only</h2>
<p class="demo-description">Emphasis on row separation without column dividers.</p>
<dees-table
heading1="Sales Performance"
heading2="Top performers this quarter"
.showHorizontalLines=${true}
.showVerticalLines=${false}
.data=${[
{
salesperson: 'Emily Johnson',
region: 'North America',
deals_closed: '42',
revenue: '$1.2M',
quota_achievement: '128%',
rating: '⭐⭐⭐⭐⭐'
},
{
salesperson: 'Michael Chen',
region: 'Asia Pacific',
deals_closed: '38',
revenue: '$980K',
quota_achievement: '115%',
rating: '⭐⭐⭐⭐⭐'
},
{
salesperson: 'Sarah Williams',
region: 'Europe',
deals_closed: '35',
revenue: '$875K',
quota_achievement: '108%',
rating: '⭐⭐⭐⭐'
},
{
salesperson: 'David Garcia',
region: 'Latin America',
deals_closed: '31',
revenue: '$750K',
quota_achievement: '95%',
rating: '⭐⭐⭐⭐'
}
]}
dataName="sales reps"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Simple Table (No Grid)</h2>
<p class="demo-description">Clean, minimal design without grid lines. Set showGrid to false to disable the default grid.</p>
<dees-table
heading1="Team Members"
heading2="Engineering Department"
.showGrid=${false}
.data=${[
{
name: 'Alice Johnson',
role: 'Lead Engineer',
email: 'alice@company.com',
location: 'San Francisco',
joined: '2020-03-15'
},
{
name: 'Bob Smith',
role: 'Senior Developer',
email: 'bob@company.com',
location: 'New York',
joined: '2019-07-22'
},
{
name: 'Charlie Davis',
role: 'DevOps Engineer',
email: 'charlie@company.com',
location: 'London',
joined: '2021-01-10'
},
{
name: 'Diana Martinez',
role: 'Frontend Developer',
email: 'diana@company.com',
location: 'Barcelona',
joined: '2022-05-18'
}
]}
dataName="team members"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Table with Custom Display Function</h2>
<p class="demo-description">Transform data for display using custom formatting.</p>
<dees-table
heading1="Sales Report"
heading2="Q4 2023 Performance"
.data=${[
{
product: 'Enterprise License',
units: 45,
revenue: 225000,
growth: 0.23,
forecast: 280000
},
{
product: 'Professional License',
units: 128,
revenue: 128000,
growth: 0.15,
forecast: 147000
},
{
product: 'Starter License',
units: 342,
revenue: 68400,
growth: 0.42,
forecast: 97000
}
]}
.displayFunction=${(item) => ({
Product: item.product,
'Units Sold': item.units.toLocaleString(),
Revenue: '$' + item.revenue.toLocaleString(),
Growth: (item.growth * 100).toFixed(1) + '%',
'Q1 2024 Forecast': '$' + item.forecast.toLocaleString()
})}
dataName="products"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Empty Table State</h2>
<p class="demo-description">How the table looks when no data is available.</p>
<dees-table
heading1="No Data Available"
heading2="This table is currently empty"
.data=${[]}
dataName="items"
></dees-table>
</div>
</div>
</div>
`;

View File

@@ -1,920 +0,0 @@
import * as plugins from './00plugins.js';
import { demoFunc } from './dees-table.demo.js';
import { cssGeistFontFamily } from './00fonts.js';
import {
customElement,
html,
DeesElement,
property,
type TemplateResult,
cssManager,
css,
directives,
} from '@design.estate/dees-element';
import { DeesContextmenu } from './dees-contextmenu.js';
import * as domtools from '@design.estate/dees-domtools';
import { type TIconKey } from './dees-icon.js';
declare global {
interface HTMLElementTagNameMap {
'dees-table': DeesTable<any>;
}
}
// interfaces
export interface ITableAction<T = any> {
name: string;
iconName: TIconKey;
/**
* the table behaviour to use for this action
* e.g. upload: allows to upload files to the table
*/
useTableBehaviour?: 'upload' | 'cancelUpload' | 'none';
/**
* the type of the action
*/
type: (
| 'inRow'
| 'contextmenu'
| 'doubleClick'
| 'footer'
| 'header'
| 'preview'
| 'keyCombination'
)[];
/**
* allows to check if the action is relevant for the given item
* @param itemArg
* @returns
*/
actionRelevancyCheckFunc?: (itemArg: T) => boolean;
/**
* the actual action function implementation
* @param itemArg
* @returns
*/
actionFunc: (actionDataArg: ITableActionDataArg<T>) => Promise<any>;
}
export interface ITableActionDataArg<T> {
item: T;
table: DeesTable<T>;
}
export type TDisplayFunction<T = any> = (itemArg: T) => object;
// the table implementation
@customElement('dees-table')
export class DeesTable<T> extends DeesElement {
public static demo = demoFunc;
// INSTANCE
@property({
type: String,
})
public heading1: string = 'heading 1';
@property({
type: String,
})
public heading2: string = 'heading 2';
@property({
type: Array,
})
public data: T[] = [];
// dees-form compatibility -----------------------------------------
@property({
type: String,
})
public key: string;
@property({
type: String,
})
public label: string;
@property({
type: Boolean,
})
public disabled: boolean = false;
@property({
type: Boolean,
})
public required: boolean = false;
get value() {
return this.data;
}
set value(_valueArg) {}
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<DeesTable<T>>();
// end dees-form compatibility -----------------------------------------
/**
* What does a row of data represent?
*/
@property({
type: String,
reflect: true,
})
public dataName: string;
@property({
type: Boolean,
})
searchable: boolean = true;
@property({
type: Array,
})
public dataActions: ITableAction<T>[] = [];
@property({
attribute: false,
})
public displayFunction: TDisplayFunction = (itemArg: T) => itemArg as any;
@property({
attribute: false,
})
public reverseDisplayFunction: (itemArg: any) => T = (itemArg: any) => itemArg as T;
@property({
type: Object,
})
public selectedDataRow: T;
@property({
type: Array,
})
public editableFields: string[] = [];
@property({
type: Boolean,
reflect: true,
attribute: 'show-vertical-lines'
})
public showVerticalLines: boolean = false;
@property({
type: Boolean,
reflect: true,
attribute: 'show-horizontal-lines'
})
public showHorizontalLines: boolean = false;
@property({
type: Boolean,
reflect: true,
attribute: 'show-grid'
})
public showGrid: boolean = true;
public files: File[] = [];
public fileWeakMap = new WeakMap();
public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject();
constructor() {
super();
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
}
.mainbox {
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
font-family: ${cssGeistFontFamily};
font-weight: 400;
font-size: 14px;
display: block;
width: 100%;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 8px;
overflow: hidden;
cursor: default;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
min-height: 64px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.headingContainer {
flex: 1;
}
.heading {
line-height: 1.5;
}
.heading1 {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
letter-spacing: -0.025em;
}
.heading2 {
font-size: 14px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
margin-top: 2px;
}
.headingSeparation {
display: none;
}
.headerActions {
user-select: none;
display: flex;
flex-direction: row;
gap: 8px;
}
.headerAction {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
background: transparent;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.headerAction:hover {
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.headerAction dees-icon {
width: 14px;
height: 14px;
}
.searchGrid {
display: grid;
grid-gap: 16px;
grid-template-columns: 1fr 200px;
padding: 16px 24px;
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(0 0% 3.9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
transition: all 0.2s ease;
}
.searchGrid.hidden {
height: 0px;
opacity: 0;
overflow: hidden;
padding: 0px 24px;
border-bottom-width: 0px;
}
table {
width: 100%;
caption-side: bottom;
font-size: 14px;
border-collapse: separate;
border-spacing: 0;
}
.noDataSet {
padding: 48px 24px;
text-align: center;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
}
thead {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
tbody tr {
transition: background-color 0.15s ease;
position: relative;
}
/* Default horizontal lines (bottom border only) */
tbody tr {
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
tbody tr:last-child {
border-bottom: none;
}
/* Full horizontal lines when enabled */
:host([show-horizontal-lines]) tbody tr {
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
:host([show-horizontal-lines]) tbody tr:first-child {
border-top: none;
}
:host([show-horizontal-lines]) tbody tr:last-child {
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
tbody tr:hover {
background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.5)', 'hsl(0 0% 14.9% / 0.5)')};
}
/* Column hover effect for better traceability */
td {
position: relative;
}
td::after {
content: '';
position: absolute;
top: -1000px;
bottom: -1000px;
left: 0;
right: 0;
background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.3)', 'hsl(0 0% 14.9% / 0.3)')};
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: -1;
}
td:hover::after {
opacity: 1;
}
/* Grid mode - shows both vertical and horizontal lines */
:host([show-grid]) th {
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-left: none;
border-top: none;
}
:host([show-grid]) td {
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-left: none;
border-top: none;
}
:host([show-grid]) th:first-child,
:host([show-grid]) td:first-child {
border-left: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
:host([show-grid]) tbody tr:first-child td {
border-top: none;
}
tbody tr.selected {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
}
tbody tr.hasAttachment {
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 76.2% 36.3% / 0.1)')};
}
th {
height: 48px;
padding: 12px 24px;
text-align: left;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
letter-spacing: -0.01em;
}
:host([show-vertical-lines]) th {
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
td {
padding: 12px 24px;
vertical-align: middle;
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
}
:host([show-vertical-lines]) td {
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
th:first-child,
td:first-child {
padding-left: 24px;
}
th:last-child,
td:last-child {
padding-right: 24px;
}
:host([show-vertical-lines]) th:last-child,
:host([show-vertical-lines]) td:last-child {
border-right: none;
}
.innerCellContainer {
position: relative;
min-height: 24px;
line-height: 24px;
}
td input {
position: absolute;
top: 4px;
bottom: 4px;
left: 20px;
right: 20px;
width: calc(100% - 40px);
height: calc(100% - 8px);
padding: 0 12px;
outline: none;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
font-family: inherit;
font-size: inherit;
font-weight: inherit;
transition: all 0.15s ease;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
td input:focus {
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.2)', 'hsl(217.2 91.2% 59.8% / 0.2)')};
}
.actionsContainer {
display: flex;
flex-direction: row;
gap: 4px;
}
.action {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
cursor: pointer;
transition: all 0.15s ease;
}
.action:hover {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.action:active {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 11.8%)')};
}
.action dees-icon {
width: 16px;
height: 16px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
padding: 0 24px;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.tableStatistics {
font-weight: 500;
}
.footerActions {
display: flex;
gap: 8px;
}
.footerActions .footerAction {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
border-radius: 6px;
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
}
.footerActions .footerAction:hover {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.footerActions .footerAction dees-icon {
width: 14px;
height: 14px;
}
`,
];
public render(): TemplateResult {
return html`
<div class="mainbox">
<!-- the heading part -->
<div class="header">
<div class="headingContainer">
<div class="heading heading1">${this.label || this.heading1}</div>
<div class="heading heading2">${this.heading2}</div>
</div>
<div class="headerActions">
${directives.resolveExec(async () => {
const resultArray: TemplateResult[] = [];
for (const action of this.dataActions) {
if (!action.type.includes('header')) continue;
resultArray.push(
html`<div
class="headerAction"
@click=${() => {
action.actionFunc({
item: this.selectedDataRow,
table: this,
});
}}
>
${action.iconName
? html`<dees-icon .iconSize=${14} .iconFA=${action.iconName}></dees-icon>
${action.name}`
: action.name}
</div>`
);
}
return resultArray;
})}
</div>
</div>
<div class="headingSeparation"></div>
<div class="searchGrid hidden">
<dees-input-text
.label=${'lucene syntax search'}
.description=${`
You can use the lucene syntax to search for data, e.g.:
\`\`\`
name: "john" AND age: 18
\`\`\`
`}
></dees-input-text>
<dees-input-multitoggle
.label=${'search mode'}
.options=${['table', 'data', 'server']}
.selectedOption=${'table'}
.description=${`
There are three basic modes:
* table: only searches data already in the table
* data: searches original data, ignoring table transforms
* server: searches data on the server
`}
></dees-input-multitoggle>
</div>
<!-- the actual table -->
<style></style>
${this.data.length > 0
? (() => {
// Only pick up the keys from the first transformed data object
// as all data objects are assumed to have the same structure
const firstTransformedItem = this.displayFunction(this.data[0]);
const headings: string[] = Object.keys(firstTransformedItem);
return html`
<table>
<thead>
<tr>
${headings.map(
(headingArg) => html`
<th>${headingArg}</th>
`
)}
${(() => {
if (this.dataActions && this.dataActions.length > 0) {
return html`
<th>Actions</th>
`;
}
})()}
</tr>
</thead>
<tbody>
${this.data.map((itemArg) => {
const transformedItem = this.displayFunction(itemArg);
const getTr = (elementArg: HTMLElement): HTMLElement => {
if (elementArg.tagName === 'TR') {
return elementArg;
} else {
return getTr(elementArg.parentElement);
}
};
return html`
<tr
@click=${() => {
this.selectedDataRow = itemArg;
}}
@dragenter=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
eventArg.stopPropagation();
const realTarget = getTr(eventArg.target as HTMLElement);
console.log('dragenter');
console.log(realTarget);
setTimeout(() => {
realTarget.classList.add('hasAttachment');
}, 0);
}}
@dragleave=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
eventArg.stopPropagation();
const realTarget = getTr(eventArg.target as HTMLElement);
realTarget.classList.remove('hasAttachment');
}}
@dragover=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
}}
@drop=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
const newFiles = [];
for (const file of Array.from(eventArg.dataTransfer.files)) {
this.files.push(file);
newFiles.push(file);
this.requestUpdate();
}
const result: File[] = this.fileWeakMap.get(itemArg as object);
if (!result) {
this.fileWeakMap.set(itemArg as object, newFiles);
} else {
result.push(...newFiles);
}
}}
@contextmenu=${async (eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(
eventArg,
this.getActionsForType('contextmenu').map((action) => {
const menuItem: plugins.tsclass.website.IMenuItem = {
name: action.name,
iconName: action.iconName as any,
action: async () => {
await action.actionFunc({
item: itemArg,
table: this,
});
return null;
},
};
return menuItem;
})
);
}}
class="${itemArg === this.selectedDataRow ? 'selected' : ''}"
>
${headings.map(
(headingArg) => html`
<td
@dblclick=${(e: Event) => {
if (this.editableFields.includes(headingArg)) {
this.handleCellEditing(e, itemArg, headingArg);
} else {
const wantedAction = this.dataActions.find((actionArg) =>
actionArg.type.includes('doubleClick')
);
if (wantedAction) {
wantedAction.actionFunc({
item: itemArg,
table: this,
});
}
}
}}
>
<div class="innerCellContainer">${transformedItem[headingArg]}</div>
</td>
`
)}
${(() => {
if (this.dataActions && this.dataActions.length > 0) {
return html`
<td>
<div class="actionsContainer">
${this.getActionsForType('inRow').map(
(actionArg) => html`
<div
class="action"
@click=${() =>
actionArg.actionFunc({
item: itemArg,
table: this,
})}
>
${actionArg.iconName
? html`
<dees-icon
.iconFA=${actionArg.iconName}
></dees-icon>
`
: actionArg.name}
</div>
`
)}
</div>
</td>
`;
}
})()}
</tr>
`;
})}
</tbody>
</table>
`;
})()
: html` <div class="noDataSet">No data set!</div> `}
<div class="footer">
<div class="tableStatistics">
${this.data.length} ${this.dataName || 'data rows'} (total) |
${this.selectedDataRow ? '# ' + `${this.data.indexOf(this.selectedDataRow) + 1}` : `No`}
selected
</div>
<div class="footerActions">
${directives.resolveExec(async () => {
const resultArray: TemplateResult[] = [];
for (const action of this.dataActions) {
if (!action.type.includes('footer')) continue;
resultArray.push(
html`<div
class="footerAction"
@click=${() => {
action.actionFunc({
item: this.selectedDataRow,
table: this,
});
}}
>
${action.iconName
? html`<dees-icon .iconSize=${14} .iconFA=${action.iconName}></dees-icon>
${action.name}`
: action.name}
</div>`
);
}
return resultArray;
})}
</div>
</div>
</div>
`;
}
public async firstUpdated() {
}
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
super.updated(changedProperties);
this.determineColumnWidths();
if (this.searchable) {
const existing = this.dataActions.find((actionArg) => actionArg.type.includes('header') && actionArg.name === 'Search');
if (!existing) {
this.dataActions.unshift({
name: 'Search',
iconName: 'magnifyingGlass',
type: ['header'],
actionFunc: async () => {
console.log('open search');
const searchGrid = this.shadowRoot.querySelector('.searchGrid');
searchGrid.classList.toggle('hidden');
}
});
console.log(this.dataActions);
this.requestUpdate();
};
}
}
public async determineColumnWidths() {
const domtools = await this.domtoolsPromise;
await domtools.convenience.smartdelay.delayFor(0);
// Get the table element
const table = this.shadowRoot.querySelector('table');
if (!table) return;
// Get the first row's cells to measure the widths
const cells = table.rows[0].cells;
const handleColumnByIndex = async (i: number, waitForRenderArg: boolean = false) => {
const done = plugins.smartpromise.defer();
const cell = cells[i];
// Get computed width
const width = window.getComputedStyle(cell).width;
if (cell.textContent.includes('Actions')) {
const neededWidth =
this.dataActions.filter((actionArg) => actionArg.type.includes('inRow')).length * 36;
cell.style.width = `${Math.max(neededWidth, 68)}px`;
} else {
cell.style.width = width;
}
if (waitForRenderArg) {
requestAnimationFrame(() => {
done.resolve();
});
await done.promise;
}
};
if (cells[cells.length - 1].textContent.includes('Actions')) {
await handleColumnByIndex(cells.length - 1, true);
}
for (let i = 0; i < cells.length; i++) {
if (cells[i].textContent.includes('Actions')) {
continue;
}
await handleColumnByIndex(i);
}
table.style.tableLayout = 'fixed';
}
getActionsForType(typeArg: ITableAction['type'][0]) {
const actions: ITableAction[] = [];
for (const action of this.dataActions) {
if (!action.type.includes(typeArg)) continue;
actions.push(action);
}
return actions;
}
async handleCellEditing(event: Event, itemArg: T, key: string) {
await this.domtoolsPromise;
const target = event.target as HTMLElement;
const originalColor = target.style.color;
target.style.color = 'transparent';
const transformedItem = this.displayFunction(itemArg);
const initialValue = (transformedItem[key] as unknown as string) || '';
// Create an input element
const input = document.createElement('input');
input.type = 'text';
input.value = initialValue;
const blurInput = async (blurArg = true, saveArg = false) => {
if (blurArg) {
input.blur();
}
if (saveArg) {
itemArg[key] = input.value as any; // Convert string to T (you might need better type casting depending on your data structure)
this.changeSubject.next(this);
}
input.remove();
target.style.color = originalColor;
this.requestUpdate();
};
// When the input loses focus or the Enter key is pressed, update the data
input.addEventListener('blur', () => {
blurInput(false, false);
});
input.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter') {
blurInput(true, true); // This will trigger the blur event handler above
}
});
// Replace the cell's content with the input
target.appendChild(input);
input.focus();
}
}

View File

@@ -0,0 +1,115 @@
import type { Column, TDisplayFunction } from './types.js';
export function computeColumnsFromDisplayFunction<T>(
displayFunction: TDisplayFunction<T>,
data: T[]
): Column<T>[] {
if (!data || data.length === 0) return [];
const firstTransformedItem = displayFunction(data[0]);
const keys: string[] = Object.keys(firstTransformedItem);
return keys.map((key) => ({
key,
header: key,
value: (row: T) => displayFunction(row)[key],
}));
}
export function computeEffectiveColumns<T>(
columns: Column<T>[] | undefined,
augmentFromDisplayFunction: boolean,
displayFunction: TDisplayFunction<T>,
data: T[]
): Column<T>[] {
const base = (columns || []).slice();
if (!augmentFromDisplayFunction) return base;
const fromDisplay = computeColumnsFromDisplayFunction(displayFunction, data);
const existingKeys = new Set(base.map((c) => String(c.key)));
for (const col of fromDisplay) {
if (!existingKeys.has(String(col.key))) {
base.push(col);
}
}
return base;
}
export function getCellValue<T>(row: T, col: Column<T>, displayFunction?: TDisplayFunction<T>): any {
return col.value ? col.value(row) : (row as any)[col.key as any];
}
export function getViewData<T>(
data: T[],
effectiveColumns: Column<T>[],
sortKey?: string,
sortDir?: 'asc' | 'desc' | null,
filterText?: string,
columnFilters?: Record<string, string>,
filterMode: 'table' | 'data' = 'table',
lucenePredicate?: (row: T) => boolean
): T[] {
let arr = data.slice();
const ft = (filterText || '').trim().toLowerCase();
const cf = columnFilters || {};
const cfKeys = Object.keys(cf).filter((k) => (cf[k] ?? '').trim().length > 0);
if (ft || cfKeys.length > 0) {
arr = arr.filter((row) => {
// column filters (AND across columns)
for (const k of cfKeys) {
if (filterMode === 'data') {
// raw object check for that key
const val = (row as any)[k];
const s = String(val ?? '').toLowerCase();
const needle = String(cf[k]).toLowerCase();
if (!s.includes(needle)) return false;
} else {
const col = effectiveColumns.find((c) => String(c.key) === k);
if (!col || col.hidden || col.filterable === false) continue;
const val = getCellValue(row, col);
const s = String(val ?? '').toLowerCase();
const needle = String(cf[k]).toLowerCase();
if (!s.includes(needle)) return false;
}
}
// global filter (OR across visible columns) or lucene predicate
if (ft) {
if (lucenePredicate) {
if (!lucenePredicate(row)) return false;
return true;
}
let any = false;
if (filterMode === 'data') {
for (const val of Object.values(row as any)) {
const s = String(val ?? '').toLowerCase();
if (s.includes(ft)) { any = true; break; }
}
} else {
for (const col of effectiveColumns) {
if (col.hidden) continue;
const val = getCellValue(row, col);
const s = String(val ?? '').toLowerCase();
if (s.includes(ft)) { any = true; break; }
}
}
if (!any) return false;
}
return true;
});
}
if (!sortKey || !sortDir) return arr;
const col = effectiveColumns.find((c) => String(c.key) === sortKey);
if (!col) return arr;
const dir = sortDir === 'asc' ? 1 : -1;
arr.sort((a, b) => {
const va = getCellValue(a, col);
const vb = getCellValue(b, col);
if (va == null && vb == null) return 0;
if (va == null) return -1 * dir;
if (vb == null) return 1 * dir;
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
const sa = String(va).toLowerCase();
const sb = String(vb).toLowerCase();
if (sa < sb) return -1 * dir;
if (sa > sb) return 1 * dir;
return 0;
});
return arr;
}

View File

@@ -0,0 +1,678 @@
import { type ITableAction } from './dees-table.js';
import * as plugins from '../00plugins.js';
import { html, css, cssManager } from '@design.estate/dees-element';
interface ITableDemoData {
date: string;
amount: string;
description: string;
}
export const demoFunc = () => html`
<style>
${css`
.demoWrapper {
box-sizing: border-box;
position: absolute;
width: 100%;
height: 100%;
padding: 32px;
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 5%)')};
overflow-y: auto;
}
.demo-container {
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
margin-bottom: 48px;
}
.demo-title {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.demo-description {
font-size: 14px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
margin-bottom: 24px;
}
.theme-toggle {
position: fixed;
top: 16px;
right: 16px;
z-index: 1000;
}
`}
</style>
<div class="demoWrapper">
<dees-button class="theme-toggle" @click=${() => {
document.body.classList.toggle('bright');
document.body.classList.toggle('dark');
}}>Toggle Theme</dees-button>
<div class="demo-container">
<div class="demo-section">
<h2 class="demo-title">Basic Table with Actions</h2>
<p class="demo-description">A standard table with row actions, editable fields, and context menu support. Double-click on descriptions to edit. Grid lines are enabled by default.</p>
<dees-table
heading1="Current Account Statement"
heading2="Bunq - Payment Account 2 - April 2021"
.editableFields="${['description']}"
.data=${[
{
date: '2021-04-01',
amount: '2464.65 €',
description: 'Printing Paper (Office Supplies) - STAPLES BREMEN',
},
{
date: '2021-04-02',
amount: '165.65 €',
description: 'Logitech Mouse (Hardware) - logi.com OnlineShop',
},
{
date: '2021-04-03',
amount: '2999,00 €',
description: 'Macbook Pro 16inch (Hardware) - Apple.de OnlineShop',
},
{
date: '2021-04-01',
amount: '2464.65 €',
description: 'Office-Supplies - STAPLES BREMEN',
},
{
date: '2021-04-01',
amount: '2464.65 €',
description: 'Office-Supplies - STAPLES BREMEN',
},
]}
dataName="transactions"
.dataActions="${[
{
name: 'upload',
iconName: 'bell',
useTableBehaviour: 'upload',
type: ['inRow'],
actionFunc: async (optionsArg) => {
alert(optionsArg.item.amount);
},
},
{
name: 'visibility',
iconName: 'copy',
type: ['inRow'],
useTableBehaviour: 'preview',
actionFunc: async (itemArg: any) => {},
},
{
name: 'create new',
iconName: 'instagram',
type: ['header'],
useTableBehaviour: 'preview',
actionFunc: async (itemArg: any) => {},
},
{
name: 'to gallery',
iconName: 'message',
type: ['footer'],
useTableBehaviour: 'preview',
actionFunc: async (itemArg: any) => {},
},
{
name: 'copy',
iconName: 'copySolid',
type: ['contextmenu', 'inRow'],
action: async () => {
return null;
},
},
{
name: 'edit (from demo)',
iconName: 'penToSquare',
type: ['contextmenu'],
action: async () => {
return null;
},
},
{
name: 'paste',
iconName: 'pasteSolid',
type: ['contextmenu'],
action: async () => {
return null;
},
},
{
name: 'preview',
type: ['doubleClick', 'contextmenu'],
iconName: 'eye',
actionFunc: async (itemArg) => {
alert(itemArg.item.amount);
return null;
},
}
] as ITableAction[]}"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Table with Vertical Lines</h2>
<p class="demo-description">Enhanced column separation for better data tracking.</p>
<dees-table
heading1="Product Inventory"
heading2="Current stock levels across warehouses"
.showVerticalLines=${true}
.data=${[
{
product: 'MacBook Pro 16"',
warehouse_a: '45',
warehouse_b: '32',
warehouse_c: '28',
total: '105',
status: '✓ In Stock'
},
{
product: 'iPhone 15 Pro',
warehouse_a: '120',
warehouse_b: '89',
warehouse_c: '156',
total: '365',
status: '✓ In Stock'
},
{
product: 'AirPods Pro',
warehouse_a: '0',
warehouse_b: '12',
warehouse_c: '5',
total: '17',
status: '⚠ Low Stock'
},
{
product: 'iPad Air',
warehouse_a: '23',
warehouse_b: '45',
warehouse_c: '67',
total: '135',
status: '✓ In Stock'
}
]}
dataName="products"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Table with Full Grid</h2>
<p class="demo-description">Complete grid lines for maximum readability and structure.</p>
<dees-table
heading1="Server Monitoring Dashboard"
heading2="Real-time metrics across regions"
.showGrid=${true}
.data=${[
{
server: 'API-1',
region: 'US-East',
cpu: '45%',
memory: '62%',
disk: '78%',
latency: '12ms',
uptime: '99.9%',
status: '🟢 Healthy'
},
{
server: 'API-2',
region: 'EU-West',
cpu: '38%',
memory: '55%',
disk: '45%',
latency: '25ms',
uptime: '99.8%',
status: '🟢 Healthy'
},
{
server: 'DB-Master',
region: 'US-East',
cpu: '72%',
memory: '81%',
disk: '92%',
latency: '8ms',
uptime: '100%',
status: '🟡 Warning'
},
{
server: 'DB-Replica',
region: 'EU-West',
cpu: '23%',
memory: '34%',
disk: '45%',
latency: '15ms',
uptime: '99.7%',
status: '🟢 Healthy'
},
{
server: 'Cache-1',
region: 'AP-South',
cpu: '89%',
memory: '92%',
disk: '12%',
latency: '120ms',
uptime: '98.5%',
status: '🔴 Critical'
}
]}
dataName="servers"
.dataActions="${[
{
name: 'SSH Connect',
iconName: 'lucide:terminal',
type: ['inRow'],
actionFunc: async (optionsArg) => {
console.log('Connecting to:', optionsArg.item.server);
},
},
{
name: 'View Logs',
iconName: 'lucide:file-text',
type: ['inRow', 'contextmenu'],
actionFunc: async (optionsArg) => {
console.log('Viewing logs for:', optionsArg.item.server);
},
},
{
name: 'Restart Server',
iconName: 'lucide:refresh-cw',
type: ['contextmenu'],
actionFunc: async (optionsArg) => {
console.log('Restarting:', optionsArg.item.server);
},
}
] as ITableAction[]}"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Table with Horizontal Lines Only</h2>
<p class="demo-description">Emphasis on row separation without column dividers.</p>
<dees-table
heading1="Sales Performance"
heading2="Top performers this quarter"
.showHorizontalLines=${true}
.showVerticalLines=${false}
.data=${[
{
salesperson: 'Emily Johnson',
region: 'North America',
deals_closed: '42',
revenue: '$1.2M',
quota_achievement: '128%',
rating: '⭐⭐⭐⭐⭐'
},
{
salesperson: 'Michael Chen',
region: 'Asia Pacific',
deals_closed: '38',
revenue: '$980K',
quota_achievement: '115%',
rating: '⭐⭐⭐⭐⭐'
},
{
salesperson: 'Sarah Williams',
region: 'Europe',
deals_closed: '35',
revenue: '$875K',
quota_achievement: '108%',
rating: '⭐⭐⭐⭐'
},
{
salesperson: 'David Garcia',
region: 'Latin America',
deals_closed: '31',
revenue: '$750K',
quota_achievement: '95%',
rating: '⭐⭐⭐⭐'
}
]}
dataName="sales reps"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Simple Table (No Grid)</h2>
<p class="demo-description">Clean, minimal design without grid lines. Set showGrid to false to disable the default grid.</p>
<dees-table
heading1="Team Members"
heading2="Engineering Department"
.showGrid=${false}
.data=${[
{
name: 'Alice Johnson',
role: 'Lead Engineer',
email: 'alice@company.com',
location: 'San Francisco',
joined: '2020-03-15'
},
{
name: 'Bob Smith',
role: 'Senior Developer',
email: 'bob@company.com',
location: 'New York',
joined: '2019-07-22'
},
{
name: 'Charlie Davis',
role: 'DevOps Engineer',
email: 'charlie@company.com',
location: 'London',
joined: '2021-01-10'
},
{
name: 'Diana Martinez',
role: 'Frontend Developer',
email: 'diana@company.com',
location: 'Barcelona',
joined: '2022-05-18'
}
]}
dataName="team members"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Table with Custom Display Function</h2>
<p class="demo-description">Transform data for display using custom formatting.</p>
<dees-table
heading1="Sales Report"
heading2="Q4 2023 Performance"
.data=${[
{
product: 'Enterprise License',
units: 45,
revenue: 225000,
growth: 0.23,
forecast: 280000
},
{
product: 'Professional License',
units: 128,
revenue: 128000,
growth: 0.15,
forecast: 147000
},
{
product: 'Starter License',
units: 342,
revenue: 68400,
growth: 0.42,
forecast: 97000
}
]}
.displayFunction=${(item) => ({
Product: item.product,
'Units Sold': item.units.toLocaleString(),
Revenue: '$' + item.revenue.toLocaleString(),
Growth: (item.growth * 100).toFixed(1) + '%',
'Q1 2024 Forecast': '$' + item.forecast.toLocaleString()
})}
dataName="products"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Empty Table State</h2>
<p class="demo-description">How the table looks when no data is available.</p>
<dees-table
heading1="No Data Available"
heading2="This table is currently empty"
.data=${[]}
dataName="items"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Schema-First Columns (New)</h2>
<p class="demo-description">Defines columns explicitly and renders via schema. No displayFunction needed.</p>
<dees-table
heading1="Users (Schema-First)"
heading2="Columns define rendering and order"
.columns=${[
{ key: 'name', header: 'Name', sortable: true },
{ key: 'email', header: 'Email', renderer: (v: string) => html`<dees-badge>${v}</dees-badge>` },
{ key: 'joinedAt', header: 'Joined', renderer: (v: string) => new Date(v).toLocaleDateString() },
]}
.data=${[
{ name: 'Alice', email: 'alice@example.com', joinedAt: '2022-08-01' },
{ name: 'Bob', email: 'bob@example.com', joinedAt: '2021-12-11' },
{ name: 'Carol', email: 'carol@example.com', joinedAt: '2023-03-22' },
]}
dataName="users"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Partial Schema + Augment (New)</h2>
<p class="demo-description">Provides only the important columns; the rest are merged in from displayFunction.</p>
<dees-table
heading1="Users (Partial + Augment)"
heading2="Missing columns are derived"
.columns=${[
{ key: 'name', header: 'Name', sortable: true },
]}
.displayFunction=${(u: any) => ({ name: u.name, email: u.email, role: u.role })}
.augmentFromDisplayFunction=${true}
.data=${[
{ name: 'Erin', email: 'erin@example.com', role: 'Admin' },
{ name: 'Finn', email: 'finn@example.com', role: 'User' },
{ name: 'Gina', email: 'gina@example.com', role: 'User' },
]}
dataName="users"
></dees-table>
</div>
<div class="demo-section"
@selectionChange=${(e: CustomEvent) => { console.log('Selection changed', e.detail); }}
@search-changed=${(e: CustomEvent) => {
const tbl = document.getElementById('tableFilterSelectDemo') as any;
if (tbl) tbl.setFilterText(e.detail.value);
}}
@search-submit=${(e: CustomEvent) => {
const tbl = document.getElementById('tableFilterSelectDemo') as any;
if (tbl) tbl.setFilterText(e.detail.value);
}}
>
<h2 class="demo-title">Filtering + Multi-Selection (New)</h2>
<p class="demo-description">Use the search bar to filter rows; toggle selection via checkboxes. Click headers to sort.</p>
<dees-searchbar></dees-searchbar>
<div style="height: 12px"></div>
<dees-table
id="tableFilterSelectDemo"
heading1="Inventory (Filter + Select)"
heading2="Try typing to filter and selecting multiple rows"
.selectionMode=${'multi'}
.rowKey=${'sku'}
.columns=${[
{ key: 'sku', header: 'SKU', sortable: true },
{ key: 'name', header: 'Name', sortable: true },
{ key: 'stock', header: 'Stock', sortable: true },
]}
.data=${[
{ sku: 'A-100', name: 'USB-C Cable', stock: 120 },
{ sku: 'A-101', name: 'Wireless Mouse', stock: 55 },
{ sku: 'A-102', name: 'Laptop Stand', stock: 18 },
{ sku: 'B-200', name: 'Keyboard (ISO)', stock: 89 },
{ sku: 'B-201', name: 'HDMI Adapter', stock: 0 },
{ sku: 'C-300', name: 'Webcam 1080p', stock: 42 },
]}
dataName="items"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Column Filters + Sticky Header (New)</h2>
<p class="demo-description">Per-column quick filters and sticky header with internal scroll. Try filtering the Name column. Uses --table-max-height var.</p>
<style>
dees-table[sticky-header] { --table-max-height: 220px; }
</style>
<dees-table
heading1="Employees"
heading2="Quick filter per column + sticky header"
.showColumnFilters=${true}
.stickyHeader=${true}
.columns=${[
{ key: 'name', header: 'Name', sortable: true },
{ key: 'email', header: 'Email', sortable: true },
{ key: 'department', header: 'Department', sortable: true },
]}
.data=${[
{ name: 'Alice Johnson', email: 'alice@corp.com', department: 'Engineering' },
{ name: 'Bob Smith', email: 'bob@corp.com', department: 'Sales' },
{ name: 'Charlie Davis', email: 'charlie@corp.com', department: 'HR' },
{ name: 'Diana Martinez', email: 'diana@corp.com', department: 'Engineering' },
{ name: 'Ethan Brown', email: 'ethan@corp.com', department: 'Finance' },
{ name: 'Fiona Clark', email: 'fiona@corp.com', department: 'Sales' },
{ name: 'Grace Lee', email: 'grace@corp.com', department: 'Engineering' },
{ name: 'Henry Wilson', email: 'henry@corp.com', department: 'Marketing' },
{ name: 'Irene Walker', email: 'irene@corp.com', department: 'Finance' },
{ name: 'Jack Turner', email: 'jack@corp.com', department: 'Support' },
]}
dataName="employees"
></dees-table>
</div>
<div class="demo-section"
@searchRequest=${async (e: CustomEvent) => {
const { query } = e.detail || { query: '' };
const table = document.getElementById('serverSearchDemo') as any;
const baseData = [
{ id: 1, name: 'Alice', city: 'Berlin', title: 'Engineer' },
{ id: 2, name: 'Bob', city: 'Paris', title: 'Designer' },
{ id: 3, name: 'Charlie', city: 'London', title: 'Manager' },
{ id: 4, name: 'Diana', city: 'Madrid', title: 'Engineer' },
{ id: 5, name: 'Ethan', city: 'Rome', title: 'Support' },
];
// Simulate async request
await new Promise((r) => setTimeout(r, 300));
const q = String(query || '').toLowerCase();
const filtered = q
? baseData.filter((r) => Object.values(r).some((v) => String(v).toLowerCase().includes(q)))
: baseData;
table.data = filtered;
}}
>
<h2 class="demo-title">Server Search (New)</h2>
<p class="demo-description">Select Server mode, type a query, and watch the table fetch simulated results.</p>
<dees-table
id="serverSearchDemo"
heading1="People (Server Search)"
heading2="Click Search, choose Server mode, and type"
.columns=${[
{ key: 'name', header: 'Name' },
{ key: 'city', header: 'City' },
{ key: 'title', header: 'Title' },
]}
.data=${[
{ id: 1, name: 'Alice', city: 'Berlin', title: 'Engineer' },
{ id: 2, name: 'Bob', city: 'Paris', title: 'Designer' },
{ id: 3, name: 'Charlie', city: 'London', title: 'Manager' },
{ id: 4, name: 'Diana', city: 'Madrid', title: 'Engineer' },
{ id: 5, name: 'Ethan', city: 'Rome', title: 'Support' },
]}
dataName="people"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Wide Properties + Many Actions</h2>
<p class="demo-description">A table with many columns and rich actions to stress test layout and sticky Actions.</p>
<dees-table
heading1="People Directory"
heading2="Many properties and actions"
.columns=${[
{ key: 'id', header: 'ID', sortable: true },
{ key: 'name', header: 'Name', sortable: true },
{ key: 'role', header: 'Role', sortable: true },
{ key: 'department', header: 'Department', sortable: true },
{ key: 'email', header: 'Email' },
{ key: 'phone', header: 'Phone' },
{ key: 'location', header: 'Location', sortable: true },
{ key: 'status', header: 'Status', sortable: true },
{ key: 'createdAt', header: 'Created', sortable: true },
{ key: 'updatedAt', header: 'Updated', sortable: true },
{ key: 'lastLogin', header: 'Last Login', sortable: true },
{ key: 'projects', header: 'Projects' },
{ key: 'tags', header: 'Tags' },
{ key: 'notes', header: 'Notes' },
]}
.data=${[
{ id: 1, name: 'Alice Johnson', role: 'Engineer', department: 'R&D', email: 'alice@corp.com', phone: '+1 202 555 0111', location: 'Berlin', status: 'Active', createdAt: '2023-01-12', updatedAt: '2024-05-03', lastLogin: '2024-10-01', projects: 5, tags: 'typescript, ui', notes: 'Mentor' },
{ id: 2, name: 'Bob Smith', role: 'Designer', department: 'Design', email: 'bob@corp.com', phone: '+1 202 555 0112', location: 'Paris', status: 'Active', createdAt: '2022-11-05', updatedAt: '2024-04-10', lastLogin: '2024-09-28', projects: 8, tags: 'figma, brand', notes: 'Part-time' },
{ id: 3, name: 'Charlie Davis', role: 'Manager', department: 'Ops', email: 'charlie@corp.com', phone: '+1 202 555 0113', location: 'London', status: 'On Leave', createdAt: '2021-04-21', updatedAt: '2024-02-15', lastLogin: '2024-08-12', projects: 3, tags: 'sre, leadership', notes: '' },
{ id: 4, name: 'Diana Martinez', role: 'Engineer', department: 'Platform', email: 'diana@corp.com', phone: '+1 202 555 0114', location: 'Madrid', status: 'Active', createdAt: '2020-06-30', updatedAt: '2024-06-25', lastLogin: '2024-10-02', projects: 6, tags: 'node, api', notes: 'On-call' },
{ id: 5, name: 'Ethan Brown', role: 'Support', department: 'CS', email: 'ethan@corp.com', phone: '+1 202 555 0115', location: 'Rome', status: 'Inactive', createdAt: '2019-09-18', updatedAt: '2024-03-09', lastLogin: '2024-06-19', projects: 2, tags: 'zendesk', notes: 'Rehire' },
{ id: 6, name: 'Fiona Clark', role: 'QA', department: 'QA', email: 'fiona@corp.com', phone: '+1 202 555 0116', location: 'Vienna', status: 'Active', createdAt: '2022-03-14', updatedAt: '2024-03-01', lastLogin: '2024-09-07', projects: 7, tags: 'playwright', notes: '' },
]}
.dataActions=${[
{ name: 'View', iconName: 'lucide:eye', type: ['inRow', 'contextmenu'], actionFunc: async ({ item }) => { console.log('view', item); } },
{ name: 'Edit', iconName: 'lucide:edit', type: ['inRow', 'contextmenu'], actionFunc: async ({ item }) => { console.log('edit', item); } },
{ name: 'Delete', iconName: 'lucide:trash', type: ['inRow', 'contextmenu'], actionFunc: async ({ item }) => { console.log('delete', item); } },
{ name: 'Message', iconName: 'lucide:message-square', type: ['inRow'], actionFunc: async ({ item }) => { console.log('message', item); } },
{ name: 'History', iconName: 'lucide:clock', type: ['inRow'], actionFunc: async ({ item }) => { console.log('history', item); } },
{ name: 'Add New', iconName: 'lucide:plus', type: ['header'], actionFunc: async ({ table }) => { console.log('add'); } },
{ name: 'Export CSV', iconName: 'lucide:download', type: ['header'], actionFunc: async ({ table }) => { console.log('export'); } },
{ name: 'Bulk Delete', iconName: 'lucide:trash-2', type: ['footer'], actionFunc: async ({ table }) => { console.log('bulk delete'); } },
] as ITableAction[]}
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Scrollable Small Height</h2>
<p class="demo-description">Same as above, but with many items and a small fixed height to force vertical scrolling inside the table. Actions remain visible on the right; horizontal scroll appears if needed.</p>
<style>
#scrollSmallHeight { --table-max-height: 240px; }
</style>
<dees-table
id="scrollSmallHeight"
.stickyHeader=${true}
heading1="People Directory (Scrollable)"
heading2="Forced scrolling with many items"
.columns=${[
{ key: 'id', header: 'ID', sortable: true },
{ key: 'name', header: 'Name', sortable: true },
{ key: 'role', header: 'Role', sortable: true },
{ key: 'department', header: 'Department', sortable: true },
{ key: 'email', header: 'Email' },
{ key: 'phone', header: 'Phone' },
{ key: 'location', header: 'Location', sortable: true },
{ key: 'status', header: 'Status', sortable: true },
{ key: 'createdAt', header: 'Created', sortable: true },
{ key: 'updatedAt', header: 'Updated', sortable: true },
{ key: 'lastLogin', header: 'Last Login', sortable: true },
{ key: 'projects', header: 'Projects' },
{ key: 'tags', header: 'Tags' },
{ key: 'notes', header: 'Notes' },
]}
.data=${Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
role: ['Engineer','Designer','Manager','QA','Support'][i % 5],
department: ['R&D','Design','Ops','QA','CS'][i % 5],
email: `user${i+1}@corp.com`,
phone: `+1 202 555 ${String(1000 + i).slice(-4)}`,
location: ['Berlin','Paris','London','Madrid','Rome'][i % 5],
status: ['Active','Inactive','On Leave'][i % 3],
createdAt: `2023-${String((i%12)+1).padStart(2,'0')}-${String((i%28)+1).padStart(2,'0')}`,
updatedAt: `2024-${String(((i+3)%12)+1).padStart(2,'0')}-${String(((i+7)%28)+1).padStart(2,'0')}`,
lastLogin: `2024-${String(((i+6)%12)+1).padStart(2,'0')}-${String(((i+10)%28)+1).padStart(2,'0')}`,
projects: (i % 12),
tags: i % 2 ? 'typescript' : 'design',
notes: i % 3 ? '' : 'Note',
}))}
.dataActions=${[
{ name: 'View', iconName: 'lucide:eye', type: ['inRow'], actionFunc: async ({ item }) => {} },
{ name: 'Edit', iconName: 'lucide:edit', type: ['inRow'], actionFunc: async ({ item }) => {} },
{ name: 'Delete', iconName: 'lucide:trash', type: ['inRow'], actionFunc: async ({ item }) => {} },
] as ITableAction[]}
></dees-table>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,841 @@
import * as plugins from '../00plugins.js';
import { demoFunc } from './dees-table.demo.js';
import { customElement, html, DeesElement, property, type TemplateResult, directives } from '@design.estate/dees-element';
import { DeesContextmenu } from '../dees-contextmenu.js';
import * as domtools from '@design.estate/dees-domtools';
import { type TIconKey } from '../dees-icon.js';
import { tableStyles } from './styles.js';
import type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
import {
computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
computeEffectiveColumns as computeEffectiveColumnsFn,
getCellValue as getCellValueFn,
getViewData as getViewDataFn,
} from './data.js';
import { compileLucenePredicate } from './lucene.js';
export type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
declare global {
interface HTMLElementTagNameMap {
'dees-table': DeesTable<any>;
}
}
// interfaces moved to ./types.ts and re-exported above
// the table implementation
@customElement('dees-table')
export class DeesTable<T> extends DeesElement {
public static demo = demoFunc;
// INSTANCE
@property({
type: String,
})
public heading1: string = 'heading 1';
@property({
type: String,
})
public heading2: string = 'heading 2';
@property({
type: Array,
})
public data: T[] = [];
// dees-form compatibility -----------------------------------------
@property({
type: String,
})
public key: string;
@property({
type: String,
})
public label: string;
@property({
type: Boolean,
})
public disabled: boolean = false;
@property({
type: Boolean,
})
public required: boolean = false;
get value() {
return this.data;
}
set value(_valueArg) {}
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<DeesTable<T>>();
// end dees-form compatibility -----------------------------------------
/**
* What does a row of data represent?
*/
@property({
type: String,
reflect: true,
})
public dataName: string;
@property({
type: Boolean,
})
searchable: boolean = true;
@property({
type: Array,
})
public dataActions: ITableAction<T>[] = [];
// schema-first columns API
@property({ attribute: false })
public columns: Column<T>[] = [];
/**
* Stable row identity for selection and updates. If provided as a function,
* it is only usable as a property (not via attribute).
*/
@property({ attribute: false })
public rowKey?: keyof T | ((row: T) => string);
/**
* When true and columns are provided, merge any missing columns discovered
* via displayFunction into the effective schema.
*/
@property({ type: Boolean })
public augmentFromDisplayFunction: boolean = false;
@property({
attribute: false,
})
public displayFunction: TDisplayFunction = (itemArg: T) => itemArg as any;
@property({
attribute: false,
})
public reverseDisplayFunction: (itemArg: any) => T = (itemArg: any) => itemArg as T;
@property({
type: Object,
})
public selectedDataRow: T;
@property({
type: Array,
})
public editableFields: string[] = [];
@property({
type: Boolean,
reflect: true,
attribute: 'show-vertical-lines'
})
public showVerticalLines: boolean = false;
@property({
type: Boolean,
reflect: true,
attribute: 'show-horizontal-lines'
})
public showHorizontalLines: boolean = false;
@property({
type: Boolean,
reflect: true,
attribute: 'show-grid'
})
public showGrid: boolean = true;
public files: File[] = [];
public fileWeakMap = new WeakMap();
public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject();
// simple client-side sorting (Phase 1)
@property({ attribute: false })
private sortKey?: string;
@property({ attribute: false })
private sortDir: 'asc' | 'desc' | null = null;
// simple client-side filtering (Phase 1)
@property({ type: String })
public filterText: string = '';
// per-column quick filters
@property({ attribute: false })
public columnFilters: Record<string, string> = {};
@property({ type: Boolean, attribute: 'show-column-filters' })
public showColumnFilters: boolean = false;
@property({ type: Boolean, reflect: true, attribute: 'sticky-header' })
public stickyHeader: boolean = false;
// search row state
@property({ type: String })
public searchMode: 'table' | 'data' | 'server' = 'table';
private __searchTextSub?: { unsubscribe?: () => void };
private __searchModeSub?: { unsubscribe?: () => void };
// selection (Phase 1)
@property({ type: String })
public selectionMode: 'none' | 'single' | 'multi' = 'none';
@property({ attribute: false })
private selectedIds: Set<string> = new Set();
private _rowIdMap = new WeakMap<object, string>();
private _rowIdCounter = 0;
constructor() {
super();
}
public static styles = tableStyles;
public render(): TemplateResult {
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
const effectiveColumns: Column<T>[] = usingColumns
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
const lucenePred = compileLucenePredicate<T>(
this.filterText,
this.searchMode === 'data' ? 'data' : 'table',
effectiveColumns
);
const viewData = getViewDataFn(
this.data,
effectiveColumns,
this.sortKey,
this.sortDir,
this.filterText,
this.columnFilters,
this.searchMode === 'data' ? 'data' : 'table',
lucenePred || undefined
);
(this as any)._lastViewData = viewData;
return html`
<div class="mainbox">
<!-- the heading part -->
<div class="header">
<div class="headingContainer">
<div class="heading heading1">${this.label || this.heading1}</div>
<div class="heading heading2">${this.heading2}</div>
</div>
<div class="headerActions">
${directives.resolveExec(async () => {
const resultArray: TemplateResult[] = [];
for (const action of this.dataActions) {
if (!action.type.includes('header')) continue;
resultArray.push(
html`<div
class="headerAction"
@click=${() => {
action.actionFunc({
item: this.selectedDataRow,
table: this,
});
}}
>
${action.iconName
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
${action.name}`
: action.name}
</div>`
);
}
return resultArray;
})}
</div>
</div>
<div class="headingSeparation"></div>
<div class="searchGrid hidden">
<dees-input-text
.label=${'lucene syntax search'}
.description=${`
You can use the lucene syntax to search for data, e.g.:
\`\`\`
name: "john" AND age: 18
\`\`\`
`}
></dees-input-text>
<dees-input-multitoggle
.label=${'search mode'}
.options=${['table', 'data', 'server']}
.selectedOption=${'table'}
.description=${`
There are three basic modes:
* table: only searches data already in the table
* data: searches original data, ignoring table transforms
* server: searches data on the server
`}
></dees-input-multitoggle>
</div>
<!-- the actual table -->
<style></style>
${this.data.length > 0
? html`
<div class="tableScroll">
<table>
<thead>
<tr>
${this.selectionMode !== 'none'
? html`
<th style="width:42px; text-align:center;">
${this.selectionMode === 'multi'
? html`
<dees-input-checkbox
.value=${this.areAllVisibleSelected()}
.indeterminate=${this.isVisibleSelectionIndeterminate()}
@newValue=${(e: CustomEvent<boolean>) => {
e.stopPropagation();
this.setSelectVisible(e.detail === true);
}}
></dees-input-checkbox>
`
: html``}
</th>
`
: html``}
${effectiveColumns
.filter((c) => !c.hidden)
.map((col) => {
const isSortable = !!col.sortable;
const ariaSort = this.getAriaSort(col);
return html`
<th
role="columnheader"
aria-sort=${ariaSort}
style="${isSortable ? 'cursor: pointer;' : ''}"
@click=${() => (isSortable ? this.toggleSort(col) : null)}
>
${col.header ?? (col.key as any)}
${this.renderSortIndicator(col)}
</th>`;
})}
${(() => {
if (this.dataActions && this.dataActions.length > 0) {
return html` <th class="actionsCol">Actions</th> `;
}
})()}
</tr>
${this.showColumnFilters
? html`<tr class="filtersRow">
${this.selectionMode !== 'none'
? html`<th style="width:42px;"></th>`
: html``}
${effectiveColumns
.filter((c) => !c.hidden)
.map((col) => {
const key = String(col.key);
if (col.filterable === false) return html`<th></th>`;
return html`<th>
<input type="text" placeholder="Filter..." .value=${this.columnFilters[key] || ''}
@input=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
</th>`;
})}
${(() => {
if (this.dataActions && this.dataActions.length > 0) {
return html` <th></th> `;
}
})()}
</tr>`
: html``}
</thead>
<tbody>
${viewData.map((itemArg, rowIndex) => {
const getTr = (elementArg: HTMLElement): HTMLElement => {
if (elementArg.tagName === 'TR') {
return elementArg;
} else {
return getTr(elementArg.parentElement);
}
};
return html`
<tr
@click=${() => {
this.selectedDataRow = itemArg;
if (this.selectionMode === 'single') {
const id = this.getRowId(itemArg);
this.selectedIds.clear();
this.selectedIds.add(id);
this.emitSelectionChange();
this.requestUpdate();
}
}}
@dragenter=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
eventArg.stopPropagation();
const realTarget = getTr(eventArg.target as HTMLElement);
setTimeout(() => {
realTarget.classList.add('hasAttachment');
}, 0);
}}
@dragleave=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
eventArg.stopPropagation();
const realTarget = getTr(eventArg.target as HTMLElement);
realTarget.classList.remove('hasAttachment');
}}
@dragover=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
}}
@drop=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
const newFiles = [];
for (const file of Array.from(eventArg.dataTransfer.files)) {
this.files.push(file);
newFiles.push(file);
this.requestUpdate();
}
const result: File[] = this.fileWeakMap.get(itemArg as object);
if (!result) {
this.fileWeakMap.set(itemArg as object, newFiles);
} else {
result.push(...newFiles);
}
}}
@contextmenu=${async (eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(
eventArg,
this.getActionsForType('contextmenu').map((action) => {
const menuItem: plugins.tsclass.website.IMenuItem = {
name: action.name,
iconName: action.iconName as any,
action: async () => {
await action.actionFunc({
item: itemArg,
table: this,
});
return null;
},
};
return menuItem;
})
);
}}
class="${itemArg === this.selectedDataRow ? 'selected' : ''}"
>
${this.selectionMode !== 'none'
? html`<td style="width:42px; text-align:center;">
<dees-input-checkbox
.value=${this.isRowSelected(itemArg)}
@newValue=${(e: CustomEvent<boolean>) => {
e.stopPropagation();
this.setRowSelected(itemArg, e.detail === true);
}}
></dees-input-checkbox>
</td>`
: html``}
${effectiveColumns
.filter((c) => !c.hidden)
.map((col, colIndex) => {
const value = getCellValueFn(itemArg, col, this.displayFunction);
const content = col.renderer
? col.renderer(value, itemArg, { rowIndex, colIndex, column: col })
: value;
const editKey = String(col.key);
return html`
<td
@dblclick=${(e: Event) => {
const dblAction = this.dataActions.find((actionArg) =>
actionArg.type.includes('doubleClick')
);
if (this.editableFields.includes(editKey)) {
this.handleCellEditing(e, itemArg, editKey);
} else if (dblAction) {
dblAction.actionFunc({ item: itemArg, table: this });
}
}}
>
<div class="innerCellContainer">${content}</div>
</td>
`;
})}
${(() => {
if (this.dataActions && this.dataActions.length > 0) {
return html`
<td class="actionsCol">
<div class="actionsContainer">
${this.getActionsForType('inRow').map(
(actionArg) => html`
<div
class="action"
@click=${() =>
actionArg.actionFunc({
item: itemArg,
table: this,
})}
>
${actionArg.iconName
? html` <dees-icon .icon=${actionArg.iconName}></dees-icon> `
: actionArg.name}
</div>
`
)}
</div>
</td>
`;
}
})()}
</tr>`;
})}
</tbody>
</table>
</div>
`
: html` <div class="noDataSet">No data set!</div> `}
<div class="footer">
<div class="tableStatistics">
${this.data.length} ${this.dataName || 'data rows'} (total) |
${this.selectedDataRow ? '# ' + `${this.data.indexOf(this.selectedDataRow) + 1}` : `No`}
selected
</div>
<div class="footerActions">
${directives.resolveExec(async () => {
const resultArray: TemplateResult[] = [];
for (const action of this.dataActions) {
if (!action.type.includes('footer')) continue;
resultArray.push(
html`<div
class="footerAction"
@click=${() => {
action.actionFunc({
item: this.selectedDataRow,
table: this,
});
}}
>
${action.iconName
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
${action.name}`
: action.name}
</div>`
);
}
return resultArray;
})}
</div>
</div>
</div>
`;
}
public async firstUpdated() {
}
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
super.updated(changedProperties);
this.determineColumnWidths();
if (this.searchable) {
const existing = this.dataActions.find((actionArg) => actionArg.type.includes('header') && actionArg.name === 'Search');
if (!existing) {
this.dataActions.unshift({
name: 'Search',
iconName: 'magnifyingGlass',
type: ['header'],
actionFunc: async () => {
console.log('open search');
const searchGrid = this.shadowRoot.querySelector('.searchGrid');
searchGrid.classList.toggle('hidden');
}
});
console.log(this.dataActions);
this.requestUpdate();
};
// wire search inputs
this.wireSearchInputs();
}
}
private __debounceTimer?: any;
private debounceRun(fn: () => void, ms = 200) {
if (this.__debounceTimer) clearTimeout(this.__debounceTimer);
this.__debounceTimer = setTimeout(fn, ms);
}
private wireSearchInputs() {
const searchTextEl: any = this.shadowRoot?.querySelector('.searchGrid dees-input-text');
const searchModeEl: any = this.shadowRoot?.querySelector('.searchGrid dees-input-multitoggle');
if (searchTextEl && !this.__searchTextSub) {
this.__searchTextSub = searchTextEl.changeSubject.subscribe((el: any) => {
const val: string = el?.value ?? '';
this.debounceRun(() => {
if (this.searchMode === 'server') {
this.dispatchEvent(
new CustomEvent('searchRequest', {
detail: { query: val, mode: 'server' },
bubbles: true,
})
);
} else {
this.setFilterText(val);
}
});
});
}
if (searchModeEl && !this.__searchModeSub) {
this.__searchModeSub = searchModeEl.changeSubject.subscribe((el: any) => {
const mode: string = el?.selectedOption || el?.value || 'table';
if (mode === 'table' || mode === 'data' || mode === 'server') {
this.searchMode = mode as any;
// When switching modes, re-apply current text input
const val: string = searchTextEl?.value ?? '';
this.debounceRun(() => {
if (this.searchMode === 'server') {
this.dispatchEvent(new CustomEvent('searchRequest', { detail: { query: val, mode: 'server' }, bubbles: true }));
} else {
this.setFilterText(val);
}
});
}
});
}
}
public async determineColumnWidths() {
const domtools = await this.domtoolsPromise;
await domtools.convenience.smartdelay.delayFor(0);
// Get the table element
const table = this.shadowRoot.querySelector('table');
if (!table) return;
// Get the first row's cells to measure the widths
const cells = table.rows[0].cells;
const handleColumnByIndex = async (i: number, waitForRenderArg: boolean = false) => {
const done = plugins.smartpromise.defer();
const cell = cells[i];
// Get computed width
const width = window.getComputedStyle(cell).width;
if (cell.textContent.includes('Actions')) {
const neededWidth =
this.dataActions.filter((actionArg) => actionArg.type.includes('inRow')).length * 36;
cell.style.width = `${Math.max(neededWidth, 68)}px`;
} else {
cell.style.width = width;
}
if (waitForRenderArg) {
requestAnimationFrame(() => {
done.resolve();
});
await done.promise;
}
};
if (cells[cells.length - 1].textContent.includes('Actions')) {
await handleColumnByIndex(cells.length - 1, true);
}
for (let i = 0; i < cells.length; i++) {
if (cells[i].textContent.includes('Actions')) {
continue;
}
await handleColumnByIndex(i);
}
table.style.tableLayout = 'fixed';
}
// compute helpers moved to ./data.ts
private toggleSort(col: Column<T>) {
const key = String(col.key);
if (this.sortKey !== key) {
this.sortKey = key;
this.sortDir = 'asc';
} else {
if (this.sortDir === 'asc') this.sortDir = 'desc';
else if (this.sortDir === 'desc') {
this.sortDir = null;
this.sortKey = undefined;
} else this.sortDir = 'asc';
}
this.dispatchEvent(
new CustomEvent('sortChange', {
detail: { key: this.sortKey, dir: this.sortDir },
bubbles: true,
})
);
this.requestUpdate();
}
private getAriaSort(col: Column<T>): 'none' | 'ascending' | 'descending' {
if (String(col.key) !== this.sortKey || !this.sortDir) return 'none';
return this.sortDir === 'asc' ? 'ascending' : 'descending';
}
private renderSortIndicator(col: Column<T>) {
if (String(col.key) !== this.sortKey || !this.sortDir) return html``;
return html`<span style="margin-left:6px; opacity:0.7;">${this.sortDir === 'asc' ? '▲' : '▼'}</span>`;
}
// filtering helpers
public setFilterText(value: string) {
const prev = this.filterText;
this.filterText = value ?? '';
if (prev !== this.filterText) {
this.dispatchEvent(
new CustomEvent('filterChange', {
detail: { text: this.filterText, columns: { ...this.columnFilters } },
bubbles: true,
})
);
this.requestUpdate();
}
}
public setColumnFilter(key: string, value: string) {
this.columnFilters = { ...this.columnFilters, [key]: value };
this.dispatchEvent(
new CustomEvent('filterChange', {
detail: { text: this.filterText, columns: { ...this.columnFilters } },
bubbles: true,
})
);
this.requestUpdate();
}
// selection helpers
private getRowId(row: T): string {
if (this.rowKey) {
if (typeof this.rowKey === 'function') return this.rowKey(row);
return String((row as any)[this.rowKey]);
}
const key = row as any as object;
if (!this._rowIdMap.has(key)) {
this._rowIdMap.set(key, String(++this._rowIdCounter));
}
return this._rowIdMap.get(key);
}
private isRowSelected(row: T): boolean {
return this.selectedIds.has(this.getRowId(row));
}
private toggleRowSelected(row: T) {
const id = this.getRowId(row);
if (this.selectionMode === 'single') {
this.selectedIds.clear();
this.selectedIds.add(id);
} else if (this.selectionMode === 'multi') {
if (this.selectedIds.has(id)) this.selectedIds.delete(id);
else this.selectedIds.add(id);
}
this.emitSelectionChange();
this.requestUpdate();
}
private setRowSelected(row: T, checked: boolean) {
const id = this.getRowId(row);
if (this.selectionMode === 'single') {
this.selectedIds.clear();
if (checked) this.selectedIds.add(id);
} else if (this.selectionMode === 'multi') {
if (checked) this.selectedIds.add(id);
else this.selectedIds.delete(id);
}
this.emitSelectionChange();
this.requestUpdate();
}
private areAllVisibleSelected(): boolean {
const view: T[] = (this as any)._lastViewData || [];
if (view.length === 0) return false;
for (const r of view) {
if (!this.selectedIds.has(this.getRowId(r))) return false;
}
return true;
}
private isVisibleSelectionIndeterminate(): boolean {
const view: T[] = (this as any)._lastViewData || [];
if (view.length === 0) return false;
let count = 0;
for (const r of view) {
if (this.selectedIds.has(this.getRowId(r))) count++;
}
return count > 0 && count < view.length;
}
private setSelectVisible(checked: boolean) {
const view: T[] = (this as any)._lastViewData || [];
if (checked) {
for (const r of view) this.selectedIds.add(this.getRowId(r));
} else {
for (const r of view) this.selectedIds.delete(this.getRowId(r));
}
this.emitSelectionChange();
this.requestUpdate();
}
private emitSelectionChange() {
const selectedIds = Array.from(this.selectedIds);
const selectedRows = this.data.filter((r) => this.selectedIds.has(this.getRowId(r)));
this.dispatchEvent(
new CustomEvent('selectionChange', {
detail: { selectedIds, selectedRows },
bubbles: true,
})
);
}
getActionsForType(typeArg: ITableAction['type'][0]) {
const actions: ITableAction[] = [];
for (const action of this.dataActions) {
if (!action.type.includes(typeArg)) continue;
actions.push(action);
}
return actions;
}
async handleCellEditing(event: Event, itemArg: T, key: string) {
await this.domtoolsPromise;
const target = event.target as HTMLElement;
const originalColor = target.style.color;
target.style.color = 'transparent';
const transformedItem = this.displayFunction(itemArg);
const initialValue = ((transformedItem as any)[key] ?? (itemArg as any)[key] ?? '') as string;
// Create an input element
const input = document.createElement('input');
input.type = 'text';
input.value = initialValue;
const blurInput = async (blurArg = true, saveArg = false) => {
if (blurArg) {
input.blur();
}
if (saveArg) {
itemArg[key] = input.value as any; // Convert string to T (you might need better type casting depending on your data structure)
this.changeSubject.next(this);
}
input.remove();
target.style.color = originalColor;
this.requestUpdate();
};
// When the input loses focus or the Enter key is pressed, update the data
input.addEventListener('blur', () => {
blurInput(false, false);
});
input.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter') {
blurInput(true, true); // This will trigger the blur event handler above
}
});
// Replace the cell's content with the input
target.appendChild(input);
input.focus();
}
}

View File

@@ -0,0 +1,170 @@
import type { Column } from './types.js';
type FilterMode = 'table' | 'data';
export type RowPredicate<T> = (row: T) => boolean;
interface Term {
field?: string; // if undefined, match across all fields
value?: string; // lowercased string
negate?: boolean;
range?: { lower: string; upper: string; inclusive: boolean };
}
interface Clause {
terms: Term[]; // AND across terms
}
interface LuceneQuery {
clauses: Clause[]; // OR across clauses
}
function stripQuotes(s: string): string {
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
return s;
}
function splitByOr(input: string): string[] {
return input.split(/\s+OR\s+/i).map((s) => s.trim()).filter(Boolean);
}
function splitByAnd(input: string): string[] {
return input.split(/\s+AND\s+/i).map((s) => s.trim()).filter(Boolean);
}
function parseTerm(raw: string): Term | null {
if (!raw) return null;
let negate = false;
// handle NOT prefix or leading '-'
const notMatch = raw.match(/^\s*(NOT\s+|-)\s*(.*)$/i);
if (notMatch) {
negate = true;
raw = notMatch[2];
}
// range: field:[lower TO upper]
const rangeMatch = raw.match(/^([^:\s]+)\s*:\s*\[(.*?)\s+TO\s+(.*?)\]$/i);
if (rangeMatch) {
return {
field: rangeMatch[1],
negate,
range: { lower: stripQuotes(rangeMatch[2]).toLowerCase(), upper: stripQuotes(rangeMatch[3]).toLowerCase(), inclusive: true },
};
}
// field:value (value may be quoted)
const m = raw.match(/^([^:\s]+)\s*:\s*("[^"]*"|'[^']*'|[^"'\s]+)$/);
if (m) {
return { field: m[1], value: stripQuotes(m[2]).toLowerCase(), negate };
}
// plain term
if (raw.length > 0) {
return { value: stripQuotes(raw).toLowerCase(), negate };
}
return null;
}
function parseLucene(input: string): LuceneQuery | null {
if (!input) return null;
const clauses = splitByOr(input).map((clauseStr) => {
const terms = splitByAnd(clauseStr)
.map(parseTerm)
.filter((t): t is Term => !!t && !!t.value);
return { terms } as Clause;
}).filter((c) => c.terms.length > 0);
if (clauses.length === 0) return null;
return { clauses };
}
export function compileLucenePredicate<T>(
input: string,
mode: FilterMode,
columns: Column<T>[]
): RowPredicate<T> | null {
const ast = parseLucene(input);
if (!ast) return null;
const colMap = new Map<string, Column<T>>(
columns.map((c) => [String(c.key), c])
);
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const coerce = (s: any) => {
const str = String(s ?? '').toLowerCase();
const num = Number(str);
const date = Date.parse(str);
if (!Number.isNaN(num) && str.trim() !== '') return { t: 'n' as const, v: num };
if (!Number.isNaN(date)) return { t: 'd' as const, v: date };
return { t: 's' as const, v: str };
};
const inRange = (val: any, lower: string, upper: string) => {
const a = coerce(val);
const lo = coerce(lower);
const up = coerce(upper);
// All strings: lexical compare
if (a.t === 's' && lo.t === 's' && up.t === 's') {
const av = a.v as string;
return cmp(av, lo.v as string) >= 0 && cmp(av, up.v as string) <= 0;
}
// All numbers
if (a.t === 'n' && lo.t === 'n' && up.t === 'n') {
const av = a.v as number;
return av >= (lo.v as number) && av <= (up.v as number);
}
// All dates (as numbers)
if (a.t === 'd' && lo.t === 'd' && up.t === 'd') {
const av = a.v as number;
return av >= (lo.v as number) && av <= (up.v as number);
}
// Fallback: compare string forms
const as = String(val ?? '').toLowerCase();
return cmp(as, lower) >= 0 && cmp(as, upper) <= 0;
};
return (row: T) => {
for (const clause of ast.clauses) {
let clauseOk = true;
for (const term of clause.terms) {
let ok = false;
if (term.range && term.field) {
// range compare on field
if (mode === 'data') {
ok = inRange((row as any)[term.field], term.range.lower, term.range.upper);
} else {
const col = colMap.get(term.field);
if (!col || col.hidden) { ok = false; } else {
const val = col.value ? col.value(row) : (row as any)[col.key as any];
ok = inRange(val, term.range.lower, term.range.upper);
}
}
} else if (term.field && term.value != null) {
if (mode === 'data') {
const s = String((row as any)[term.field] ?? '').toLowerCase();
ok = s.includes(term.value);
} else {
const col = colMap.get(term.field);
if (!col || col.hidden === true) { ok = false; }
else {
const val = col.value ? col.value(row) : (row as any)[col.key as any];
const s = String(val ?? '').toLowerCase();
ok = s.includes(term.value);
}
}
} else if (term.value != null) {
// search across all visible/raw fields
if (mode === 'data') {
ok = Object.values(row as any).some((v) => String(v ?? '').toLowerCase().includes(term.value!));
} else {
ok = columns.some((col) => {
if (col.hidden) return false;
const val = col.value ? col.value(row) : (row as any)[col.key as any];
const s = String(val ?? '').toLowerCase();
return s.includes(term.value!);
});
}
}
if (term.negate) ok = !ok;
if (!ok) { clauseOk = false; break; }
}
if (clauseOk) return true;
}
return false;
};
}

View File

@@ -0,0 +1,429 @@
import { cssManager, css, type CSSResult } from '@design.estate/dees-element';
import { cssGeistFontFamily } from '../00fonts.js';
export const tableStyles: CSSResult[] = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
}
.mainbox {
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
font-family: ${cssGeistFontFamily};
font-weight: 400;
font-size: 14px;
display: block;
width: 100%;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 8px;
overflow: hidden;
cursor: default;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
min-height: 64px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.headingContainer {
flex: 1;
}
.heading {
line-height: 1.5;
}
.heading1 {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
letter-spacing: -0.025em;
}
.heading2 {
font-size: 14px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
margin-top: 2px;
}
.headingSeparation {
display: none;
}
.headerActions {
user-select: none;
display: flex;
flex-direction: row;
gap: 8px;
}
.headerAction {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
background: transparent;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.headerAction:hover {
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.headerAction dees-icon {
width: 14px;
height: 14px;
}
.searchGrid {
display: grid;
grid-gap: 16px;
grid-template-columns: 1fr max-content;
padding: 16px 24px;
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(0 0% 3.9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
transition: all 0.2s ease;
}
@media (max-width: 900px) {
.searchGrid {
grid-template-columns: 1fr;
}
}
/* let search mode size to content (no forced width) */
.searchGrid.hidden {
height: 0px;
opacity: 0;
overflow: hidden;
padding: 0px 24px;
border-bottom-width: 0px;
}
.tableScroll {
/* enable horizontal scroll only when content exceeds width */
overflow-x: auto;
/* prevent vertical scroll inside the table container */
overflow-y: hidden;
/* avoid reserving extra space for classic scrollbars where possible */
scrollbar-gutter: stable both-edges;
}
/* Hide horizontal scrollbar entirely when not using sticky header */
:host(:not([sticky-header])) .tableScroll {
-ms-overflow-style: none; /* IE/Edge */
scrollbar-width: none; /* Firefox (hides both axes) */
}
:host(:not([sticky-header])) .tableScroll::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
/* In sticky-header mode, hide only the horizontal scrollbar in WebKit/Blink */
:host([sticky-header]) .tableScroll::-webkit-scrollbar:horizontal {
height: 0px;
}
:host([sticky-header]) .tableScroll {
max-height: var(--table-max-height, 360px);
overflow: auto;
}
table {
/* allow table to grow wider than container so actions column can stick */
width: max-content;
min-width: 100%;
caption-side: bottom;
font-size: 14px;
border-collapse: separate;
border-spacing: 0;
}
.noDataSet {
padding: 48px 24px;
text-align: center;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
}
thead {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
:host([sticky-header]) thead th {
position: sticky;
top: 0;
z-index: 2;
}
tbody tr {
transition: background-color 0.15s ease;
position: relative;
}
/* Default horizontal lines (bottom border only) */
tbody tr {
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
tbody tr:last-child {
border-bottom: none;
}
/* Full horizontal lines when enabled */
:host([show-horizontal-lines]) tbody tr {
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
:host([show-horizontal-lines]) tbody tr:first-child {
border-top: none;
}
:host([show-horizontal-lines]) tbody tr:last-child {
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
tbody tr:hover {
background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.5)', 'hsl(0 0% 14.9% / 0.5)')};
}
/* Column hover effect for better traceability */
td {
position: relative;
}
td::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.3)', 'hsl(0 0% 14.9% / 0.3)')};
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: -1;
}
td:hover::after {
opacity: 1;
}
/* Grid mode - shows both vertical and horizontal lines */
:host([show-grid]) th {
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-left: none;
border-top: none;
}
:host([show-grid]) td {
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-left: none;
border-top: none;
}
:host([show-grid]) th:first-child,
:host([show-grid]) td:first-child {
border-left: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
:host([show-grid]) tbody tr:first-child td {
border-top: none;
}
/* Sticky Actions column (right pinned) */
thead th.actionsCol,
tbody td.actionsCol {
position: sticky;
right: 0;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
}
thead th.actionsCol { z-index: 3; }
tbody td.actionsCol {
z-index: 1;
box-shadow: -1px 0 0 0 ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
tbody tr.selected {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
}
tbody tr.hasAttachment {
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 76.2% 36.3% / 0.1)')};
}
th {
height: 48px;
padding: 12px 24px;
text-align: left;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
letter-spacing: -0.01em;
}
:host([show-vertical-lines]) th {
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
td {
padding: 12px 24px;
vertical-align: middle;
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
}
:host([show-vertical-lines]) td {
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
th:first-child,
td:first-child {
padding-left: 24px;
}
th:last-child,
td:last-child {
padding-right: 24px;
}
:host([show-vertical-lines]) th:last-child,
:host([show-vertical-lines]) td:last-child {
border-right: none;
}
.innerCellContainer {
position: relative;
min-height: 24px;
line-height: 24px;
}
td input {
position: absolute;
top: 4px;
bottom: 4px;
left: 20px;
right: 20px;
width: calc(100% - 40px);
height: calc(100% - 8px);
padding: 0 12px;
outline: none;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
font-family: inherit;
font-size: inherit;
font-weight: inherit;
transition: all 0.15s ease;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
td input:focus {
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.2)', 'hsl(217.2 91.2% 59.8% / 0.2)')};
}
/* filter row */
thead tr.filtersRow th {
padding: 8px 12px 12px 12px;
}
thead tr.filtersRow th input[type='text'] {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
font-size: 13px;
border-radius: 6px;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
}
.actionsContainer {
display: flex;
flex-direction: row;
gap: 4px;
}
.action {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
cursor: pointer;
transition: all 0.15s ease;
}
.action:hover {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.action:active {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 11.8%)')};
}
.action dees-icon {
width: 16px;
height: 16px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
padding: 0 24px;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.tableStatistics {
font-weight: 500;
}
.footerActions {
display: flex;
gap: 8px;
}
.footerActions .footerAction {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
border-radius: 6px;
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
}
.footerActions .footerAction:hover {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.footerActions .footerAction dees-icon {
width: 14px;
height: 14px;
}
`,
];

View File

@@ -0,0 +1,29 @@
import type { TemplateResult } from '@design.estate/dees-element';
import type { TIconKey } from '../dees-icon.js';
export interface ITableActionDataArg<T> {
item: T;
table: any; // avoid circular typing with DeesTable; consumers rely on shape only
}
export interface ITableAction<T = any> {
name: string;
iconName: TIconKey;
useTableBehaviour?: 'upload' | 'cancelUpload' | 'none';
type: ('inRow' | 'contextmenu' | 'doubleClick' | 'footer' | 'header' | 'preview' | 'keyCombination')[];
actionRelevancyCheckFunc?: (itemArg: T) => boolean;
actionFunc: (actionDataArg: ITableActionDataArg<T>) => Promise<any>;
}
export interface Column<T = any> {
key: keyof T | string;
header?: string | TemplateResult;
value?: (row: T) => any;
renderer?: (value: any, row: T, ctx: { rowIndex: number; colIndex: number; column: Column<T> }) => TemplateResult | string;
sortable?: boolean;
/** whether this column participates in per-column quick filtering (default: true) */
filterable?: boolean;
hidden?: boolean;
}
export type TDisplayFunction<T = any> = (itemArg: T) => Record<string, any>;

View File

@@ -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,12 @@ 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 './dees-input-list.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';
@@ -53,7 +57,7 @@ export * from './dees-speechbubble.js';
export * from './dees-spinner.js';
export * from './dees-statsgrid.js';
export * from './dees-stepper.js';
export * from './dees-table.js';
export * from './dees-table/dees-table.js';
export * from './dees-terminal.js';
export * from './dees-toast.js';
export * from './dees-updater.js';

View 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>
`;

View 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;
}
}

View File

@@ -0,0 +1,3 @@
export * from './dees-input-profilepicture.js';
export * from './profilepicture.modal.js';
export * from './profilepicture.cropper.js';

View 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();
}
}

View 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();
}
}

View File

@@ -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 }[] = [];
for (let i = 0; i < blocks.length; i++) {
if (i === draggedIndex) continue; // Skip the dragged block
// 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
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 }))
});
}
/**

View File

@@ -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 -->