Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcb7ca2df3 | |||
| ccbb0415e4 | |||
| 496f54cedd | |||
| 83b5ecebeb | |||
| 53b5cbed07 | |||
| 352fe79791 | |||
| a95d5a96a0 | |||
| ece7bb9a94 | |||
| d42859b7b2 | |||
| f5655ad20b | |||
| d3463f009b | |||
| bb883ce341 | |||
| d9703d3ce3 | |||
| 7b5ba74d8b | |||
| a61f57db13 | |||
| c33ad2e405 | |||
| 4190324cb4 | |||
| 1b108fcc8c | |||
| 0b2675c7e5 | |||
| 12b0aa0aad | |||
| 987ae70e7a | |||
| 3ba673282a | |||
| 20a52d1b3e | |||
| dafcf3834c | |||
| 639672358a | |||
| 671fb7dc66 | |||
| b92966ef28 | |||
| c1102634f3 | |||
| ee470775b2 | |||
| ba0f1602a1 | |||
| 682955212e | |||
| 0410f6c196 | |||
| 24aa7588c5 | |||
| b46fe8fe93 | |||
| b47c2053b5 | |||
| 16bf8001ae | |||
| 792e77f824 | |||
| 9b39196195 | |||
| ad59e3d334 | |||
| 0de4283fae | |||
| 6f9c92a866 | |||
| 0ec2f2aebb | |||
| cd22106597 | |||
| a212536cfa | |||
| 18297d54c4 | |||
| f790ca38d0 | |||
| ce2b42ecd5 | |||
| 09e299bc2e | |||
| bbc7dfe29a | |||
| 49b9e833e8 | |||
| f739bb608e | |||
| 286a6f9088 | |||
| e32b9589a5 | |||
| 6427510c98 | |||
| cf92a423cf | |||
| 3f3677ebaa | |||
| edc15a727c | |||
| 960085145d | |||
| 7fdb4f19a8 | |||
| e21fb79731 | |||
| 05f669a7bd | |||
| 8137d79e18 | |||
| 3b474b7dcc | |||
| e449b413d1 | |||
| 8918dc94bd | |||
| 2c595bf803 | |||
| 75f31a6cec | |||
| b211c0d068 | |||
| 911159ee55 | |||
| c0dbc3c0d0 | |||
| 7eea21c9d4 | |||
| 2f17dea480 | |||
| ce33aff843 | |||
| 09eea844d7 | |||
| 956edf0d63 | |||
| 1db74177b3 | |||
| 1c25554c38 | |||
| 7d1e06701b | |||
| aae4427281 | |||
| 911c51d078 | |||
| 2c12c22666 | |||
| 60a811fd18 | |||
| 9a9aea56da | |||
| 49ad998b2c | |||
| 5066681b3a | |||
| ee22879c00 | |||
| 9b0ff2d856 | |||
| 7e14645ed7 | |||
| 811737adcd | |||
| 7b6c135cd3 | |||
| 46065b2424 | |||
| e76a6c3632 | |||
| 896bc2bbb1 | |||
| 296d254ba2 | |||
| ecad05098f | |||
| 956964f5b9 | |||
| ed73e16bbb | |||
| 7817b4a440 | |||
| 03f25b7f10 | |||
| 24957f02d4 | |||
| fe3cd0591f | |||
| 56f5f5887b | |||
| 2e0bf26301 | |||
| 3d7f5253e8 | |||
| 669f12e822 | |||
| 8b870a8e46 | |||
| 9148f0595a | |||
| ea7da1c9b9 | |||
| 3e81f54e99 | |||
| 65aa9f3c06 | |||
| 82ebd9c556 | |||
| 50aa071e2e | |||
| 807e1ff733 | |||
| 4146a348ab | |||
| bd10b4e64d | |||
| 243ecddd42 | |||
| d7b690621e | |||
| 60951330d1 | |||
| 7095197d07 | |||
| 3ee48e80ad | |||
| 00ad2b0563 | |||
| a57005a49b | |||
| d67a66662d | |||
| c75c5bcd3b | |||
| ad0864cddf | |||
| 9985c29a84 |
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
6
.serena/memories/done_checklist.md
Normal file
6
.serena/memories/done_checklist.md
Normal 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.
|
||||
11
.serena/memories/project_overview.md
Normal file
11
.serena/memories/project_overview.md
Normal 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.
|
||||
5
.serena/memories/style_and_conventions.md
Normal file
5
.serena/memories/style_and_conventions.md
Normal 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).
|
||||
6
.serena/memories/suggested_commands.md
Normal file
6
.serena/memories/suggested_commands.md
Normal 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
67
.serena/project.yml
Normal 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"
|
||||
218
changelog.md
218
changelog.md
@@ -1,5 +1,221 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-09-23 - 1.12.5 - fix(ci)
|
||||
Add local permissions settings for development
|
||||
|
||||
- Adds a new local settings file: .claude/settings.local.json
|
||||
- Provides explicit permission entries for development tasks (allow running pnpm scripts, reading files, searching/replacing patterns, activating project, and helper tooling)
|
||||
- Intended for local dev environment to enable tool automation without changing repository code
|
||||
|
||||
## 2025-09-20 - 1.12.4 - fix(ci)
|
||||
Add local assistant settings to enable permitted dev tooling commands
|
||||
|
||||
- Add a local assistant settings file to configure allowed development tooling commands.
|
||||
- Allows running pnpm scripts, file read/search/replace operations and other local project helper actions.
|
||||
- Local configuration only — does not change library code or public API.
|
||||
|
||||
## 2025-09-19 - 1.12.3 - fix(dees-input-fileupload)
|
||||
Show selected files inside dropzone and improve file upload UX
|
||||
|
||||
- Render the selected file list inside the dropzone container so files are displayed inline with the drop area
|
||||
- Add dropzone--has-files class and styles to visually indicate when files are present
|
||||
- Avoid opening the file selector when clicking on the browse button or inside the file list (prevents accidental re-opening)
|
||||
- Refine file list and file-row styles (sizes, paddings, border radius, hover/background behavior and thumbnail/icon sizes) for a more compact and consistent appearance
|
||||
- Simplify empty-state handling by returning an empty template when no files are present (file list is only rendered when files exist)
|
||||
|
||||
## 2025-09-18 - 1.12.2 - fix(dees-input-wysiwyg)
|
||||
Integrate output format preview into WYSIWYG demo; update plan and add local dev settings
|
||||
|
||||
- Wire output format preview into the WYSIWYG demo (ts_web/elements/dees-input-wysiwyg.demo.ts) by calling setupOutputFormatDemo(editors.meeting, editors.recipe) so HTML/Markdown preview controls are initialized.
|
||||
- Update readme.plan.md: mark the Output Formats review tasks as completed and document that preview controls were added.
|
||||
- Add a local settings file to allow running local tooling tasks (grants permission for pnpm run scripts and related local commands).
|
||||
- No library API or runtime component behavior changed — this is a demo/documentation and local-settings update.
|
||||
|
||||
## 2025-09-18 - 1.12.1 - fix(ci)
|
||||
Add local settings to allow running pnpm scripts and enable dev chat permission
|
||||
|
||||
- Add a repository-local settings file granting permission to run pnpm scripts (Bash(pnpm run:*)) for development tooling.
|
||||
- Enable the mcp__zen__chat permission for local dev workflows.
|
||||
|
||||
## 2025-09-18 - 1.12.0 - feat(dees-stepper)
|
||||
Revamp dees-stepper: modern styling, new steps and improved navigation/validation
|
||||
|
||||
- Visual refresh for dees-stepper: updated card shapes, shadows, refined borders and stronger selected-state visuals for a modern shadcn-inspired look
|
||||
- Improved transitions and animations (transform, box-shadow, filter) for smoother step selection and show/hide behavior
|
||||
- Expanded default/demo steps: replaced small sample with a richer multi-step flow (Account Setup, Profile Details, Contact Information, Team Size, Goals, Brand Preferences, Integrations, Review & Launch)
|
||||
- Enhanced step interactions: safer goNext/goBack handling with boundary checks and reset of validation flags to avoid stale validation state
|
||||
- Better toolbar/controls placement for stepper demo (spacing, counters, accessible back control) and improved keyboard/UX affordances
|
||||
- Minor documentation and meta updates: readme.plan.md extended with dees-stepper plan items and added .claude/settings.local.json
|
||||
|
||||
## 2025-09-18 - 1.11.8 - fix(ci)
|
||||
Add local tool permissions config to allow running pnpm scripts and enable mcp__zen__chat
|
||||
|
||||
- Add local settings file to grant permission to run pnpm scripts (Bash(pnpm run:*))
|
||||
- Enable mcp__zen__chat permission in local tool settings
|
||||
|
||||
## 2025-09-16 - 1.11.7 - fix(readme)
|
||||
Expand README with comprehensive component documentation, examples and developer guide; add local Claude settings
|
||||
|
||||
- Expanded README substantially: installation, component overview, detailed component docs, usage examples, demos and developer guidance
|
||||
- Updated many example snippets and API usage examples (icons, inputs, editor, forms, overlays, charts, etc.) to be more explicit and consistent
|
||||
- Added .claude/settings.local.json to configure local Claude permissions for repository tooling
|
||||
- No runtime or library code changes — documentation and demo content only
|
||||
|
||||
## 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
|
||||
|
||||
- Added 'overscroll-behavior: contain' to .modal .content to ensure proper scroll containment
|
||||
- Applied overscroll-behavior in modal container for enhanced responsiveness on mobile and desktop
|
||||
|
||||
## 2025-06-26 - 1.10.0 - feat(dees-modal)
|
||||
Add mobileFullscreen option to modals for full-screen mobile support
|
||||
|
||||
- Introduced a new boolean property 'mobileFullscreen' in ts_web/elements/dees-modal.ts
|
||||
- Updated modal CSS under the media query to apply 'mobile-fullscreen' class, allowing full viewport modals on mobile devices
|
||||
- Extended modal style rules to include adjustments for margin, border-radius, and maximum heights on smaller screens
|
||||
|
||||
## 2025-06-26 - 1.9.9 - fix(dees-input-multitoggle, dees-input-typelist)
|
||||
Replace dynamic import with static import for demo functions in dees-input-multitoggle and dees-input-typelist
|
||||
|
||||
- Converted `await import('./dees-input-multitoggle.demo.js')` to a direct static import.
|
||||
- Converted `await import('./dees-input-typelist.demo.js')` to a direct static import to improve build performance and clarity.
|
||||
|
||||
## 2025-06-26 - 1.9.8 - fix(deps, windowlayer)
|
||||
Update dependency versions and adjust dees-windowlayer CSS to add pointer-events fix
|
||||
|
||||
@@ -52,7 +268,7 @@ Add dees-searchbar component with live search and filter demo
|
||||
## 2025-04-22 - 1.6.0 - feat(documentation/dees-heading)
|
||||
Add codex documentation overview and dees-heading component demo
|
||||
|
||||
- Introduce 'codex.md' to provide a high-level overview of project layout, component patterns, and build workflow
|
||||
- Introduce contributor overview doc (`codex.md`, now consolidated into `readme.info.md`) to provide a high-level overview of project layout, component patterns, and build workflow
|
||||
- Add and update dees-heading component with demo to support multiple heading levels and horizontal rule styles
|
||||
- Update component export index to include dees-heading
|
||||
|
||||
|
||||
43
codex.md
43
codex.md
@@ -1,43 +0,0 @@
|
||||
# Codex: Project Overview and Codebase Structure
|
||||
|
||||
## Project Overview
|
||||
- Package: `@design.estate/dees-catalog`
|
||||
- Focus: Web Components library providing UI elements and layouts for modern web apps.
|
||||
|
||||
## Directory Layout
|
||||
- ts_web/: TypeScript source files
|
||||
- elements/: Individual Web Component definitions
|
||||
- pages/: Page-level templates for composite layouts
|
||||
- html/: Demo/app entry point loading the bundled scripts
|
||||
- dist_bundle/: Bundled browser JS and source maps
|
||||
- dist_ts_web/: ES module outputs for TypeScript/web consumers
|
||||
- dist_watch/: Watch-mode development bundle with live reload
|
||||
- test/: Browser-based tests using `@push.rocks/tapbundle`
|
||||
|
||||
## Component Patterns
|
||||
- Each component in ts_web/elements/:
|
||||
- Decorated with `@customElement('tag-name')`
|
||||
- Extends `DeesElement` from `@design.estate/dees-element`
|
||||
- Uses `@property` for reactive, reflected attributes
|
||||
- Defines `static styles = [cssManager.defaultStyles, css`...`]`
|
||||
- Implements `render()` returning a Lit `html` template with slots or markup
|
||||
- Exposes a demo via `public static demo` linking to `.demo.ts` files
|
||||
|
||||
## Build & Development Workflow
|
||||
- Install dependencies: `npm install` or `pnpm install`
|
||||
- Build production bundle: `npm run build`
|
||||
- Start dev watch mode: `npm run watch`
|
||||
- Run tests: `npm test` (launches browser fixtures)
|
||||
|
||||
## Theming & Utilities
|
||||
- Default global styles via `cssManager.defaultStyles`
|
||||
- Theme-aware values with `cssManager.bdTheme(light, dark)`
|
||||
- DOM utilities set up in `html/index.ts` using `@design.estate/dees-domtools`
|
||||
|
||||
## Documentation
|
||||
- `readme.md` provides an overview of all components and basic usage
|
||||
- Live examples in `.demo.ts` files
|
||||
accessible via component `demo` static property
|
||||
|
||||
## Updates to this file
|
||||
If you have pattern insisights or general changes to the codebase, please update this file.
|
||||
34
package.json
34
package.json
@@ -1,30 +1,31 @@
|
||||
{
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "1.9.8",
|
||||
"version": "1.12.5",
|
||||
"private": false,
|
||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||
"main": "dist_ts_web/index.js",
|
||||
"typings": "dist_ts_web/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tstest test/ --web --verbose --timeout 30",
|
||||
"test": "tstest test/ --web --verbose --timeout 30 --logfile",
|
||||
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production --bundler esbuild",
|
||||
"watch": "tswatch element",
|
||||
"buildDocs": "tsdoc"
|
||||
"buildDocs": "tsdoc",
|
||||
"postinstall": "node scripts/update-monaco-version.cjs"
|
||||
},
|
||||
"author": "Lossless GmbH",
|
||||
"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.2.0",
|
||||
"@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 +34,21 @@
|
||||
"@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",
|
||||
"lit": "^3.3.1",
|
||||
"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"
|
||||
|
||||
3343
pnpm-lock.yaml
generated
3343
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- mongodb-memory-server
|
||||
- puppeteer
|
||||
@@ -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
1906
readme.icons.md
Normal file
File diff suppressed because it is too large
Load Diff
80
readme.info.md
Normal file
80
readme.info.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Contributor Information
|
||||
|
||||
This reference consolidates the helper notes previously split across `codex.md` and `CLAUDE.md`. Use it to get oriented quickly when working on `@design.estate/dees-catalog`, a TypeScript/Lit web-components library that ships themed UI building blocks for modern web applications.
|
||||
|
||||
## Project Snapshot
|
||||
- Package: `@design.estate/dees-catalog`
|
||||
- Description: Comprehensive catalog of reusable web components with cohesive design, advanced form inputs, data displays, and layout scaffolding.
|
||||
- Entry points: builds ship to `dist_ts_web/` (ES modules) and `dist_bundle/` (browser bundle); demos live in `html/`.
|
||||
- Type system: strict TypeScript targeting modern browsers (see `tsconfig.json`).
|
||||
|
||||
## Repository Layout
|
||||
- `ts_web/` – TypeScript source
|
||||
- `elements/` – component implementations (`00*.ts` shared utilities, `dees-*.ts` components, `*.demo.ts` demos)
|
||||
- `pages/` – showcase pages aggregating demos
|
||||
- `index.ts` – main export surface
|
||||
- `html/` – demo entry point bootstrapping bundles
|
||||
- `dist_bundle/`, `dist_ts_web/`, `dist_watch/` – build outputs (production, module, and watch bundles)
|
||||
- `test/` – browser/node tests powered by `@push.rocks/tapbundle`
|
||||
- `scripts/` – maintenance utilities (e.g., Monaco version sync postinstall)
|
||||
|
||||
## Build & Development Commands
|
||||
All workflows use pnpm (see `package.json`).
|
||||
|
||||
```bash
|
||||
pnpm install # install dependencies
|
||||
pnpm run build # tsbuild tsfolders --allowimplicitany && tsbundle element --production --bundler esbuild
|
||||
pnpm run watch # tswatch element (development watch/dev server)
|
||||
pnpm test # tstest test/ --web --verbose --timeout 30 --logfile
|
||||
pnpm run buildDocs # tsdoc (generates docs)
|
||||
tsx test/test.file.ts # run a specific test file (file must be named test.*)
|
||||
```
|
||||
|
||||
`postinstall` runs `node scripts/update-monaco-version.cjs` to sync the Monaco editor version, so keep the script intact when updating dependencies.
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: `@push.rocks/tapbundle` with smartexpect assertions. Always review https://code.foss.global/push.rocks/smartexpect/raw/branch/master/readme.md when adding tests.
|
||||
- Import pattern:
|
||||
```typescript
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
```
|
||||
- Test naming: `test.*.both.ts` for dual runtime, `.node.ts` for Node-only, `.browser.ts` for browser-only suites.
|
||||
- Prefer `pnpm test` for full runs; use `tsx` for focused debugging. Type-check failing tests with `tsc --noEmit`.
|
||||
- Logs live under `.nogit/testlogs/`; put ad-hoc debug artefacts in `.nogit/debug/`.
|
||||
|
||||
## Component Architecture
|
||||
- **Base pattern**: Components extend `DeesElement` from `@design.estate/dees-element`, use Lit decorators (`@customElement`, `@property`), and combine `cssManager.defaultStyles` with component styles. Rendering happens via Lit `html` templates; demos sit on a static `demo` property referencing a `.demo.ts` module.
|
||||
- **Theming**: `cssManager.bdTheme(light, dark)` selects theme-aware values. Shared palettes live in `ts_web/elements/00colors.ts`.
|
||||
- **Z-index management**: Overlays consult the registry in `ts_web/elements/00zindex.ts` (`ZIndexRegistry`) to coordinate stacking.
|
||||
- **Component families**:
|
||||
- Core UI (`dees-button`, `dees-badge`, `dees-icon`, …) focus on consistent theming and interactions.
|
||||
- Form inputs (`dees-form`, `dees-input-*`) build on `DeesInputBase` and communicate through subjects/events for validation.
|
||||
- Layout shells (`dees-appui-*`) orchestrate responsive app frames with centralized event rebroadcasts.
|
||||
- Data views (`dees-table`, `dees-dataview-*`, `dees-statsgrid`) handle large datasets with virtualisation and chart integrations.
|
||||
- Overlays (`dees-modal`, `dees-contextmenu`, `dees-toast`) respect the z-index registry and use shared window-layer utilities.
|
||||
- **WYSIWYG editor**: `dees-input-wysiwyg` coordinates specialized handler classes (`WysiwygInputHandler`, `WysiwygKeyboardHandler`, drag/drop & modal managers) and global menus (`DeesSlashMenu`, `DeesFormattingMenu`). Rendering is imperative to preserve caret focus.
|
||||
|
||||
## Implementation Guidelines
|
||||
- Import external modules through `ts_web/elements/00plugins.ts`: `import * as plugins from './plugins.ts';` then reference `plugins.moduleName`.
|
||||
- When creating new components:
|
||||
1. Extend `DeesElement` and decorate with `@customElement('dees-component')`.
|
||||
2. Support theming, slots, and accessibility; provide meaningful default styles.
|
||||
3. Expose a `.demo.ts` for the component and re-export via `elements/index.ts`.
|
||||
- Form components must implement `getValue()` / `setValue()` and emit through `changeSubject` while honoring `disabled` and `required` states.
|
||||
- Overlay components retrieve z-indices from the registry, register/unregister on show/hide, and use `DeesWindowLayer` for backdrops when appropriate.
|
||||
- Avoid simplifying away functionality; prefer small, targeted changes and keep compatibility with existing APIs.
|
||||
|
||||
## Common Patterns & Pitfalls
|
||||
- Focus management: schedule DOM updates with `requestAnimationFrame` inside interactive editors to avoid focus loss.
|
||||
- Event handling: stop propagation where nested interactive elements coexist; mix pointer and keyboard handling for accessibility.
|
||||
- Performance: heavy blocks/components may load lazily; charts use debounced observers, tables rely on virtual scrolling. Watch bundle size when adding dependencies.
|
||||
|
||||
## Documentation & Demos
|
||||
- `readme.md` surfaces component overviews; demos in `.demo.ts` illustrate real usage.
|
||||
- Update this `readme.info.md` when architectural patterns or workflows change so contributors stay in sync.
|
||||
|
||||
## Recent Highlights
|
||||
- Z-index registry overhaul enables dynamic stacking control across overlays.
|
||||
- WYSIWYG refactor separated block handlers for maintainability.
|
||||
- Dashboard grid enhancements added live drag-and-drop previews and overlap fixes.
|
||||
- Monaco editor integration now reads the installed version at build time.
|
||||
BIN
readme.plan.md
BIN
readme.plan.md
Binary file not shown.
784
readme.playbook.md
Normal file
784
readme.playbook.md
Normal file
@@ -0,0 +1,784 @@
|
||||
# UI Components Playbook
|
||||
|
||||
This playbook provides comprehensive guidance for creating and maintaining UI components in the @design.estate/dees-catalog library. Follow these patterns and best practices to ensure consistency, maintainability, and quality.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Component Creation Checklist](#component-creation-checklist)
|
||||
2. [Architectural Patterns](#architectural-patterns)
|
||||
3. [Component Types and Base Classes](#component-types-and-base-classes)
|
||||
4. [Theming System](#theming-system)
|
||||
5. [Event Handling](#event-handling)
|
||||
6. [State Management](#state-management)
|
||||
7. [Form Components](#form-components)
|
||||
8. [Overlay Components](#overlay-components)
|
||||
9. [Complex Components](#complex-components)
|
||||
10. [Performance Optimization](#performance-optimization)
|
||||
11. [Focus Management](#focus-management)
|
||||
12. [Demo System](#demo-system)
|
||||
13. [Common Pitfalls and Anti-patterns](#common-pitfalls-and-anti-patterns)
|
||||
14. [Code Examples](#code-examples)
|
||||
|
||||
## Component Creation Checklist
|
||||
|
||||
When creating a new component, follow this checklist:
|
||||
|
||||
- [ ] Choose the appropriate base class (`DeesElement` or `DeesInputBase`)
|
||||
- [ ] Use `@customElement('dees-componentname')` decorator
|
||||
- [ ] Implement consistent theming with `cssManager.bdTheme()`
|
||||
- [ ] Create demo function in separate `.demo.ts` file
|
||||
- [ ] Export component from `ts_web/elements/index.ts`
|
||||
- [ ] Use proper TypeScript types and interfaces (prefix with `I` for interfaces, `T` for types)
|
||||
- [ ] Implement proper event handling with bubbling and composition
|
||||
- [ ] Consider mobile responsiveness
|
||||
- [ ] Add focus states for accessibility
|
||||
- [ ] Clean up resources in `destroy()` method
|
||||
- [ ] Follow lowercase naming convention for files
|
||||
- [ ] Add z-index registry support if it's an overlay component
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### Base Component Structure
|
||||
|
||||
```typescript
|
||||
import { customElement, property, state, css, TemplateResult, html } from '@design.estate/dees-element';
|
||||
import { DeesElement } from '@design.estate/dees-element';
|
||||
import * as cssManager from './00colors.js';
|
||||
import * as demoFunc from './dees-componentname.demo.js';
|
||||
|
||||
@customElement('dees-componentname')
|
||||
export class DeesComponentName extends DeesElement {
|
||||
// Static demo reference
|
||||
public static demo = demoFunc.demoFunc;
|
||||
|
||||
// Public properties (reactive, can be set via attributes)
|
||||
@property({ type: String })
|
||||
public label: string = '';
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public disabled: boolean = false;
|
||||
|
||||
// Internal state (reactive, but not exposed as attributes)
|
||||
@state()
|
||||
private internalState: string = '';
|
||||
|
||||
// Static styles with theme support
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
// Render method
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="main-container">
|
||||
<!-- Component content -->
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Lifecycle methods
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Setup that needs DOM access
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
// One-time initialization after first render
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
public destroy() {
|
||||
// Clean up listeners, observers, registrations
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Patterns
|
||||
|
||||
#### 1. Separation of Concerns (Complex Components)
|
||||
|
||||
For complex components like WYSIWYG editors, separate concerns into handler classes:
|
||||
|
||||
```typescript
|
||||
export class DeesComplexComponent extends DeesElement {
|
||||
// Orchestrator pattern - main component coordinates handlers
|
||||
private inputHandler: InputHandler;
|
||||
private stateHandler: StateHandler;
|
||||
private renderHandler: RenderHandler;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.inputHandler = new InputHandler(this);
|
||||
this.stateHandler = new StateHandler(this);
|
||||
this.renderHandler = new RenderHandler(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Singleton Pattern (Global Components)
|
||||
|
||||
For global UI elements like menus:
|
||||
|
||||
```typescript
|
||||
export class DeesGlobalMenu extends DeesElement {
|
||||
private static instance: DeesGlobalMenu;
|
||||
|
||||
public static getInstance(): DeesGlobalMenu {
|
||||
if (!DeesGlobalMenu.instance) {
|
||||
DeesGlobalMenu.instance = new DeesGlobalMenu();
|
||||
document.body.appendChild(DeesGlobalMenu.instance);
|
||||
}
|
||||
return DeesGlobalMenu.instance;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Registry Pattern (Z-Index Management)
|
||||
|
||||
Use centralized registries for global state:
|
||||
|
||||
```typescript
|
||||
class ComponentRegistry {
|
||||
private static instance: ComponentRegistry;
|
||||
private registry = new WeakMap<HTMLElement, number>();
|
||||
|
||||
public register(element: HTMLElement, value: number) {
|
||||
this.registry.set(element, value);
|
||||
}
|
||||
|
||||
public unregister(element: HTMLElement) {
|
||||
this.registry.delete(element);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Component Types and Base Classes
|
||||
|
||||
### Standard Component (extends DeesElement)
|
||||
|
||||
Use for most UI components:
|
||||
- Buttons, badges, icons
|
||||
- Layout components
|
||||
- Data display components
|
||||
- Overlay components
|
||||
|
||||
### Form Input Component (extends DeesInputBase)
|
||||
|
||||
Use for all form inputs:
|
||||
- Text inputs, dropdowns, checkboxes
|
||||
- Date pickers, file uploads
|
||||
- Rich text editors
|
||||
|
||||
**Required implementations:**
|
||||
```typescript
|
||||
export class DeesInputCustom extends DeesInputBase<ValueType> {
|
||||
// Required: Get current value
|
||||
public getValue(): ValueType {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
// Required: Set value programmatically
|
||||
public setValue(value: ValueType): void {
|
||||
this.value = value;
|
||||
this.changeSubject.next(this); // Notify form
|
||||
}
|
||||
|
||||
// Optional: Custom validation
|
||||
public async validate(): Promise<boolean> {
|
||||
// Custom validation logic
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Theming System
|
||||
|
||||
### DO: Use Theme Functions
|
||||
|
||||
Always use `cssManager.bdTheme()` for colors that change between themes:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333333')};
|
||||
|
||||
// ❌ INCORRECT
|
||||
background: #ffffff; // Hard-coded color
|
||||
color: var(--custom-color); // Custom CSS variable
|
||||
```
|
||||
|
||||
### DO: Use Consistent Color Values
|
||||
|
||||
Reference shared color constants when possible:
|
||||
|
||||
```typescript
|
||||
// From 00colors.ts
|
||||
background: ${cssManager.bdTheme(colors.bright.background, colors.dark.background)};
|
||||
```
|
||||
|
||||
## Event Handling
|
||||
|
||||
### DO: Dispatch Custom Events Properly
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Events bubble and cross shadow DOM
|
||||
this.dispatchEvent(new CustomEvent('dees-componentname-change', {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
|
||||
// ❌ INCORRECT - Event won't propagate properly
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: { value: this.value }
|
||||
// Missing bubbles and composed
|
||||
}));
|
||||
```
|
||||
|
||||
### DO: Use Event Delegation
|
||||
|
||||
For dynamic content, use event delegation:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Single listener for all items
|
||||
this.addEventListener('click', (e: MouseEvent) => {
|
||||
const item = (e.target as HTMLElement).closest('.item');
|
||||
if (item) {
|
||||
this.handleItemClick(item);
|
||||
}
|
||||
});
|
||||
|
||||
// ❌ INCORRECT - Multiple listeners
|
||||
this.items.forEach(item => {
|
||||
item.addEventListener('click', () => this.handleItemClick(item));
|
||||
});
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### DO: Use Appropriate Property Decorators
|
||||
|
||||
```typescript
|
||||
// Public API - use @property
|
||||
@property({ type: String })
|
||||
public label: string;
|
||||
|
||||
// Internal state - use @state
|
||||
@state()
|
||||
private isLoading: boolean = false;
|
||||
|
||||
// Reflect to attribute when needed
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public disabled: boolean = false;
|
||||
```
|
||||
|
||||
### DON'T: Manipulate State in Render
|
||||
|
||||
```typescript
|
||||
// ❌ INCORRECT - Side effects in render
|
||||
public render() {
|
||||
this.counter++; // Don't modify state
|
||||
return html`<div>${this.counter}</div>`;
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Pure render function
|
||||
public render() {
|
||||
return html`<div>${this.counter}</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
## Form Components
|
||||
|
||||
### DO: Extend DeesInputBase
|
||||
|
||||
All form inputs must extend the base class:
|
||||
|
||||
```typescript
|
||||
export class DeesInputNew extends DeesInputBase<string> {
|
||||
// Inherits: key, label, value, required, disabled, validationState
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Emit Changes Consistently
|
||||
|
||||
```typescript
|
||||
private handleInput(e: Event) {
|
||||
this.value = (e.target as HTMLInputElement).value;
|
||||
this.changeSubject.next(this); // Notify form system
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Support Standard Form Properties
|
||||
|
||||
```typescript
|
||||
// All form inputs should support:
|
||||
@property() public key: string;
|
||||
@property() public label: string;
|
||||
@property() public required: boolean = false;
|
||||
@property() public disabled: boolean = false;
|
||||
@property() public validationState: 'valid' | 'warn' | 'invalid';
|
||||
```
|
||||
|
||||
## Overlay Components
|
||||
|
||||
### DO: Use Z-Index Registry
|
||||
|
||||
Never hardcode z-index values:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
import { zIndexRegistry } from './00zindex.js';
|
||||
|
||||
public async show() {
|
||||
this.modalZIndex = zIndexRegistry.getNextZIndex();
|
||||
zIndexRegistry.register(this, this.modalZIndex);
|
||||
this.style.zIndex = `${this.modalZIndex}`;
|
||||
}
|
||||
|
||||
public async hide() {
|
||||
zIndexRegistry.unregister(this);
|
||||
}
|
||||
|
||||
// ❌ INCORRECT
|
||||
public async show() {
|
||||
this.style.zIndex = '9999'; // Hardcoded z-index
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Use Window Layers
|
||||
|
||||
For modal backdrops:
|
||||
|
||||
```typescript
|
||||
import { DeesWindowLayer } from './dees-windowlayer.js';
|
||||
|
||||
private windowLayer: DeesWindowLayer;
|
||||
|
||||
public async show() {
|
||||
this.windowLayer = new DeesWindowLayer();
|
||||
this.windowLayer.zIndex = zIndexRegistry.getNextZIndex();
|
||||
document.body.append(this.windowLayer);
|
||||
}
|
||||
```
|
||||
|
||||
## Complex Components
|
||||
|
||||
### DO: Use Handler Classes
|
||||
|
||||
For complex logic, separate into specialized handlers:
|
||||
|
||||
```typescript
|
||||
// wysiwyg/handlers/input.handler.ts
|
||||
export class InputHandler {
|
||||
constructor(private component: DeesInputWysiwyg) {}
|
||||
|
||||
public handleInput(event: InputEvent) {
|
||||
// Specialized input handling
|
||||
}
|
||||
}
|
||||
|
||||
// Main component orchestrates
|
||||
export class DeesInputWysiwyg extends DeesInputBase {
|
||||
private inputHandler = new InputHandler(this);
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Use Programmatic Rendering
|
||||
|
||||
For performance-critical updates that shouldn't trigger re-renders:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Direct DOM manipulation when needed
|
||||
private updateBlockContent(blockId: string, content: string) {
|
||||
const blockElement = this.shadowRoot.querySelector(`#${blockId}`);
|
||||
if (blockElement) {
|
||||
blockElement.textContent = content; // Direct update
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ INCORRECT - Triggering full re-render
|
||||
private updateBlockContent(blockId: string, content: string) {
|
||||
this.blocks.find(b => b.id === blockId).content = content;
|
||||
this.requestUpdate(); // Unnecessary re-render
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### DO: Debounce Expensive Operations
|
||||
|
||||
```typescript
|
||||
private resizeTimeout: number;
|
||||
|
||||
private handleResize = () => {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
this.resizeTimeout = window.setTimeout(() => {
|
||||
this.updateLayout();
|
||||
}, 250);
|
||||
};
|
||||
```
|
||||
|
||||
### DO: Use Observers Efficiently
|
||||
|
||||
```typescript
|
||||
// Clean up observers
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.resizeObserver?.disconnect();
|
||||
this.mutationObserver?.disconnect();
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Implement Virtual Scrolling
|
||||
|
||||
For large lists:
|
||||
|
||||
```typescript
|
||||
// Only render visible items
|
||||
private getVisibleItems() {
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
const containerHeight = this.scrollContainer.clientHeight;
|
||||
const itemHeight = 50;
|
||||
|
||||
const startIndex = Math.floor(scrollTop / itemHeight);
|
||||
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
|
||||
|
||||
return this.items.slice(startIndex, endIndex);
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Management
|
||||
|
||||
### DO: Handle Focus Timing
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Wait for render
|
||||
async focusInput() {
|
||||
await this.updateComplete;
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
this.inputElement?.focus();
|
||||
}
|
||||
|
||||
// ❌ INCORRECT - Focus too early
|
||||
focusInput() {
|
||||
this.inputElement?.focus(); // Element might not exist
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Prevent Focus Loss
|
||||
|
||||
```typescript
|
||||
// For global menus
|
||||
constructor() {
|
||||
super();
|
||||
// Prevent focus loss when clicking menu
|
||||
this.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Implement Blur Debouncing
|
||||
|
||||
```typescript
|
||||
private blurTimeout: number;
|
||||
|
||||
private handleBlur = () => {
|
||||
clearTimeout(this.blurTimeout);
|
||||
this.blurTimeout = window.setTimeout(() => {
|
||||
// Check if truly blurred
|
||||
if (!this.contains(document.activeElement)) {
|
||||
this.handleTrueBlur();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
```
|
||||
|
||||
## Demo System
|
||||
|
||||
### DO: Create Comprehensive Demos
|
||||
|
||||
Every component needs a demo:
|
||||
|
||||
```typescript
|
||||
// dees-button.demo.ts
|
||||
import { html } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-button>Default Button</dees-button>
|
||||
<dees-button type="primary">Primary Button</dees-button>
|
||||
<dees-button type="danger" disabled>Disabled Danger</dees-button>
|
||||
`;
|
||||
|
||||
// In component file
|
||||
import * as demoFunc from './dees-button.demo.js';
|
||||
|
||||
export class DeesButton extends DeesElement {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Include All Variants
|
||||
|
||||
Show all component states and variations in demos:
|
||||
- Default state
|
||||
- Different types/variants
|
||||
- Disabled state
|
||||
- Loading state
|
||||
- Error states
|
||||
- Edge cases (long text, empty content)
|
||||
|
||||
## Common Pitfalls and Anti-patterns
|
||||
|
||||
### ❌ DON'T: Hardcode Z-Index Values
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
this.style.zIndex = '9999';
|
||||
|
||||
// ✅ CORRECT
|
||||
this.style.zIndex = `${zIndexRegistry.getNextZIndex()}`;
|
||||
```
|
||||
|
||||
### ❌ DON'T: Skip Base Classes
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Form input without base class
|
||||
export class DeesInputCustom extends DeesElement {
|
||||
// Missing standard form functionality
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
export class DeesInputCustom extends DeesInputBase<string> {
|
||||
// Inherits all form functionality
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Forget Theme Support
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
|
||||
// ✅ CORRECT
|
||||
background-color: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
```
|
||||
|
||||
### ❌ DON'T: Create Components Without Demos
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
export class DeesComponent extends DeesElement {
|
||||
// No demo property
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
export class DeesComponent extends DeesElement {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Emit Non-Bubbling Events
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: this.value
|
||||
}));
|
||||
|
||||
// ✅ CORRECT
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: this.value,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
```
|
||||
|
||||
### ❌ DON'T: Skip Cleanup
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
public connectedCallback() {
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Use Inline Styles for Theming
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
<div style="background-color: ${this.darkMode ? '#000' : '#fff'}">
|
||||
|
||||
// ✅ CORRECT
|
||||
<div class="themed-container">
|
||||
// In styles:
|
||||
.themed-container {
|
||||
background-color: ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Forget Mobile Responsiveness
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
:host {
|
||||
width: 800px; // Fixed width
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
:host {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:host {
|
||||
/* Mobile adjustments */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Example: Creating a New Button Variant
|
||||
|
||||
```typescript
|
||||
// dees-special-button.ts
|
||||
import { customElement, property, css, html } from '@design.estate/dees-element';
|
||||
import { DeesElement } from '@design.estate/dees-element';
|
||||
import * as cssManager from './00colors.js';
|
||||
import * as demoFunc from './dees-special-button.demo.js';
|
||||
|
||||
@customElement('dees-special-button')
|
||||
export class DeesSpecialButton extends DeesElement {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
|
||||
@property({ type: String })
|
||||
public text: string = 'Click me';
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public loading: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 8px 16px;
|
||||
background: ${cssManager.bdTheme('#0066ff', '#0044cc')};
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
|
||||
}
|
||||
|
||||
:host([loading]) .button {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<button class="button" ?disabled=${this.loading} @click=${this.handleClick}>
|
||||
${this.loading ? html`<dees-spinner size="small"></dees-spinner>` : this.text}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
this.dispatchEvent(new CustomEvent('special-click', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Creating a Form Input
|
||||
|
||||
```typescript
|
||||
// dees-input-special.ts
|
||||
export class DeesInputSpecial extends DeesInputBase<string> {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<dees-label .label=${this.label} .required=${this.required}>
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.value || ''}
|
||||
?disabled=${this.disabled}
|
||||
@input=${this.handleInput}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
</dees-label>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleInput(e: Event) {
|
||||
this.value = (e.target as HTMLInputElement).value;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.dispatchEvent(new CustomEvent('blur', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
This playbook represents the collective wisdom and patterns found in the @design.estate/dees-catalog component library. Following these guidelines will help you create components that are:
|
||||
|
||||
- **Consistent**: Following established patterns
|
||||
- **Maintainable**: Easy to understand and modify
|
||||
- **Performant**: Optimized for real-world use
|
||||
- **Accessible**: Usable by everyone
|
||||
- **Theme-aware**: Supporting light and dark modes
|
||||
- **Well-integrated**: Working seamlessly with the component ecosystem
|
||||
|
||||
Remember: When in doubt, look at existing components for examples. The codebase itself is the best documentation of these patterns in action.
|
||||
@@ -1,138 +0,0 @@
|
||||
# WYSIWYG Editor Refactoring Progress Summary
|
||||
|
||||
## Latest Updates
|
||||
|
||||
### Selection Highlighting Fix ✅
|
||||
- **Issue**: "Paragraphs are not highlighted consistently, headings are always highlighted"
|
||||
- **Root Cause**: The `shouldUpdate` method in `dees-wysiwyg-block.ts` was using a generic `.block` selector that would match the first element with that class, not necessarily the correct block element
|
||||
- **Solution**: Changed the selector to be more specific: `.block.${blockType}` which ensures the correct element is found for each block type
|
||||
- **Result**: All block types now highlight consistently when selected
|
||||
|
||||
### Enter Key Block Creation Fix ✅
|
||||
- **Issue**: "When pressing enter and jumping to new block then typing something: The cursor is not at the beginning of the new block and there is content"
|
||||
- **Root Cause**: Block handlers were rendering content with template syntax `${block.content || ''}` in their render methods, which violates the static HTML principle
|
||||
- **Solution**:
|
||||
- Removed all `${block.content}` from render methods in paragraph, heading, quote, and code block handlers
|
||||
- Content is now set programmatically in the setup() method only when needed
|
||||
- Fixed `setCursorToStart` and `setCursorToEnd` to always find elements fresh instead of relying on cached `blockElement`
|
||||
- **Result**: New empty blocks remain truly empty, cursor positioning works correctly
|
||||
|
||||
### Backspace Key Deletion Fix ✅
|
||||
- **Issue**: "After typing in a new block, pressing backspace deletes the whole block instead of just the last character"
|
||||
- **Root Cause**:
|
||||
1. `getCursorPositionInElement` was using `element.contains()` which doesn't work across Shadow DOM boundaries
|
||||
2. The backspace handler was checking `block.content === ''` which only contains the stored content, not the actual DOM content
|
||||
- **Solution**:
|
||||
1. Fixed `getCursorPositionInElement` to use `containsAcrossShadowDOM` for proper Shadow DOM support
|
||||
2. Updated backspace handler to get actual content from DOM using `blockComponent.getContent()` instead of relying on stored `block.content`
|
||||
3. Added debug logging to track cursor position and content state
|
||||
- **Result**: Backspace now correctly deletes individual characters instead of the whole block
|
||||
|
||||
### Arrow Left Navigation Fix ✅
|
||||
- **Issue**: "When jumping to the previous block from the beginning of a block with arrow left, the cursor should be at the end of the previous block, not at the start"
|
||||
- **Root Cause**: Browser's default focus behavior places cursor at the beginning of contenteditable elements, overriding our cursor positioning
|
||||
- **Solution**: For 'end' position, set up the selection range BEFORE focusing the element:
|
||||
1. Create a range pointing to the end of content
|
||||
2. Apply the selection
|
||||
3. Then focus the element (which preserves the existing selection)
|
||||
4. Only use setCursorToEnd for empty blocks
|
||||
- **Result**: Arrow left navigation now correctly places cursor at the end of the previous block
|
||||
|
||||
## Completed Phases
|
||||
|
||||
### Phase 1: Infrastructure ✅
|
||||
- Created modular block handler architecture
|
||||
- Implemented `IBlockHandler` interface and `BaseBlockHandler` class
|
||||
- Created `BlockRegistry` for dynamic block type registration
|
||||
- Set up proper file structure under `blocks/` directory
|
||||
|
||||
### Phase 2: Proof of Concept ✅
|
||||
- Successfully migrated divider block as the simplest example
|
||||
- Validated the architecture works correctly
|
||||
- Established patterns for block migration
|
||||
|
||||
### Phase 3: Text Blocks ✅
|
||||
- **Paragraph Block**: Full editing support with text splitting, selection handling, and cursor tracking
|
||||
- **Heading Blocks**: All three heading levels (h1, h2, h3) with unified handler
|
||||
- **Quote Block**: Italic styling with border, full editing capabilities
|
||||
- **Code Block**: Monospace font, tab handling, plain text paste support
|
||||
- **List Block**: Bullet/numbered lists with proper list item management
|
||||
|
||||
## Key Achievements
|
||||
|
||||
### 1. Preserved Critical Knowledge
|
||||
- **Static Rendering**: Blocks use `innerHTML` in `firstUpdated` to prevent focus loss during typing
|
||||
- **Shadow DOM Selection**: Implemented `containsAcrossShadowDOM` utility for proper selection detection
|
||||
- **Cursor Position Tracking**: All editable blocks track cursor position across multiple events
|
||||
- **Content Splitting**: HTML-aware splitting using Range API preserves formatting
|
||||
- **Focus Management**: Microtask-based focus restoration ensures reliable cursor placement
|
||||
|
||||
### 2. Enhanced Architecture
|
||||
- Each block type is now self-contained in its own file
|
||||
- Block handlers are dynamically registered and loaded
|
||||
- Common functionality is shared through base classes
|
||||
- Styles are co-located with their block handlers
|
||||
|
||||
### 3. Maintained Functionality
|
||||
- All keyboard navigation works (arrows, backspace, delete, enter)
|
||||
- Text selection across Shadow DOM boundaries functions correctly
|
||||
- Block merging and splitting behave as before
|
||||
- IME (Input Method Editor) support is preserved
|
||||
- Formatting shortcuts (Cmd/Ctrl+B/I/U/K) continue to work
|
||||
|
||||
## Code Organization
|
||||
|
||||
```
|
||||
ts_web/elements/wysiwyg/
|
||||
├── dees-wysiwyg-block.ts (simplified main component)
|
||||
├── wysiwyg.selection.ts (Shadow DOM selection utilities)
|
||||
├── wysiwyg.blockregistration.ts (handler registration)
|
||||
└── blocks/
|
||||
├── index.ts (exports and registry)
|
||||
├── block.base.ts (base handler interface)
|
||||
├── decorative/
|
||||
│ └── divider.block.ts
|
||||
└── text/
|
||||
├── paragraph.block.ts
|
||||
├── heading.block.ts
|
||||
├── quote.block.ts
|
||||
├── code.block.ts
|
||||
└── list.block.ts
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 4: Media Blocks (In Progress)
|
||||
- Image block with upload/drag-drop support
|
||||
- YouTube block with video embedding
|
||||
- Attachment block for file uploads
|
||||
|
||||
### Phase 5: Content Blocks
|
||||
- Markdown block with preview toggle
|
||||
- HTML block with raw HTML editing
|
||||
|
||||
### Phase 6: Cleanup
|
||||
- Remove old code from main component
|
||||
- Optimize bundle size
|
||||
- Update documentation
|
||||
|
||||
## Technical Improvements
|
||||
|
||||
1. **Modularity**: Each block type is now completely self-contained
|
||||
2. **Extensibility**: New blocks can be added by creating a handler and registering it
|
||||
3. **Maintainability**: Files are smaller and focused on single responsibilities
|
||||
4. **Type Safety**: Strong TypeScript interfaces ensure consistent implementation
|
||||
5. **Performance**: No degradation in performance; potential for lazy loading in future
|
||||
|
||||
## Migration Pattern
|
||||
|
||||
For future block migrations, follow this pattern:
|
||||
|
||||
1. Create block handler extending `BaseBlockHandler`
|
||||
2. Implement required methods: `render()`, `setup()`, `getStyles()`
|
||||
3. Add helper methods for cursor/content management
|
||||
4. Handle Shadow DOM selection properly using utilities
|
||||
5. Register handler in `wysiwyg.blockregistration.ts`
|
||||
6. Test all interactions (typing, selection, navigation)
|
||||
|
||||
The refactoring has been successful in making the codebase more maintainable while preserving all the hard-won functionality and edge case handling from the original implementation.
|
||||
@@ -1,82 +0,0 @@
|
||||
# WYSIWYG Editor Refactoring
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
This refactoring cleaned up the wysiwyg editor implementation to fix focus, cursor position, and selection issues.
|
||||
|
||||
### Phase 1: Code Organization
|
||||
|
||||
#### 1. Removed Duplicate Code
|
||||
- Removed duplicate `handleBlockInput` method from main component (was already in inputHandler)
|
||||
- Removed duplicate `handleBlockKeyDown` method from main component (was already in keyboardHandler)
|
||||
- Consolidated all input handling in the respective handler classes
|
||||
|
||||
#### 2. Simplified Focus Management
|
||||
- Removed complex `updated` lifecycle method that was trying to maintain focus
|
||||
- Simplified `handleBlockBlur` to not immediately close menus
|
||||
- Added `requestAnimationFrame` to focus operations for better timing
|
||||
- Removed `slashMenuOpenTime` tracking which was no longer needed
|
||||
|
||||
#### 3. Fixed Slash Menu Behavior
|
||||
- Changed from `@mousedown` to `@click` events for better UX
|
||||
- Added proper event prevention to avoid focus loss
|
||||
- Menu now closes when clicking outside
|
||||
- Simplified the insertBlock method to close menu first
|
||||
|
||||
### Phase 2: Cursor & Selection Fixes
|
||||
|
||||
#### 4. Enhanced Cursor Position Management
|
||||
- Added `focusWithCursor()` method to block component for precise cursor positioning
|
||||
- Improved `handleSlashCommand` to preserve cursor position when menu opens
|
||||
- Added `getCaretCoordinates()` for accurate menu positioning based on cursor location
|
||||
- Updated `focusBlock()` to support numeric cursor positions
|
||||
|
||||
#### 5. Fixed Selection Across Shadow DOM
|
||||
- Added custom `block-text-selected` event to communicate selections across shadow boundaries
|
||||
- Implemented `handleMouseUp()` in block component to detect selections
|
||||
- Updated main component to listen for selection events from blocks
|
||||
- Selection now works properly even with nested shadow DOMs
|
||||
|
||||
#### 6. Improved Slash Menu Close Behavior
|
||||
- Added optional `clearSlash` parameter to `closeSlashMenu()`
|
||||
- Escape key now properly clears the slash command
|
||||
- Clicking outside clears the slash if menu is open
|
||||
- Selecting an item preserves content and just transforms the block
|
||||
|
||||
### Technical Improvements
|
||||
|
||||
#### Block Component (`dees-wysiwyg-block`)
|
||||
- Better focus management with immediate focus (removed unnecessary requestAnimationFrame)
|
||||
- Added cursor position control methods
|
||||
- Custom event dispatching for cross-shadow-DOM communication
|
||||
- Improved content handling for different block types
|
||||
|
||||
#### Input Handler
|
||||
- Preserves cursor position when showing slash menu
|
||||
- Better caret coordinate calculation for menu positioning
|
||||
- Ensures focus stays in the block when menu appears
|
||||
|
||||
#### Block Operations
|
||||
- Enhanced `focusBlock()` to support start/end/numeric positions
|
||||
- Better timing with requestAnimationFrame for focus operations
|
||||
|
||||
### Key Benefits
|
||||
- Slash menu no longer causes focus or cursor position loss
|
||||
- Text selection works properly across shadow DOM boundaries
|
||||
- Cursor position is preserved when interacting with menus
|
||||
- Cleaner, more maintainable code structure
|
||||
- Better separation of concerns
|
||||
|
||||
## Testing
|
||||
|
||||
Use the test files in `.nogit/debug/`:
|
||||
- `test-slash-menu.html` - Tests slash menu focus behavior
|
||||
- `test-wysiwyg-formatting.html` - Tests text formatting
|
||||
|
||||
## Known Issues Fixed
|
||||
- Slash menu disappearing immediately on first "/"
|
||||
- Focus lost when slash menu opens
|
||||
- Cursor position lost when typing "/"
|
||||
- Text selection not working properly
|
||||
- Selection events not propagating across shadow DOM
|
||||
- Duplicate event handling causing conflicts
|
||||
44
scripts/update-monaco-version.cjs
Executable file
44
scripts/update-monaco-version.cjs
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function resolveMonacoPackageJson() {
|
||||
try {
|
||||
const resolvedPath = require.resolve('monaco-editor/package.json', {
|
||||
paths: [projectRoot],
|
||||
});
|
||||
return resolvedPath;
|
||||
} catch (error) {
|
||||
console.error('[dees-editor] Unable to resolve monaco-editor/package.json');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getMonacoVersion() {
|
||||
const monacoPackagePath = resolveMonacoPackageJson();
|
||||
const monacoPackage = require(monacoPackagePath);
|
||||
if (!monacoPackage.version) {
|
||||
throw new Error('[dees-editor] monaco-editor/package.json does not expose a version field');
|
||||
}
|
||||
return monacoPackage.version;
|
||||
}
|
||||
|
||||
function writeVersionModule(version) {
|
||||
const targetDir = path.join(projectRoot, 'ts_web', 'elements', 'dees-editor');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
const targetFile = path.join(targetDir, 'version.ts');
|
||||
const fileContent = `// Auto-generated by scripts/update-monaco-version.cjs\nexport const MONACO_VERSION = '${version}';\n`;
|
||||
fs.writeFileSync(targetFile, fileContent, 'utf8');
|
||||
console.log(`[dees-editor] Wrote ${path.relative(projectRoot, targetFile)} with monaco-editor@${version}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const version = getMonacoVersion();
|
||||
writeVersionModule(version);
|
||||
} catch (error) {
|
||||
console.error('[dees-editor] Failed to update Monaco version module.');
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
35
test/test.contextmenu-demo.browser.ts
Normal file
35
test/test.contextmenu-demo.browser.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
import { demoFunc } from '../ts_web/elements/dees-contextmenu.demo.js';
|
||||
|
||||
tap.test('should render context menu demo', async () => {
|
||||
// Create demo container
|
||||
const demoContainer = document.createElement('div');
|
||||
document.body.appendChild(demoContainer);
|
||||
|
||||
// Render the demo
|
||||
const demoContent = demoFunc();
|
||||
|
||||
// Create a temporary element to hold the rendered template
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = demoContent.strings.join('');
|
||||
|
||||
// Check that panels are rendered
|
||||
const panels = tempDiv.querySelectorAll('dees-panel');
|
||||
expect(panels.length).toEqual(4);
|
||||
|
||||
// Check panel headings
|
||||
expect(panels[0].getAttribute('heading')).toEqual('Basic Context Menu with Nested Submenus');
|
||||
expect(panels[1].getAttribute('heading')).toEqual('Component-Specific Context Menu');
|
||||
expect(panels[2].getAttribute('heading')).toEqual('Advanced Context Menu Example');
|
||||
expect(panels[3].getAttribute('heading')).toEqual('Static Context Menu (Always Visible)');
|
||||
|
||||
// Check that static context menu exists
|
||||
const staticMenu = tempDiv.querySelector('dees-contextmenu');
|
||||
expect(staticMenu).toBeTruthy();
|
||||
|
||||
// Clean up
|
||||
demoContainer.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
93
test/test.contextmenu-nested-close.browser.ts
Normal file
93
test/test.contextmenu-nested-close.browser.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
|
||||
tap.test('should close all parent menus when clicking action in nested submenu', async () => {
|
||||
let actionCalled = false;
|
||||
|
||||
// Create a test element
|
||||
const testDiv = document.createElement('div');
|
||||
testDiv.style.width = '300px';
|
||||
testDiv.style.height = '300px';
|
||||
testDiv.style.background = '#f0f0f0';
|
||||
testDiv.innerHTML = 'Right-click for nested menu test';
|
||||
document.body.appendChild(testDiv);
|
||||
|
||||
// Simulate right-click to open context menu
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
clientX: 150,
|
||||
clientY: 150,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
// Open context menu with nested structure
|
||||
DeesContextmenu.openContextMenuWithOptions(contextMenuEvent, [
|
||||
{
|
||||
name: 'Parent Item',
|
||||
iconName: 'folder',
|
||||
action: async () => {}, // Parent items with submenus need an action
|
||||
submenu: [
|
||||
{
|
||||
name: 'Child Item',
|
||||
iconName: 'file',
|
||||
action: async () => {
|
||||
actionCalled = true;
|
||||
console.log('Child action called');
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Another Child',
|
||||
iconName: 'fileText',
|
||||
action: async () => console.log('Another child')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Regular Item',
|
||||
iconName: 'box',
|
||||
action: async () => console.log('Regular item')
|
||||
}
|
||||
]);
|
||||
|
||||
// Wait for main menu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Check main menu exists
|
||||
const mainMenu = document.querySelector('dees-contextmenu');
|
||||
expect(mainMenu).toBeInstanceOf(DeesContextmenu);
|
||||
|
||||
// Hover over "Parent Item" to trigger submenu
|
||||
const parentItem = mainMenu!.shadowRoot!.querySelector('.menuitem');
|
||||
expect(parentItem).toBeTruthy();
|
||||
parentItem!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
// Wait for submenu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Check submenu exists
|
||||
const allMenus = document.querySelectorAll('dees-contextmenu');
|
||||
expect(allMenus.length).toEqual(2); // Main menu and submenu
|
||||
|
||||
const submenu = allMenus[1];
|
||||
expect(submenu).toBeTruthy();
|
||||
|
||||
// Click on "Child Item" in submenu
|
||||
const childItem = submenu.shadowRoot!.querySelector('.menuitem');
|
||||
expect(childItem).toBeTruthy();
|
||||
childItem!.click();
|
||||
|
||||
// Wait for menus to close
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Verify action was called
|
||||
expect(actionCalled).toEqual(true);
|
||||
|
||||
// Verify all menus are closed
|
||||
const remainingMenus = document.querySelectorAll('dees-contextmenu');
|
||||
expect(remainingMenus.length).toEqual(0);
|
||||
|
||||
// Clean up
|
||||
testDiv.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
71
test/test.contextmenu-shadowdom.browser.ts
Normal file
71
test/test.contextmenu-shadowdom.browser.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
import { DeesElement, customElement, html } from '@design.estate/dees-element';
|
||||
|
||||
// Create a test element with shadow DOM
|
||||
@customElement('test-shadow-element')
|
||||
class TestShadowElement extends DeesElement {
|
||||
public getContextMenuItems() {
|
||||
return [
|
||||
{ name: 'Shadow Item 1', iconName: 'box', action: async () => console.log('Shadow 1') },
|
||||
{ name: 'Shadow Item 2', iconName: 'package', action: async () => console.log('Shadow 2') }
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div style="padding: 40px; background: #eee; border-radius: 8px;">
|
||||
<h3>Shadow DOM Content</h3>
|
||||
<p>Right-click anywhere inside this shadow DOM</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('should show context menu when right-clicking inside shadow DOM', async () => {
|
||||
// Create the shadow DOM element
|
||||
const shadowElement = document.createElement('test-shadow-element');
|
||||
document.body.appendChild(shadowElement);
|
||||
|
||||
// Wait for element to be ready
|
||||
await shadowElement.updateComplete;
|
||||
|
||||
// Get the content inside shadow DOM
|
||||
const shadowContent = shadowElement.shadowRoot!.querySelector('div');
|
||||
expect(shadowContent).toBeTruthy();
|
||||
|
||||
// Simulate right-click on content inside shadow DOM
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true // Important for shadow DOM
|
||||
});
|
||||
|
||||
shadowContent!.dispatchEvent(contextMenuEvent);
|
||||
|
||||
// Wait for context menu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if context menu is created
|
||||
const contextMenu = document.querySelector('dees-contextmenu');
|
||||
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
|
||||
|
||||
// Check if menu items from shadow element are rendered
|
||||
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
|
||||
expect(menuItems.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Check menu item text
|
||||
const menuTexts = Array.from(menuItems).map(item =>
|
||||
item.querySelector('.menuitem-text')?.textContent
|
||||
);
|
||||
expect(menuTexts).toContain('Shadow Item 1');
|
||||
expect(menuTexts).toContain('Shadow Item 2');
|
||||
|
||||
// Clean up
|
||||
contextMenu!.remove();
|
||||
shadowElement.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
77
test/test.contextmenu.browser.ts
Normal file
77
test/test.contextmenu.browser.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
|
||||
tap.test('should show context menu with nested submenu', async () => {
|
||||
// Create a test element with context menu items
|
||||
const testDiv = document.createElement('div');
|
||||
testDiv.style.width = '200px';
|
||||
testDiv.style.height = '200px';
|
||||
testDiv.style.background = '#eee';
|
||||
testDiv.innerHTML = 'Right-click me';
|
||||
|
||||
// Add getContextMenuItems method
|
||||
(testDiv as any).getContextMenuItems = () => {
|
||||
return [
|
||||
{
|
||||
name: 'Change Type',
|
||||
iconName: 'type',
|
||||
submenu: [
|
||||
{ name: 'Paragraph', iconName: 'text', action: () => console.log('Paragraph') },
|
||||
{ name: 'Heading 1', iconName: 'heading1', action: () => console.log('Heading 1') },
|
||||
{ name: 'Heading 2', iconName: 'heading2', action: () => console.log('Heading 2') },
|
||||
{ divider: true },
|
||||
{ name: 'Code Block', iconName: 'fileCode', action: () => console.log('Code') },
|
||||
{ name: 'Quote', iconName: 'quote', action: () => console.log('Quote') }
|
||||
]
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'trash2',
|
||||
action: () => console.log('Delete')
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
document.body.appendChild(testDiv);
|
||||
|
||||
// Simulate right-click
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
testDiv.dispatchEvent(contextMenuEvent);
|
||||
|
||||
// Wait for context menu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if context menu is created
|
||||
const contextMenu = document.querySelector('dees-contextmenu');
|
||||
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
|
||||
|
||||
// Check if menu items are rendered
|
||||
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
|
||||
expect(menuItems.length).toEqual(2); // "Change Type" and "Delete"
|
||||
|
||||
// Hover over "Change Type" to trigger submenu
|
||||
const changeTypeItem = menuItems[0] as HTMLElement;
|
||||
changeTypeItem.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
// Wait for submenu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Check if submenu is created
|
||||
const submenus = document.querySelectorAll('dees-contextmenu');
|
||||
expect(submenus.length).toEqual(2); // Main menu and submenu
|
||||
|
||||
// Clean up
|
||||
contextMenu!.remove();
|
||||
const submenu = submenus[1];
|
||||
if (submenu) submenu.remove();
|
||||
testDiv.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
28
test/test.dashboardgrid-layout.node.ts
Normal file
28
test/test.dashboardgrid-layout.node.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
|
||||
import {
|
||||
resolveWidgetPlacement,
|
||||
collectCollisions,
|
||||
} from '../ts_web/elements/dees-dashboardgrid/layout.ts';
|
||||
import type { DashboardWidget } from '../ts_web/elements/dees-dashboardgrid/types.ts';
|
||||
|
||||
tap.test('dashboardgrid does not overlap widgets after swap attempt', async () => {
|
||||
const widgets: DashboardWidget[] = [
|
||||
{ id: 'w0', x: 6, y: 5, w: 1, h: 3 },
|
||||
{ id: 'w1', x: 6, y: 1, w: 1, h: 3 },
|
||||
{ id: 'w2', x: 3, y: 0, w: 2, h: 2 },
|
||||
{ id: 'w3', x: 9, y: 0, w: 1, h: 2 },
|
||||
{ id: 'w4', x: 4, y: 3, w: 1, h: 2 },
|
||||
];
|
||||
|
||||
const placement = resolveWidgetPlacement(widgets, 'w0', { x: 6, y: 3 }, 12);
|
||||
expect(placement).toBeTruthy();
|
||||
|
||||
const layout = placement!.widgets;
|
||||
for (const widget of layout) {
|
||||
const collisions = collectCollisions(layout, widget, widget.x, widget.y, widget.w, widget.h);
|
||||
expect(collisions).toBeEmptyArray();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
146
test/test.tabs-indicator.browser.ts
Normal file
146
test/test.tabs-indicator.browser.ts
Normal 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();
|
||||
85
test/test.wysiwyg-blockmovement.browser.ts
Normal file
85
test/test.wysiwyg-blockmovement.browser.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg block movement during drag', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
|
||||
// Start dragging block 1
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: () => {},
|
||||
setDragImage: () => {}
|
||||
},
|
||||
clientY: 50,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Wait for dragging class
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Verify drag state
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Check that drag height was calculated
|
||||
console.log('Checking drag height...');
|
||||
const dragHandler = element.dragDropHandler as any;
|
||||
console.log('draggedBlockHeight:', dragHandler.draggedBlockHeight);
|
||||
console.log('draggedBlockContentHeight:', dragHandler.draggedBlockContentHeight);
|
||||
|
||||
// Manually call updateBlockPositions to simulate drag movement
|
||||
console.log('Simulating drag movement...');
|
||||
const updateBlockPositions = dragHandler.updateBlockPositions.bind(dragHandler);
|
||||
|
||||
// Simulate dragging down past block 2
|
||||
const block2 = editorContent.querySelector('[data-block-id="block2"]') as HTMLElement;
|
||||
const block2Rect = block2.getBoundingClientRect();
|
||||
const dragToY = block2Rect.bottom + 10;
|
||||
|
||||
console.log('Dragging to Y position:', dragToY);
|
||||
updateBlockPositions(dragToY);
|
||||
|
||||
// Check if blocks have moved
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
|
||||
console.log('Block states after drag:');
|
||||
blocks.forEach((block, i) => {
|
||||
const classes = block.className;
|
||||
const offset = (block as HTMLElement).style.getPropertyValue('--drag-offset');
|
||||
console.log(`Block ${i}: classes="${classes}", offset="${offset}"`);
|
||||
});
|
||||
|
||||
// Check that at least one block has move class
|
||||
const movedUpBlocks = editorContent.querySelectorAll('.block-wrapper.move-up');
|
||||
const movedDownBlocks = editorContent.querySelectorAll('.block-wrapper.move-down');
|
||||
console.log('Moved up blocks:', movedUpBlocks.length);
|
||||
console.log('Moved down blocks:', movedDownBlocks.length);
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
98
test/test.wysiwyg-blocktype-change.browser.ts
Normal file
98
test/test.wysiwyg-blocktype-change.browser.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
|
||||
tap.test('should change block type via context menu', async () => {
|
||||
// Create WYSIWYG editor with a paragraph
|
||||
const wysiwygEditor = new DeesInputWysiwyg();
|
||||
wysiwygEditor.value = '<p>This is a test paragraph</p>';
|
||||
document.body.appendChild(wysiwygEditor);
|
||||
|
||||
// Wait for editor to be ready
|
||||
await wysiwygEditor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Get the first block
|
||||
const firstBlock = wysiwygEditor.blocks[0];
|
||||
expect(firstBlock.type).toEqual('paragraph');
|
||||
|
||||
// Get the block element
|
||||
const firstBlockWrapper = wysiwygEditor.shadowRoot!.querySelector('.block-wrapper');
|
||||
expect(firstBlockWrapper).toBeTruthy();
|
||||
|
||||
const blockComponent = firstBlockWrapper!.querySelector('dees-wysiwyg-block') as any;
|
||||
expect(blockComponent).toBeTruthy();
|
||||
await blockComponent.updateComplete;
|
||||
|
||||
// Get the editable content inside the block's shadow DOM
|
||||
const editableBlock = blockComponent.shadowRoot!.querySelector('.block');
|
||||
expect(editableBlock).toBeTruthy();
|
||||
|
||||
// Simulate right-click on the editable block
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
});
|
||||
|
||||
editableBlock!.dispatchEvent(contextMenuEvent);
|
||||
|
||||
// Wait for context menu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if context menu is created
|
||||
const contextMenu = document.querySelector('dees-contextmenu');
|
||||
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
|
||||
|
||||
// Find "Change Type" menu item
|
||||
const menuItems = Array.from(contextMenu!.shadowRoot!.querySelectorAll('.menuitem'));
|
||||
const changeTypeItem = menuItems.find(item =>
|
||||
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Change Type'
|
||||
);
|
||||
expect(changeTypeItem).toBeTruthy();
|
||||
|
||||
// Hover over "Change Type" to trigger submenu
|
||||
changeTypeItem!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
// Wait for submenu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Check if submenu is created
|
||||
const allMenus = document.querySelectorAll('dees-contextmenu');
|
||||
expect(allMenus.length).toEqual(2);
|
||||
|
||||
const submenu = allMenus[1];
|
||||
const submenuItems = Array.from(submenu.shadowRoot!.querySelectorAll('.menuitem'));
|
||||
|
||||
// Find "Heading 1" option
|
||||
const heading1Item = submenuItems.find(item =>
|
||||
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Heading 1'
|
||||
);
|
||||
expect(heading1Item).toBeTruthy();
|
||||
|
||||
// Click on "Heading 1"
|
||||
(heading1Item as HTMLElement).click();
|
||||
|
||||
// Wait for menu to close and block to update
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Verify block type has changed
|
||||
expect(wysiwygEditor.blocks[0].type).toEqual('heading-1');
|
||||
|
||||
// Verify DOM has been updated
|
||||
const updatedBlockComponent = wysiwygEditor.shadowRoot!
|
||||
.querySelector('.block-wrapper')!
|
||||
.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
await updatedBlockComponent.updateComplete;
|
||||
|
||||
const updatedBlock = updatedBlockComponent.shadowRoot!.querySelector('.block');
|
||||
expect(updatedBlock?.classList.contains('heading-1')).toEqual(true);
|
||||
|
||||
// Clean up
|
||||
wysiwygEditor.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
68
test/test.wysiwyg-contextmenu.browser.ts
Normal file
68
test/test.wysiwyg-contextmenu.browser.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
|
||||
tap.test('should show context menu on WYSIWYG blocks', async () => {
|
||||
// Create WYSIWYG editor
|
||||
const wysiwygEditor = new DeesInputWysiwyg();
|
||||
wysiwygEditor.value = '<p>Test paragraph</p><h1>Test heading</h1>';
|
||||
document.body.appendChild(wysiwygEditor);
|
||||
|
||||
// Wait for editor to be ready
|
||||
await wysiwygEditor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Get the first block element
|
||||
const firstBlockWrapper = wysiwygEditor.shadowRoot!.querySelector('.block-wrapper');
|
||||
expect(firstBlockWrapper).toBeTruthy();
|
||||
|
||||
const blockComponent = firstBlockWrapper!.querySelector('dees-wysiwyg-block') as any;
|
||||
expect(blockComponent).toBeTruthy();
|
||||
|
||||
// Wait for block to be ready
|
||||
await blockComponent.updateComplete;
|
||||
|
||||
// Get the editable content inside the block's shadow DOM
|
||||
const editableBlock = blockComponent.shadowRoot!.querySelector('.block');
|
||||
expect(editableBlock).toBeTruthy();
|
||||
|
||||
// Simulate right-click on the editable block
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true // Important for shadow DOM
|
||||
});
|
||||
|
||||
editableBlock!.dispatchEvent(contextMenuEvent);
|
||||
|
||||
// Wait for context menu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if context menu is created
|
||||
const contextMenu = document.querySelector('dees-contextmenu');
|
||||
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
|
||||
|
||||
// Check if menu items from WYSIWYG block are rendered
|
||||
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
|
||||
const menuTexts = Array.from(menuItems).map(item =>
|
||||
item.querySelector('.menuitem-text')?.textContent?.trim()
|
||||
);
|
||||
|
||||
// Should have "Change Type" and "Delete Block" items
|
||||
expect(menuTexts).toContain('Change Type');
|
||||
expect(menuTexts).toContain('Delete Block');
|
||||
|
||||
// Check if "Change Type" has submenu indicator
|
||||
const changeTypeItem = Array.from(menuItems).find(item =>
|
||||
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Change Type'
|
||||
);
|
||||
expect(changeTypeItem?.classList.contains('has-submenu')).toEqual(true);
|
||||
|
||||
// Clean up
|
||||
contextMenu!.remove();
|
||||
wysiwygEditor.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
95
test/test.wysiwyg-dragdrop-simple.browser.ts
Normal file
95
test/test.wysiwyg-dragdrop-simple.browser.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag handler initialization', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Wait for element to be ready
|
||||
await element.updateComplete;
|
||||
|
||||
// Check that drag handler is initialized
|
||||
expect(element.dragDropHandler).toBeTruthy();
|
||||
|
||||
// Set initial content with multiple blocks
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'First paragraph' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Second paragraph' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Check that editor content ref exists
|
||||
console.log('editorContentRef:', element.editorContentRef);
|
||||
expect(element.editorContentRef).toBeTruthy();
|
||||
|
||||
// Check that blocks are rendered
|
||||
const blockWrappers = element.shadowRoot!.querySelectorAll('.block-wrapper');
|
||||
console.log('Number of block wrappers:', blockWrappers.length);
|
||||
expect(blockWrappers.length).toEqual(2);
|
||||
|
||||
// Check drag handles
|
||||
const dragHandles = element.shadowRoot!.querySelectorAll('.drag-handle');
|
||||
console.log('Number of drag handles:', dragHandles.length);
|
||||
expect(dragHandles.length).toEqual(2);
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drag start behavior', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const dragHandle = element.shadowRoot!.querySelector('.drag-handle') as HTMLElement;
|
||||
expect(dragHandle).toBeTruthy();
|
||||
|
||||
// Check that drag handle has draggable attribute
|
||||
console.log('Drag handle draggable:', dragHandle.draggable);
|
||||
expect(dragHandle.draggable).toBeTrue();
|
||||
|
||||
// Test drag handler state before drag
|
||||
console.log('Initial drag state:', element.dragDropHandler.dragState);
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toBeNull();
|
||||
|
||||
// Try to manually call handleDragStart
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {
|
||||
console.log('setData called with:', type, data);
|
||||
},
|
||||
setDragImage: (img: any, x: number, y: number) => {
|
||||
console.log('setDragImage called');
|
||||
}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Check drag state after drag start
|
||||
console.log('Drag state after start:', element.dragDropHandler.dragState);
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
133
test/test.wysiwyg-dragdrop-visual.browser.ts
Normal file
133
test/test.wysiwyg-dragdrop-visual.browser.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag visual feedback - block movement', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
|
||||
// Manually start drag
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {},
|
||||
setDragImage: (img: any, x: number, y: number) => {}
|
||||
},
|
||||
clientY: 50,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Wait for dragging class
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Check dragging state
|
||||
console.log('Block 1 classes:', block1.className);
|
||||
console.log('Editor content classes:', editorContent.className);
|
||||
expect(block1.classList.contains('dragging')).toBeTrue();
|
||||
expect(editorContent.classList.contains('dragging')).toBeTrue();
|
||||
|
||||
// Check drop indicator exists
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
|
||||
console.log('Drop indicator:', dropIndicator);
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Test block movement calculation
|
||||
console.log('Testing updateBlockPositions...');
|
||||
|
||||
// Access private method for testing
|
||||
const updateBlockPositions = element.dragDropHandler['updateBlockPositions'].bind(element.dragDropHandler);
|
||||
|
||||
// Simulate dragging to different position
|
||||
updateBlockPositions(150); // Move down
|
||||
|
||||
// Check if blocks have move classes
|
||||
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
|
||||
console.log('Block classes after move:');
|
||||
blocks.forEach((block, i) => {
|
||||
console.log(`Block ${i}:`, block.className, 'transform:', (block as HTMLElement).style.getPropertyValue('--drag-offset'));
|
||||
});
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drop indicator positioning', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Paragraph 1' },
|
||||
{ id: 'block2', type: 'heading-2', content: 'Heading 2' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
|
||||
// Start dragging first block
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {},
|
||||
setDragImage: (img: any, x: number, y: number) => {}
|
||||
},
|
||||
clientY: 50,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Wait for initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Get drop indicator
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Check initial display state
|
||||
console.log('Drop indicator initial display:', dropIndicator.style.display);
|
||||
|
||||
// Trigger updateBlockPositions to see drop indicator
|
||||
const updateBlockPositions = element.dragDropHandler['updateBlockPositions'].bind(element.dragDropHandler);
|
||||
updateBlockPositions(100);
|
||||
|
||||
// Check drop indicator position
|
||||
console.log('Drop indicator after update:');
|
||||
console.log('- display:', dropIndicator.style.display);
|
||||
console.log('- top:', dropIndicator.style.top);
|
||||
console.log('- height:', dropIndicator.style.height);
|
||||
|
||||
expect(dropIndicator.style.display).toEqual('block');
|
||||
expect(dropIndicator.style.top).toBeTruthy();
|
||||
expect(dropIndicator.style.height).toBeTruthy();
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
172
test/test.wysiwyg-dragdrop.browser.ts
Normal file
172
test/test.wysiwyg-dragdrop.browser.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag and drop should work correctly', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Wait for element to be ready
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content with multiple blocks
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'First paragraph' },
|
||||
{ id: 'block2', type: 'heading-2', content: 'Test Heading' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Second paragraph' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Check that blocks are rendered
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
expect(editorContent).toBeTruthy();
|
||||
|
||||
const blockWrappers = editorContent.querySelectorAll('.block-wrapper');
|
||||
expect(blockWrappers.length).toEqual(3);
|
||||
|
||||
// Test drag handles exist for non-divider blocks
|
||||
const dragHandles = editorContent.querySelectorAll('.drag-handle');
|
||||
expect(dragHandles.length).toEqual(3);
|
||||
|
||||
// Get references to specific blocks
|
||||
const firstBlock = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const secondBlock = editorContent.querySelector('[data-block-id="block2"]') as HTMLElement;
|
||||
const firstDragHandle = firstBlock.querySelector('.drag-handle') as HTMLElement;
|
||||
|
||||
expect(firstBlock).toBeTruthy();
|
||||
expect(secondBlock).toBeTruthy();
|
||||
expect(firstDragHandle).toBeTruthy();
|
||||
|
||||
// Test drag initialization
|
||||
console.log('Testing drag initialization...');
|
||||
|
||||
// Create drag event
|
||||
const dragStartEvent = new DragEvent('dragstart', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 100,
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
// Simulate drag start
|
||||
firstDragHandle.dispatchEvent(dragStartEvent);
|
||||
|
||||
// Check that drag state is initialized
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Check that dragging class is applied
|
||||
await new Promise(resolve => setTimeout(resolve, 20)); // Wait for setTimeout in drag start
|
||||
expect(firstBlock.classList.contains('dragging')).toBeTrue();
|
||||
expect(editorContent.classList.contains('dragging')).toBeTrue();
|
||||
|
||||
// Test drop indicator creation
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator');
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Simulate drag over
|
||||
const dragOverEvent = new DragEvent('dragover', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 200,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
document.dispatchEvent(dragOverEvent);
|
||||
|
||||
// Check that blocks move out of the way
|
||||
console.log('Checking block movements...');
|
||||
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
|
||||
const hasMovedBlocks = blocks.some(block =>
|
||||
block.classList.contains('move-up') || block.classList.contains('move-down')
|
||||
);
|
||||
|
||||
console.log('Blocks with move classes:', blocks.filter(block =>
|
||||
block.classList.contains('move-up') || block.classList.contains('move-down')
|
||||
).length);
|
||||
|
||||
// Test drag end
|
||||
const dragEndEvent = new DragEvent('dragend', {
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
document.dispatchEvent(dragEndEvent);
|
||||
|
||||
// Wait for cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Check that drag state is cleaned up
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toBeNull();
|
||||
expect(firstBlock.classList.contains('dragging')).toBeFalse();
|
||||
expect(editorContent.classList.contains('dragging')).toBeFalse();
|
||||
|
||||
// Check that drop indicator is removed
|
||||
const dropIndicatorAfter = editorContent.querySelector('.drop-indicator');
|
||||
expect(dropIndicatorAfter).toBeFalsy();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drag and drop visual feedback', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const dragHandle1 = block1.querySelector('.drag-handle') as HTMLElement;
|
||||
|
||||
// Start dragging block 1
|
||||
const dragStartEvent = new DragEvent('dragstart', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 50,
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
dragHandle1.dispatchEvent(dragStartEvent);
|
||||
|
||||
// Wait for dragging class
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Simulate dragging down
|
||||
const dragOverEvent = new DragEvent('dragover', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 150, // Move down past block 2
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
// Trigger the global drag over handler
|
||||
element.dragDropHandler['handleGlobalDragOver'](dragOverEvent);
|
||||
|
||||
// Check that transform is applied to dragged block
|
||||
const transform = block1.style.transform;
|
||||
console.log('Dragged block transform:', transform);
|
||||
expect(transform).toContain('translateY');
|
||||
|
||||
// Check drop indicator position
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
|
||||
if (dropIndicator) {
|
||||
const indicatorStyle = dropIndicator.style;
|
||||
console.log('Drop indicator position:', indicatorStyle.top, 'display:', indicatorStyle.display);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
124
test/test.wysiwyg-dragissue.browser.ts
Normal file
124
test/test.wysiwyg-dragissue.browser.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag full flow without await', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Mock drag event
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {
|
||||
console.log('setData:', type, data);
|
||||
},
|
||||
setDragImage: (img: any, x: number, y: number) => {
|
||||
console.log('setDragImage');
|
||||
}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
console.log('Starting drag...');
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
console.log('Drag started');
|
||||
|
||||
// Check immediate state
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Instead of await with setTimeout, use a done callback
|
||||
return new Promise<void>((resolve) => {
|
||||
console.log('Setting up delayed check...');
|
||||
|
||||
// Use regular setTimeout
|
||||
setTimeout(() => {
|
||||
console.log('In setTimeout callback');
|
||||
|
||||
try {
|
||||
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
|
||||
console.log('Block has dragging class:', block1?.classList.contains('dragging'));
|
||||
console.log('Editor has dragging class:', editorContent?.classList.contains('dragging'));
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error('Error in setTimeout:', error);
|
||||
throw error;
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('identify the crash point', async () => {
|
||||
console.log('Test started');
|
||||
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
console.log('Element created');
|
||||
await element.updateComplete;
|
||||
|
||||
console.log('Setting blocks');
|
||||
element.blocks = [{ id: 'block1', type: 'paragraph', content: 'Test' }];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
console.log('Waiting for update');
|
||||
await element.updateComplete;
|
||||
|
||||
console.log('Creating mock event');
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: () => {},
|
||||
setDragImage: () => {}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
console.log('Calling handleDragStart');
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
console.log('handleDragStart completed');
|
||||
|
||||
// Try different wait methods
|
||||
console.log('About to wait...');
|
||||
|
||||
// Method 1: Direct promise
|
||||
await Promise.resolve();
|
||||
console.log('Promise.resolve completed');
|
||||
|
||||
// Method 2: setTimeout 0
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
console.log('setTimeout 0 completed');
|
||||
|
||||
// Method 3: requestAnimationFrame
|
||||
await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)));
|
||||
console.log('requestAnimationFrame completed');
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
console.log('Cleanup completed');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
108
test/test.wysiwyg-dropindicator.browser.ts
Normal file
108
test/test.wysiwyg-dropindicator.browser.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drop indicator creation', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Check editorContentRef
|
||||
console.log('editorContentRef exists:', !!element.editorContentRef);
|
||||
console.log('editorContentRef tagName:', element.editorContentRef?.tagName);
|
||||
expect(element.editorContentRef).toBeTruthy();
|
||||
|
||||
// Check initial state - no drop indicator
|
||||
let dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
|
||||
console.log('Drop indicator before drag:', dropIndicator);
|
||||
expect(dropIndicator).toBeFalsy();
|
||||
|
||||
// Manually call createDropIndicator
|
||||
try {
|
||||
console.log('Calling createDropIndicator...');
|
||||
element.dragDropHandler['createDropIndicator']();
|
||||
console.log('createDropIndicator succeeded');
|
||||
} catch (error) {
|
||||
console.error('Error creating drop indicator:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check drop indicator was created
|
||||
dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
|
||||
console.log('Drop indicator after creation:', dropIndicator);
|
||||
console.log('Drop indicator parent:', dropIndicator?.parentElement?.className);
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
expect(dropIndicator!.style.display).toEqual('none');
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drag initialization with drop indicator', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Mock drag event
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {
|
||||
console.log('setData:', type, data);
|
||||
},
|
||||
setDragImage: (img: any, x: number, y: number) => {
|
||||
console.log('setDragImage');
|
||||
}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
console.log('Starting drag...');
|
||||
|
||||
try {
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
console.log('Drag start succeeded');
|
||||
} catch (error) {
|
||||
console.error('Error during drag start:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait for async operations
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Check drop indicator exists
|
||||
const dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
|
||||
console.log('Drop indicator after drag start:', dropIndicator);
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Check drag state
|
||||
console.log('Drag state:', element.dragDropHandler.dragState);
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
114
test/test.wysiwyg-eventlisteners.browser.ts
Normal file
114
test/test.wysiwyg-eventlisteners.browser.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg global event listeners', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
console.log('Block 1 found:', !!block1);
|
||||
|
||||
// Set up drag state manually without using handleDragStart
|
||||
element.dragDropHandler['draggedBlockId'] = 'block1';
|
||||
element.dragDropHandler['draggedBlockElement'] = block1;
|
||||
element.dragDropHandler['initialMouseY'] = 100;
|
||||
|
||||
// Create drop indicator manually
|
||||
element.dragDropHandler['createDropIndicator']();
|
||||
|
||||
// Test adding global event listeners
|
||||
console.log('Adding event listeners...');
|
||||
const handleGlobalDragOver = element.dragDropHandler['handleGlobalDragOver'];
|
||||
const handleGlobalDragEnd = element.dragDropHandler['handleGlobalDragEnd'];
|
||||
|
||||
try {
|
||||
document.addEventListener('dragover', handleGlobalDragOver);
|
||||
console.log('dragover listener added');
|
||||
|
||||
document.addEventListener('dragend', handleGlobalDragEnd);
|
||||
console.log('dragend listener added');
|
||||
} catch (error) {
|
||||
console.error('Error adding event listeners:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Test firing a dragover event
|
||||
console.log('Creating dragover event...');
|
||||
const dragOverEvent = new Event('dragover', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
Object.defineProperty(dragOverEvent, 'clientY', { value: 150 });
|
||||
|
||||
console.log('Dispatching dragover event...');
|
||||
document.dispatchEvent(dragOverEvent);
|
||||
console.log('dragover event dispatched');
|
||||
|
||||
// Clean up
|
||||
document.removeEventListener('dragover', handleGlobalDragOver);
|
||||
document.removeEventListener('dragend', handleGlobalDragEnd);
|
||||
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg setTimeout in drag start', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
|
||||
// Set drag state
|
||||
element.dragDropHandler['draggedBlockId'] = 'block1';
|
||||
element.dragDropHandler['draggedBlockElement'] = block1;
|
||||
|
||||
console.log('Testing setTimeout callback...');
|
||||
|
||||
// Test the setTimeout callback directly
|
||||
try {
|
||||
if (block1) {
|
||||
console.log('Adding dragging class to block...');
|
||||
block1.classList.add('dragging');
|
||||
console.log('Block classes:', block1.className);
|
||||
}
|
||||
if (editorContent) {
|
||||
console.log('Adding dragging class to editor...');
|
||||
editorContent.classList.add('dragging');
|
||||
console.log('Editor classes:', editorContent.className);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in setTimeout callback:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
expect(block1.classList.contains('dragging')).toBeTrue();
|
||||
expect(editorContent.classList.contains('dragging')).toBeTrue();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-catalog',
|
||||
version: '1.9.8',
|
||||
version: '1.12.5',
|
||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||
}
|
||||
|
||||
53
ts_web/elements/00fonts.ts
Normal file
53
ts_web/elements/00fonts.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { unsafeCSS } from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* Geist Sans font family - Main font for the design system
|
||||
* Already available in the environment, no need to load
|
||||
*/
|
||||
export const geistSansFont = 'Geist Sans';
|
||||
|
||||
/**
|
||||
* Intel One Mono font family - Monospace font for code and technical content
|
||||
* Already available in the environment, no need to load
|
||||
*/
|
||||
export const intelOneMonoFont = 'Intel One Mono';
|
||||
|
||||
/**
|
||||
* Complete font family stacks with fallbacks
|
||||
*/
|
||||
export const geistFontFamily = `'${geistSansFont}', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif`;
|
||||
|
||||
export const monoFontFamily = `'${intelOneMonoFont}', 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace`;
|
||||
|
||||
/**
|
||||
* CSS-ready font family values using unsafeCSS
|
||||
* Use these in component styles
|
||||
*/
|
||||
export const cssGeistFontFamily = unsafeCSS(geistFontFamily);
|
||||
export const cssMonoFontFamily = unsafeCSS(monoFontFamily);
|
||||
|
||||
/**
|
||||
* Cal Sans font for headings - Display font
|
||||
* May need to be loaded separately
|
||||
*/
|
||||
export const calSansFont = 'Cal Sans';
|
||||
export const calSansFontFamily = `'${calSansFont}', ${geistFontFamily}`;
|
||||
export const cssCalSansFontFamily = unsafeCSS(calSansFontFamily);
|
||||
|
||||
/**
|
||||
* Roboto Slab font for special content - Serif font
|
||||
* May need to be loaded separately
|
||||
*/
|
||||
export const robotoSlabFont = 'Roboto Slab';
|
||||
export const robotoSlabFontFamily = `'${robotoSlabFont}', Georgia, serif`;
|
||||
export const cssRobotoSlabFontFamily = unsafeCSS(robotoSlabFontFamily);
|
||||
|
||||
/**
|
||||
* Base font styles that can be applied to components
|
||||
*/
|
||||
export const baseFontStyles = unsafeCSS(`
|
||||
font-family: ${geistFontFamily};
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: 'cv11', 'tnum', 'cv05' 1;
|
||||
`);
|
||||
@@ -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="activityentry">
|
||||
<span class="timestamp">22:15:</span> Max Mustermann changed password
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> created a new invoice
|
||||
</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>
|
||||
|
||||
@@ -5,19 +5,19 @@ import {
|
||||
property,
|
||||
state,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
import * as plugins from './00plugins.js';
|
||||
import { demoFunc } from './dees-appui-appbar.demo.js';
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import * as plugins from '../00plugins.js';
|
||||
import { demoFunc } from './demo.js';
|
||||
import { appuiAppbarStyles } from './styles.js';
|
||||
import { renderAppuiAppbar } from './template.js';
|
||||
|
||||
// Import required components
|
||||
import './dees-icon.js';
|
||||
import './dees-windowcontrols.js';
|
||||
import './dees-appui-profiledropdown.js';
|
||||
import '../dees-icon.js';
|
||||
import '../dees-windowcontrols.js';
|
||||
import '../dees-appui-profiledropdown.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -73,259 +73,16 @@ export class DeesAppuiBar extends DeesElement {
|
||||
@state()
|
||||
private isProfileDropdownOpen: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
/* CSS Variables for theming */
|
||||
--appbar-height: 40px;
|
||||
--appbar-font-size: 12px;
|
||||
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--appbar-height);
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
|
||||
font-size: var(--appbar-font-size);
|
||||
display: grid;
|
||||
grid-template-columns: ${cssManager.cssGridColumns(3, 20)};
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.menus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
position: relative;
|
||||
line-height: 24px;
|
||||
padding: 0px 12px;
|
||||
margin: 8px 0px;
|
||||
border-radius: 4px;
|
||||
-webkit-app-region: no-drag;
|
||||
transition: all 0.2s ease;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Optional: Style for menu items with icons (not typically used for top-level items) */
|
||||
.menuItem dees-icon {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.menuItem.active {
|
||||
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.menuItem[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.menuItem:focus-visible {
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#00000080', '#ffffff80')};
|
||||
}
|
||||
|
||||
|
||||
/* Dropdown styles */
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
border-radius: 4px;
|
||||
box-shadow: ${cssManager.bdTheme('0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.3)')};
|
||||
margin-top: 4px;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item.focused {
|
||||
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.dropdown-item[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown-item .shortcut {
|
||||
margin-left: auto;
|
||||
opacity: 0.6;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Breadcrumbs */
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
|
||||
cursor: default;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Account section */
|
||||
.account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.search-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: default;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
}
|
||||
|
||||
.user-status.online {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.user-status.offline {
|
||||
background: #757575;
|
||||
}
|
||||
|
||||
.user-status.busy {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.user-status.away {
|
||||
background: #ff9800;
|
||||
}
|
||||
`,
|
||||
];
|
||||
public static styles = appuiAppbarStyles;
|
||||
|
||||
// INSTANCE
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="menus">
|
||||
${this.showWindowControls ? html`<dees-windowcontrols></dees-windowcontrols>` : ''}
|
||||
${this.renderMenuItems()}
|
||||
</div>
|
||||
<div class="breadcrumbs">
|
||||
${this.renderBreadcrumbs()}
|
||||
</div>
|
||||
<div class="account">
|
||||
${this.renderAccountSection()}
|
||||
</div>
|
||||
`;
|
||||
return renderAppuiAppbar(this);
|
||||
}
|
||||
|
||||
private renderMenuItems(): TemplateResult {
|
||||
|
||||
|
||||
public renderMenuItems(): TemplateResult {
|
||||
return html`
|
||||
${this.menuItems.map((item, index) => this.renderMenuItem(item, `menu-${index}`))}
|
||||
`;
|
||||
@@ -398,7 +155,7 @@ export class DeesAppuiBar extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBreadcrumbs(): TemplateResult {
|
||||
public renderBreadcrumbs(): TemplateResult {
|
||||
if (!this.breadcrumbs) {
|
||||
return html``;
|
||||
}
|
||||
@@ -417,7 +174,7 @@ export class DeesAppuiBar extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAccountSection(): TemplateResult {
|
||||
public renderAccountSection(): TemplateResult {
|
||||
return html`
|
||||
${this.showSearch ? html`
|
||||
<dees-icon
|
||||
@@ -1,7 +1,8 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import type { DeesAppuiBar } from './dees-appui-appbar.js';
|
||||
import type { IAppBarMenuItem } from './interfaces/appbarmenuitem.js';
|
||||
import type { DeesAppuiBar } from './component.js';
|
||||
import type { IAppBarMenuItem } from '../interfaces/appbarmenuitem.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import './component.js';
|
||||
|
||||
export const demoFunc = () => {
|
||||
// Sample menu items with various configurations
|
||||
3
ts_web/elements/dees-appui-appbar/index.ts
Normal file
3
ts_web/elements/dees-appui-appbar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './component.js';
|
||||
export { appuiAppbarStyles } from './styles.js';
|
||||
export { renderAppuiAppbar } from './template.js';
|
||||
238
ts_web/elements/dees-appui-appbar/styles.ts
Normal file
238
ts_web/elements/dees-appui-appbar/styles.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const appuiAppbarStyles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
/* CSS Variables for theming */
|
||||
--appbar-height: 40px;
|
||||
--appbar-font-size: 12px;
|
||||
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--appbar-height);
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
|
||||
font-size: var(--appbar-font-size);
|
||||
display: grid;
|
||||
grid-template-columns: ${cssManager.cssGridColumns(3, 20)};
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.menus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
position: relative;
|
||||
line-height: 24px;
|
||||
padding: 0px 12px;
|
||||
margin: 8px 0px;
|
||||
border-radius: 4px;
|
||||
-webkit-app-region: no-drag;
|
||||
transition: all 0.2s ease;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Optional: Style for menu items with icons (not typically used for top-level items) */
|
||||
.menuItem dees-icon {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.menuItem.active {
|
||||
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.menuItem[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.menuItem:focus-visible {
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#00000080', '#ffffff80')};
|
||||
}
|
||||
|
||||
|
||||
/* Dropdown styles */
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
border-radius: 4px;
|
||||
box-shadow: ${cssManager.bdTheme('0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.3)')};
|
||||
margin-top: 4px;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item.focused {
|
||||
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.dropdown-item[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown-item .shortcut {
|
||||
margin-left: auto;
|
||||
opacity: 0.6;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Breadcrumbs */
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
|
||||
cursor: default;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Account section */
|
||||
.account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.search-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: default;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
}
|
||||
|
||||
.user-status.online {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.user-status.offline {
|
||||
background: #757575;
|
||||
}
|
||||
|
||||
.user-status.busy {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.user-status.away {
|
||||
background: #ff9800;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
18
ts_web/elements/dees-appui-appbar/template.ts
Normal file
18
ts_web/elements/dees-appui-appbar/template.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import type { DeesAppuiBar } from './component.js';
|
||||
|
||||
export const renderAppuiAppbar = (component: DeesAppuiBar): TemplateResult => {
|
||||
return html`
|
||||
<div class="menus">
|
||||
${component.showWindowControls ? html`<dees-windowcontrols></dees-windowcontrols>` : ''}
|
||||
${component.renderMenuItems()}
|
||||
</div>
|
||||
<div class="breadcrumbs">
|
||||
${component.renderBreadcrumbs()}
|
||||
</div>
|
||||
<div class="account">
|
||||
${component.renderAccountSection()}
|
||||
</div>
|
||||
`;
|
||||
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@design.estate/dees-element';
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
import * as plugins from './00plugins.js';
|
||||
import type { DeesAppuiBar } from './dees-appui-appbar.js';
|
||||
import type { DeesAppuiBar } from './dees-appui-appbar/index.js';
|
||||
import type { DeesAppuiMainmenu } from './dees-appui-mainmenu.js';
|
||||
import type { DeesAppuiMainselector } from './dees-appui-mainselector.js';
|
||||
import type { DeesAppuiMaincontent } from './dees-appui-maincontent.js';
|
||||
@@ -18,7 +18,7 @@ import type { DeesAppuiActivitylog } from './dees-appui-activitylog.js';
|
||||
import { demoFunc } from './dees-appui-base.demo.js';
|
||||
|
||||
// Import child components
|
||||
import './dees-appui-appbar.js';
|
||||
import './dees-appui-appbar/index.js';
|
||||
import './dees-appui-mainmenu.js';
|
||||
import './dees-appui-mainselector.js';
|
||||
import './dees-appui-maincontent.js';
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
})}
|
||||
|
||||
@@ -14,16 +14,95 @@ 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({
|
||||
type: Array,
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.vertical .tabIndicator {
|
||||
display: none;
|
||||
.tabIndicator.no-transition {
|
||||
transition: 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')) {
|
||||
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`;
|
||||
}
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
|
||||
@@ -1,77 +1,421 @@
|
||||
import { html, css } 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`
|
||||
<style>
|
||||
${css`
|
||||
h3 {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
h3 {
|
||||
color: #ccc;
|
||||
}
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-demo {
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.form-demo {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
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;
|
||||
margin: 20px 0;
|
||||
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>
|
||||
|
||||
<h3>Button Types</h3>
|
||||
<dees-button>This is a slotted Text</dees-button>
|
||||
<p>
|
||||
<dees-button text="Highlighted: This text shows" type="highlighted">Highlighted</dees-button>
|
||||
</p>
|
||||
<p><dees-button type="discreet">This is discreete button</dees-button></p>
|
||||
<p><dees-button disabled>This is a disabled button</dees-button></p>
|
||||
<p><dees-button type="big">This is a slotted Text</dees-button></p>
|
||||
<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>
|
||||
<dees-button type="secondary">Secondary</dees-button>
|
||||
<dees-button type="destructive">Destructive</dees-button>
|
||||
<dees-button type="outline">Outline</dees-button>
|
||||
<dees-button type="ghost">Ghost</dees-button>
|
||||
<dees-button type="link">Link Button</dees-button>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<h3>Button States</h3>
|
||||
<p><dees-button status="normal">Normal Status</dees-button></p>
|
||||
<p><dees-button disabled status="pending">Pending Status</dees-button></p>
|
||||
<p><dees-button disabled status="success">Success Status</dees-button></p>
|
||||
<p><dees-button disabled status="error">Error Status</dees-button></p>
|
||||
<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>
|
||||
<dees-button size="default">Default Size</dees-button>
|
||||
<dees-button size="lg">Large Button</dees-button>
|
||||
<dees-button size="icon" type="outline" .text=${'🚀'}></dees-button>
|
||||
</div>
|
||||
|
||||
<h3>Buttons in Forms (Auto-spacing)</h3>
|
||||
<div class="form-demo">
|
||||
<div class="button-group" style="margin-top: 16px;">
|
||||
<dees-button size="sm" type="secondary">Small Secondary</dees-button>
|
||||
<dees-button size="default" type="destructive">Default Destructive</dees-button>
|
||||
<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>
|
||||
<dees-icon iconFA="faPlus"></dees-icon>
|
||||
Add Item
|
||||
</dees-button>
|
||||
<dees-button type="destructive">
|
||||
<dees-icon iconFA="faTrash"></dees-icon>
|
||||
Delete
|
||||
</dees-button>
|
||||
<dees-button type="outline">
|
||||
<dees-icon iconFA="faDownload"></dees-icon>
|
||||
Download
|
||||
</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="icon-row">
|
||||
<dees-button type="secondary" size="sm">
|
||||
<dees-icon iconFA="faCog"></dees-icon>
|
||||
Settings
|
||||
</dees-button>
|
||||
<dees-button type="ghost">
|
||||
<dees-icon iconFA="faChevronLeft"></dees-icon>
|
||||
Back
|
||||
</dees-button>
|
||||
<dees-button type="ghost">
|
||||
Next
|
||||
<dees-icon iconFA="faChevronRight"></dees-icon>
|
||||
</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="icon-row">
|
||||
<dees-button size="icon" type="default">
|
||||
<dees-icon iconFA="faPlus"></dees-icon>
|
||||
</dees-button>
|
||||
<dees-button size="icon" type="secondary">
|
||||
<dees-icon iconFA="faCog"></dees-icon>
|
||||
</dees-button>
|
||||
<dees-button size="icon" type="outline">
|
||||
<dees-icon iconFA="faSearch"></dees-icon>
|
||||
</dees-button>
|
||||
<dees-button size="icon" type="ghost">
|
||||
<dees-icon iconFA="faEllipsisV"></dees-icon>
|
||||
</dees-button>
|
||||
<dees-button size="icon" type="destructive">
|
||||
<dees-icon iconFA="faTrash"></dees-icon>
|
||||
</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>
|
||||
<dees-button status="pending">Processing...</dees-button>
|
||||
<dees-button status="success">Success!</dees-button>
|
||||
<dees-button status="error">Error!</dees-button>
|
||||
<dees-button disabled>Disabled</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="button-group" style="margin-top: 16px;">
|
||||
<dees-button type="secondary" status="pending" size="sm">Small Loading</dees-button>
|
||||
<dees-button type="outline" status="pending">Default Loading</dees-button>
|
||||
<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>Click Me</dees-button>
|
||||
<dees-button type="secondary" .eventDetailData=${'custom-data-123'}>
|
||||
Click with Data
|
||||
</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>
|
||||
<dees-input-text label="Name" key="name"></dees-input-text>
|
||||
<dees-input-text label="Email" key="email"></dees-input-text>
|
||||
<dees-button>Save Draft</dees-button>
|
||||
<dees-button type="highlighted">Save and Continue</dees-button>
|
||||
<dees-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>
|
||||
|
||||
<dees-button type="secondary">Save Draft</dees-button>
|
||||
<dees-button type="ghost">Cancel</dees-button>
|
||||
<dees-form-submit>Submit Form</dees-form-submit>
|
||||
</dees-form>
|
||||
</div>
|
||||
|
||||
<h3>Buttons Outside Forms (No auto-spacing)</h3>
|
||||
<div id="form-output" class="demo-output" style="white-space: pre-wrap;">
|
||||
<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>Button 1</dees-button>
|
||||
<dees-button>Button 2</dees-button>
|
||||
<dees-button>Button 3</dees-button>
|
||||
<dees-button type="normal">Normal → Default</dees-button>
|
||||
<dees-button type="highlighted">Highlighted → Destructive</dees-button>
|
||||
<dees-button type="discreet">Discreet → Outline</dees-button>
|
||||
<dees-button type="big">Big → Large Size</dees-button>
|
||||
</div>
|
||||
|
||||
<h3>Manual Form Spacing</h3>
|
||||
<div>
|
||||
<dees-button inside-form="true">Manually spaced button 1</dees-button>
|
||||
<dees-button inside-form="true">Manually spaced button 2</dees-button>
|
||||
<p style="margin-top: 16px; font-size: 14px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};">
|
||||
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">
|
||||
<h4 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500;">Action Group</h4>
|
||||
<dees-button type="default" size="sm">
|
||||
<dees-icon iconFA="faSave"></dees-icon>
|
||||
Save Changes
|
||||
</dees-button>
|
||||
<dees-button type="secondary" size="sm">
|
||||
<dees-icon iconFA="faUndo"></dees-icon>
|
||||
Discard
|
||||
</dees-button>
|
||||
<dees-button type="ghost" size="sm">
|
||||
<dees-icon iconFA="faQuestionCircle"></dees-icon>
|
||||
Help
|
||||
</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="vertical-group">
|
||||
<h4 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500;">Danger Zone</h4>
|
||||
<dees-button type="destructive" size="sm">
|
||||
<dees-icon iconFA="faTrash"></dees-icon>
|
||||
Delete Account
|
||||
</dees-button>
|
||||
<dees-button type="outline" size="sm">
|
||||
<dees-icon iconFA="faArchive"></dees-icon>
|
||||
Archive Data
|
||||
</dees-button>
|
||||
<dees-button type="ghost" size="sm" disabled>
|
||||
<dees-icon iconFA="faBan"></dees-icon>
|
||||
Not Available
|
||||
</dees-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<h4 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500;">Code Example:</h4>
|
||||
<div class="code-snippet">
|
||||
<dees-button type="default" size="sm" @clicked="\${handleClick}"><br>
|
||||
<dees-icon iconFA="faSave"></dees-icon><br>
|
||||
Save Changes<br>
|
||||
</dees-button>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { demoFunc } from './dees-button.demo.js';
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { demoFunc } from './dees-button.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -48,7 +48,12 @@ export class DeesButton extends DeesElement {
|
||||
@property({
|
||||
type: String
|
||||
})
|
||||
public type: 'normal' | 'highlighted' | 'discreet' | 'big' = 'normal';
|
||||
public type: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' | 'normal' | 'highlighted' | 'discreet' | 'big' = 'default';
|
||||
|
||||
@property({
|
||||
type: String
|
||||
})
|
||||
public size: 'default' | 'sm' | 'lg' | 'icon' = 'default';
|
||||
|
||||
@property({
|
||||
type: String
|
||||
@@ -77,25 +82,23 @@ export class DeesButton extends DeesElement {
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Geist Sans', 'monospace';
|
||||
font-family: inherit;
|
||||
}
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Form spacing styles */
|
||||
/* Default vertical form layout */
|
||||
:host([inside-form]) {
|
||||
margin-bottom: 16px; /* Using standard 16px like inputs */
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:host([inside-form]:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Horizontal form layout - auto-detected via parent */
|
||||
dees-form[horizontal-layout] :host([inside-form]) {
|
||||
display: inline-block;
|
||||
margin-right: 16px;
|
||||
@@ -107,114 +110,260 @@ export class DeesButton extends DeesElement {
|
||||
}
|
||||
|
||||
.button {
|
||||
transition: all 0.1s , color 0s;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: ${cssManager.bdTheme('#fff', '#333')};
|
||||
box-shadow: ${cssManager.bdTheme('0px 1px 3px rgba(0,0,0,0.3)', 'none')};
|
||||
border: 1px solid ${cssManager.bdTheme('#eee', '#333')};
|
||||
border-top: ${cssManager.bdTheme('1px solid #eee', '1px solid #444')};
|
||||
border-radius: 4px;
|
||||
height: 40px;
|
||||
padding: 0px 8px;
|
||||
min-width: 100px;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: ${cssManager.bdTheme('#333', ' #ccc')};
|
||||
max-width: 500px;
|
||||
outline: none;
|
||||
letter-spacing: -0.01em;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #0050b9;
|
||||
color: #ffffff;
|
||||
border: 1px solid #0050b9;
|
||||
border-top: 1px solid #0050b9;
|
||||
/* Size variants */
|
||||
.button.size-default {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
background: #0069f2;
|
||||
border-top: 1px solid #0069f2;
|
||||
.button.size-sm {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.button.highlighted {
|
||||
background: #e4002b;
|
||||
.button.size-lg {
|
||||
height: 44px;
|
||||
padding: 0 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.button.size-icon {
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Default variant */
|
||||
.button.default {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20.2% 11.8%)')};
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 16.8%)')};
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.button.default:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 10.2%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 85%)', 'hsl(215 20.2% 20%)')};
|
||||
}
|
||||
|
||||
.button.default:active:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 9%)')};
|
||||
}
|
||||
|
||||
/* Destructive variant */
|
||||
.button.destructive {
|
||||
background: hsl(0 84.2% 60.2%);
|
||||
color: hsl(0 0% 98%);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button.destructive:hover:not(.disabled) {
|
||||
background: hsl(0 84.2% 56.2%);
|
||||
}
|
||||
|
||||
.button.destructive:active:not(.disabled) {
|
||||
background: hsl(0 84.2% 52.2%);
|
||||
}
|
||||
|
||||
/* Outline variant */
|
||||
.button.outline {
|
||||
background: transparent;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 21.8%)')};
|
||||
}
|
||||
|
||||
.button.outline:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 85%)', 'hsl(215 20.2% 26.8%)')};
|
||||
}
|
||||
|
||||
.button.outline:active:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 13.8%)')};
|
||||
}
|
||||
|
||||
/* Secondary variant */
|
||||
.button.secondary {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button.secondary:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 13.8%)')};
|
||||
}
|
||||
|
||||
.button.secondary:active:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 85%)', 'hsl(215 20.2% 11.8%)')};
|
||||
}
|
||||
|
||||
/* Ghost variant */
|
||||
.button.ghost {
|
||||
background: transparent;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button.ghost:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
}
|
||||
|
||||
.button.ghost:active:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 13.8%)')};
|
||||
}
|
||||
|
||||
/* Link variant */
|
||||
.button.link {
|
||||
background: transparent;
|
||||
color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
border: none;
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: transparent;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.button.highlighted:hover {
|
||||
background: #b50021;
|
||||
border: none;
|
||||
color: #fff;
|
||||
.button.link:hover:not(.disabled) {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
.button.discreet {
|
||||
background: none;
|
||||
border: 1px solid #9b9b9e;
|
||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
||||
/* Status states */
|
||||
.button.pending,
|
||||
.button.success,
|
||||
.button.error {
|
||||
pointer-events: none;
|
||||
padding-left: 36px; /* Space for spinner */
|
||||
}
|
||||
|
||||
.button.discreet:hover {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
.button.size-sm.pending,
|
||||
.button.size-sm.success,
|
||||
.button.size-sm.error {
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.button.size-lg.pending,
|
||||
.button.size-lg.success,
|
||||
.button.size-lg.error {
|
||||
padding-left: 44px;
|
||||
}
|
||||
|
||||
.button.pending {
|
||||
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(213.1 93.9% 67.8% / 0.2)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button.success {
|
||||
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3% / 0.2)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(142.1 70.6% 45.3%)')};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button.error {
|
||||
background: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 62.8% 70.6% / 0.2)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 62.8% 70.6%)')};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
.button.disabled {
|
||||
background: ${cssManager.bdTheme('#ffffff00', '#11111100')};
|
||||
border: 1px dashed ${cssManager.bdTheme('#666666', '#666666')};
|
||||
color: #9b9b9e;
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hidden state */
|
||||
.button.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button.big {
|
||||
width: 300px;
|
||||
line-height: 48px;
|
||||
font-size: 16px;
|
||||
padding: 0px 48px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.button.pending {
|
||||
border: 1px dashed ${cssManager.bdTheme('#0069f2', '#0069f270')};
|
||||
background: ${cssManager.bdTheme('#0069f2', '#0069f270')};
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button.success {
|
||||
border: 1px dashed ${cssManager.bdTheme('#689F38', '#8BC34A70')};
|
||||
background: ${cssManager.bdTheme('#689F38', '#8BC34A70')};
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button.error {
|
||||
border: 1px dashed ${cssManager.bdTheme('#B71C1C', '#E64A1970')};
|
||||
background: ${cssManager.bdTheme('#B71C1C', '#E64A1970')};
|
||||
color: #fff;
|
||||
/* Focus state */
|
||||
.button:focus-visible {
|
||||
outline: 2px solid ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
dees-spinner {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.button.size-sm dees-spinner {
|
||||
left: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.button.size-lg dees-spinner {
|
||||
left: 14px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Icon sizing within buttons */
|
||||
.button dees-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.button.size-sm dees-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.button.size-lg dees-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
// Map old types to new types for backward compatibility
|
||||
const typeMap: {[key: string]: string} = {
|
||||
'normal': 'default',
|
||||
'highlighted': 'destructive',
|
||||
'discreet': 'outline',
|
||||
'big': 'default' // Will use size instead
|
||||
};
|
||||
|
||||
const actualType = typeMap[this.type] || this.type;
|
||||
const actualSize = this.type === 'big' ? 'lg' : this.size;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="button ${this.isHidden ? 'hidden' : 'block'} ${this.type} ${this.status} ${this.disabled
|
||||
class="button ${this.isHidden ? 'hidden' : ''} ${actualType} size-${actualSize} ${this.status} ${this.disabled
|
||||
? 'disabled'
|
||||
: null}"
|
||||
: ''}"
|
||||
@click="${this.dispatchClick}"
|
||||
>
|
||||
${this.status === 'normal' ? html``: html`
|
||||
<dees-spinner .bnw=${true} status="${this.status}"></dees-spinner>
|
||||
<dees-spinner
|
||||
.bnw=${true}
|
||||
status="${this.status}"
|
||||
size="${actualSize === 'sm' ? 14 : actualSize === 'lg' ? 18 : 16}"
|
||||
></dees-spinner>
|
||||
`}
|
||||
<div class="textbox">${this.text || html`<slot>Button</slot>`}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,531 +0,0 @@
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { demoFunc } from './dees-chart-area.demo.js';
|
||||
|
||||
import ApexCharts from 'apexcharts';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-chart-area': DeesChartArea;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-chart-area')
|
||||
export class DeesChartArea extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// instance
|
||||
@state()
|
||||
public chart: ApexCharts;
|
||||
|
||||
@property()
|
||||
public label: string = 'Untitled Chart';
|
||||
|
||||
@property({ type: Array })
|
||||
public series: ApexAxisChartSeries = [];
|
||||
|
||||
// Override getter to return internal chart data
|
||||
get chartSeries(): ApexAxisChartSeries {
|
||||
return this.internalChartData.length > 0 ? this.internalChartData : this.series;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
public yAxisFormatter: (value: number) => string = (val) => `${val} Mbps`;
|
||||
|
||||
@property({ type: Number })
|
||||
public rollingWindow: number = 0; // 0 means no rolling window
|
||||
|
||||
@property({ type: Boolean })
|
||||
public realtimeMode: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
public yAxisScaling: 'fixed' | 'dynamic' | 'percentage' = 'dynamic';
|
||||
|
||||
@property({ type: Number })
|
||||
public yAxisMax: number = 100; // Used when yAxisScaling is 'fixed' or 'percentage'
|
||||
|
||||
@property({ type: Number })
|
||||
public autoScrollInterval: number = 1000; // Auto-scroll interval in milliseconds (0 to disable)
|
||||
|
||||
private resizeObserver: ResizeObserver;
|
||||
private resizeTimeout: number;
|
||||
private internalChartData: ApexAxisChartSeries = [];
|
||||
private autoScrollTimer: number | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
domtools.elementBasic.setup();
|
||||
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
// Debounce resize calls to prevent excessive updates
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
}
|
||||
|
||||
this.resizeTimeout = window.setTimeout(() => {
|
||||
for (let entry of entries) {
|
||||
if (entry.target.classList.contains('mainbox') && this.chart) {
|
||||
this.resizeChart();
|
||||
}
|
||||
}
|
||||
}, 100); // 100ms debounce
|
||||
});
|
||||
|
||||
this.registerStartupFunction(async () => {
|
||||
this.updateComplete.then(() => {
|
||||
const mainbox = this.shadowRoot.querySelector('.mainbox');
|
||||
if (mainbox) {
|
||||
this.resizeObserver.observe(mainbox);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.registerGarbageFunction(async () => {
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
}
|
||||
this.resizeObserver.disconnect();
|
||||
this.stopAutoScroll();
|
||||
});
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mainbox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
background: #111;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding-top: 16px;
|
||||
z-index: 10;
|
||||
}
|
||||
.chartContainer {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
padding: 32px 16px 16px 0px;
|
||||
overflow: hidden;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="mainbox">
|
||||
<div class="chartTitle">${this.label}</div>
|
||||
<div class="chartContainer"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
await this.domtoolsPromise;
|
||||
|
||||
// Wait for next animation frame to ensure layout is complete
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Get actual dimensions of the container
|
||||
const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox');
|
||||
const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer');
|
||||
|
||||
if (!mainbox || !chartContainer) {
|
||||
console.error('Chart containers not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate initial dimensions
|
||||
const styleChartContainer = window.getComputedStyle(chartContainer);
|
||||
const paddingTop = parseInt(styleChartContainer.paddingTop, 10);
|
||||
const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10);
|
||||
const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10);
|
||||
const paddingRight = parseInt(styleChartContainer.paddingRight, 10);
|
||||
|
||||
const initialWidth = mainbox.clientWidth - paddingLeft - paddingRight;
|
||||
const initialHeight = mainbox.offsetHeight - paddingTop - paddingBottom;
|
||||
|
||||
// Use provided series data or default demo data
|
||||
const chartSeries = this.series.length > 0 ? this.series : [
|
||||
{
|
||||
name: 'cpu',
|
||||
data: [
|
||||
{ x: '2025-01-15T03:00:00', y: 25 },
|
||||
{ x: '2025-01-15T07:00:00', y: 30 },
|
||||
{ x: '2025-01-15T11:00:00', y: 20 },
|
||||
{ x: '2025-01-15T15:00:00', y: 35 },
|
||||
{ x: '2025-01-15T19:00:00', y: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
data: [
|
||||
{ x: '2025-01-15T03:00:00', y: 10 },
|
||||
{ x: '2025-01-15T07:00:00', y: 12 },
|
||||
{ x: '2025-01-15T11:00:00', y: 10 },
|
||||
{ x: '2025-01-15T15:00:00', y: 30 },
|
||||
{ x: '2025-01-15T19:00:00', y: 40 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Store internal data
|
||||
this.internalChartData = chartSeries;
|
||||
|
||||
var options: ApexCharts.ApexOptions = {
|
||||
series: chartSeries,
|
||||
chart: {
|
||||
width: initialWidth || 100, // Use actual width or fallback
|
||||
height: initialHeight || 100, // Use actual height or fallback
|
||||
type: 'area',
|
||||
toolbar: {
|
||||
show: false, // This line disables the toolbar
|
||||
},
|
||||
animations: {
|
||||
enabled: !this.realtimeMode, // Disable animations in realtime mode
|
||||
speed: 400,
|
||||
animateGradually: {
|
||||
enabled: false, // Disable gradual animation for cleaner updates
|
||||
delay: 0
|
||||
},
|
||||
dynamicAnimation: {
|
||||
enabled: !this.realtimeMode,
|
||||
speed: 350
|
||||
}
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
stroke: {
|
||||
width: 1,
|
||||
curve: 'smooth',
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime', // Time-series data
|
||||
labels: {
|
||||
format: 'HH:mm:ss', // Time formatting with seconds
|
||||
datetimeUTC: false,
|
||||
style: {
|
||||
colors: '#9e9e9e', // Label color
|
||||
fontSize: '11px',
|
||||
},
|
||||
},
|
||||
axisBorder: {
|
||||
show: false, // Hide x-axis border
|
||||
},
|
||||
axisTicks: {
|
||||
show: false, // Hide x-axis ticks
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
min: 0,
|
||||
max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax,
|
||||
labels: {
|
||||
formatter: this.yAxisFormatter,
|
||||
style: {
|
||||
colors: '#9e9e9e', // Label color
|
||||
fontSize: '12px',
|
||||
},
|
||||
},
|
||||
axisBorder: {
|
||||
show: false, // Hide y-axis border
|
||||
},
|
||||
axisTicks: {
|
||||
show: false, // Hide y-axis ticks
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
shared: true, // Enables the tooltip to display across series
|
||||
intersect: false, // Allows hovering anywhere on the chart
|
||||
followCursor: true, // Makes tooltip follow mouse even between points
|
||||
x: {
|
||||
format: 'dd/MM/yy HH:mm',
|
||||
},
|
||||
custom: function ({ series, dataPointIndex, w }: any) {
|
||||
// Iterate through each series and get its value
|
||||
let tooltipContent = `<div style="padding: 10px; background: #1e1e2f; color: white; border-radius: 5px;">`;
|
||||
|
||||
series.forEach((s: number[], index: number) => {
|
||||
const label = w.globals.seriesNames[index]; // Get series label
|
||||
const value = s[dataPointIndex]; // Get value at data point
|
||||
tooltipContent += `<strong>${label}:</strong> ${value} Mbps<br/>`;
|
||||
});
|
||||
|
||||
tooltipContent += `</div>`;
|
||||
return tooltipContent;
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
lines: {
|
||||
show: true, // This enables the grid lines along the x-axis
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
lines: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
borderColor: '#333', // Set the color of the grid lines
|
||||
strokeDashArray: 0, // Solid line
|
||||
row: {
|
||||
colors: [], // This can be used to alternate the shading of the horizontal rows
|
||||
opacity: 0.1,
|
||||
},
|
||||
column: {
|
||||
colors: [], // For vertical column bands, not needed here but available for customization
|
||||
opacity: 0.1,
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient', // Gradient fill for the area
|
||||
gradient: {
|
||||
shade: 'dark',
|
||||
type: 'vertical',
|
||||
gradientToColors: ['#9c27b0'], // Gradient color ending
|
||||
stops: [0, 100],
|
||||
},
|
||||
},
|
||||
};
|
||||
this.chart = new ApexCharts(this.shadowRoot.querySelector('.chartContainer'), options);
|
||||
await this.chart.render();
|
||||
|
||||
// Give the chart a moment to fully initialize before resizing
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await this.resizeChart();
|
||||
}
|
||||
|
||||
public async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
// Update chart if series data changes
|
||||
if (changedProperties.has('series') && this.chart && this.series.length > 0) {
|
||||
await this.updateSeries(this.series);
|
||||
}
|
||||
|
||||
// Update y-axis formatter if it changes
|
||||
if (changedProperties.has('yAxisFormatter') && this.chart) {
|
||||
await this.chart.updateOptions({
|
||||
yaxis: {
|
||||
labels: {
|
||||
formatter: this.yAxisFormatter,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle realtime mode changes
|
||||
if (changedProperties.has('realtimeMode') && this.chart) {
|
||||
await this.chart.updateOptions({
|
||||
chart: {
|
||||
animations: {
|
||||
enabled: !this.realtimeMode,
|
||||
speed: 400,
|
||||
animateGradually: {
|
||||
enabled: false,
|
||||
delay: 0
|
||||
},
|
||||
dynamicAnimation: {
|
||||
enabled: !this.realtimeMode,
|
||||
speed: 350
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start/stop auto-scroll based on realtime mode
|
||||
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
|
||||
this.startAutoScroll();
|
||||
} else {
|
||||
this.stopAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle auto-scroll interval changes
|
||||
if (changedProperties.has('autoScrollInterval') && this.chart) {
|
||||
this.stopAutoScroll();
|
||||
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
|
||||
this.startAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle y-axis scaling changes
|
||||
if ((changedProperties.has('yAxisScaling') || changedProperties.has('yAxisMax')) && this.chart) {
|
||||
await this.chart.updateOptions({
|
||||
yaxis: {
|
||||
min: 0,
|
||||
max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async updateSeries(newSeries: ApexAxisChartSeries, animate: boolean = true) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the new data first
|
||||
this.internalChartData = newSeries;
|
||||
|
||||
// Handle rolling window if enabled
|
||||
if (this.rollingWindow > 0 && this.realtimeMode) {
|
||||
const now = Date.now();
|
||||
const cutoffTime = now - this.rollingWindow;
|
||||
|
||||
// Filter data to only include points within the rolling window
|
||||
const filteredSeries = newSeries.map(series => ({
|
||||
name: series.name,
|
||||
data: (series.data as any[]).filter(point => {
|
||||
if (typeof point === 'object' && point !== null && 'x' in point) {
|
||||
return new Date(point.x).getTime() > cutoffTime;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
}));
|
||||
|
||||
// Only update if we have data
|
||||
if (filteredSeries.some(s => s.data.length > 0)) {
|
||||
// Handle y-axis scaling first
|
||||
if (this.yAxisScaling === 'dynamic') {
|
||||
const allValues = filteredSeries.flatMap(s => (s.data as any[]).map(d => d.y));
|
||||
if (allValues.length > 0) {
|
||||
const maxValue = Math.max(...allValues);
|
||||
const dynamicMax = Math.ceil(maxValue * 1.1);
|
||||
await this.chart.updateOptions({
|
||||
yaxis: {
|
||||
min: 0,
|
||||
max: dynamicMax
|
||||
}
|
||||
}, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
this.chart.updateSeries(filteredSeries, false);
|
||||
}
|
||||
} else {
|
||||
this.chart.updateSeries(newSeries, animate);
|
||||
}
|
||||
}
|
||||
|
||||
// New method to update just the x-axis for smooth scrolling
|
||||
public async updateTimeWindow() {
|
||||
if (!this.chart || this.rollingWindow <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cutoffTime = now - this.rollingWindow;
|
||||
|
||||
await this.chart.updateOptions({
|
||||
xaxis: {
|
||||
min: cutoffTime,
|
||||
max: now,
|
||||
labels: {
|
||||
format: 'HH:mm:ss',
|
||||
datetimeUTC: false,
|
||||
style: {
|
||||
colors: '#9e9e9e',
|
||||
fontSize: '11px',
|
||||
},
|
||||
},
|
||||
tickAmount: 6,
|
||||
}
|
||||
}, false, false);
|
||||
}
|
||||
|
||||
public async appendData(newData: { data: any[] }[]) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use ApexCharts' appendData method for smoother real-time updates
|
||||
this.chart.appendData(newData);
|
||||
}
|
||||
|
||||
public async updateOptions(options: ApexCharts.ApexOptions, redrawPaths?: boolean, animate?: boolean) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.chart.updateOptions(options, redrawPaths, animate);
|
||||
}
|
||||
|
||||
public async resizeChart() {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox');
|
||||
const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer');
|
||||
|
||||
if (!mainbox || !chartContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get computed style of the element
|
||||
const styleChartContainer = window.getComputedStyle(chartContainer);
|
||||
|
||||
// Extract padding values
|
||||
const paddingTop = parseInt(styleChartContainer.paddingTop, 10);
|
||||
const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10);
|
||||
const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10);
|
||||
const paddingRight = parseInt(styleChartContainer.paddingRight, 10);
|
||||
|
||||
// Calculate the actual width and height to use, subtracting padding
|
||||
const actualWidth = mainbox.clientWidth - paddingLeft - paddingRight;
|
||||
const actualHeight = mainbox.offsetHeight - paddingTop - paddingBottom;
|
||||
|
||||
await this.chart.updateOptions({
|
||||
chart: {
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private startAutoScroll() {
|
||||
if (this.autoScrollTimer) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
this.autoScrollTimer = window.setInterval(() => {
|
||||
this.updateTimeWindow();
|
||||
}, this.autoScrollInterval);
|
||||
}
|
||||
|
||||
private stopAutoScroll() {
|
||||
if (this.autoScrollTimer) {
|
||||
window.clearInterval(this.autoScrollTimer);
|
||||
this.autoScrollTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
667
ts_web/elements/dees-chart-area/component.ts
Normal file
667
ts_web/elements/dees-chart-area/component.ts
Normal file
@@ -0,0 +1,667 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { demoFunc } from './demo.js';
|
||||
import { chartAreaStyles } from './styles.js';
|
||||
import { renderChartArea } from './template.js';
|
||||
|
||||
import ApexCharts from 'apexcharts';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-chart-area': DeesChartArea;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-chart-area')
|
||||
export class DeesChartArea extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// instance
|
||||
@state()
|
||||
public chart: ApexCharts;
|
||||
|
||||
@property()
|
||||
public label: string = 'Untitled Chart';
|
||||
|
||||
@property({ type: Array })
|
||||
public series: ApexAxisChartSeries = [];
|
||||
|
||||
// Override getter to return internal chart data
|
||||
get chartSeries(): ApexAxisChartSeries {
|
||||
return this.internalChartData.length > 0 ? this.internalChartData : this.series;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
public yAxisFormatter: (value: number) => string = (val) => `${val} Mbps`;
|
||||
|
||||
@property({ type: Number })
|
||||
public rollingWindow: number = 0; // 0 means no rolling window
|
||||
|
||||
@property({ type: Boolean })
|
||||
public realtimeMode: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
public yAxisScaling: 'fixed' | 'dynamic' | 'percentage' = 'dynamic';
|
||||
|
||||
@property({ type: Number })
|
||||
public yAxisMax: number = 100; // Used when yAxisScaling is 'fixed' or 'percentage'
|
||||
|
||||
@property({ type: Number })
|
||||
public autoScrollInterval: number = 1000; // Auto-scroll interval in milliseconds (0 to disable)
|
||||
|
||||
private resizeObserver: ResizeObserver;
|
||||
private resizeTimeout: number;
|
||||
private internalChartData: ApexAxisChartSeries = [];
|
||||
private autoScrollTimer: number | null = null;
|
||||
private readonly DEBUG_RESIZE = false; // Set to true to enable resize debugging
|
||||
|
||||
// Chart color schemes
|
||||
private readonly CHART_COLORS = {
|
||||
dark: [
|
||||
'hsl(217.2 91.2% 59.8%)', // Blue
|
||||
'hsl(173.4 80.4% 40%)', // Teal
|
||||
'hsl(280.3 87.4% 66.7%)', // Purple
|
||||
'hsl(24.6 95% 53.1%)', // Orange
|
||||
],
|
||||
light: [
|
||||
'hsl(222.2 47.4% 51.2%)', // Blue (shadcn primary)
|
||||
'hsl(142.1 76.2% 36.3%)', // Green (shadcn success)
|
||||
'hsl(280.3 47.7% 50.2%)', // Purple (muted)
|
||||
'hsl(20.5 90.2% 48.2%)', // Orange (shadcn destructive variant)
|
||||
]
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
domtools.elementBasic.setup();
|
||||
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
// Debounce resize calls to prevent excessive updates
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
}
|
||||
|
||||
this.resizeTimeout = window.setTimeout(() => {
|
||||
// Simply resize if we have a chart, since we're only observing the mainbox
|
||||
if (this.chart) {
|
||||
// Log resize event for debugging
|
||||
if (this.DEBUG_RESIZE && entries.length > 0) {
|
||||
const entry = entries[0];
|
||||
console.log('DeesChartArea - Resize detected:', {
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height
|
||||
});
|
||||
}
|
||||
this.resizeChart();
|
||||
}
|
||||
}, 100); // 100ms debounce
|
||||
});
|
||||
|
||||
// Note: ResizeObserver is now set up after chart initialization in firstUpdated()
|
||||
// to ensure proper timing and avoid race conditions
|
||||
|
||||
this.registerGarbageFunction(async () => {
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
}
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
this.stopAutoScroll();
|
||||
|
||||
// Critical: Destroy chart instance to prevent memory leak
|
||||
if (this.chart) {
|
||||
try {
|
||||
this.chart.destroy();
|
||||
this.chart = null;
|
||||
} catch (error) {
|
||||
console.error('Error destroying chart:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Trigger resize when element is connected to DOM
|
||||
// This helps with dynamically added charts
|
||||
if (this.chart) {
|
||||
// Wait a frame for layout to settle
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
await this.resizeChart();
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = chartAreaStyles;
|
||||
|
||||
public render(): TemplateResult {
|
||||
return renderChartArea(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async firstUpdated() {
|
||||
await this.domtoolsPromise;
|
||||
|
||||
// Wait for next animation frame to ensure layout is complete
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Get actual dimensions of the container
|
||||
const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox');
|
||||
const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer');
|
||||
|
||||
if (!mainbox || !chartContainer) {
|
||||
console.error('Chart containers not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate initial dimensions
|
||||
const styleChartContainer = window.getComputedStyle(chartContainer);
|
||||
const paddingTop = parseInt(styleChartContainer.paddingTop, 10);
|
||||
const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10);
|
||||
const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10);
|
||||
const paddingRight = parseInt(styleChartContainer.paddingRight, 10);
|
||||
|
||||
const initialWidth = mainbox.clientWidth - paddingLeft - paddingRight;
|
||||
const initialHeight = mainbox.offsetHeight - paddingTop - paddingBottom;
|
||||
|
||||
// Use provided series data or default demo data
|
||||
const chartSeries = this.series.length > 0 ? this.series : [
|
||||
{
|
||||
name: 'cpu',
|
||||
data: [
|
||||
{ x: '2025-01-15T03:00:00', y: 25 },
|
||||
{ x: '2025-01-15T07:00:00', y: 30 },
|
||||
{ x: '2025-01-15T11:00:00', y: 20 },
|
||||
{ x: '2025-01-15T15:00:00', y: 35 },
|
||||
{ x: '2025-01-15T19:00:00', y: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
data: [
|
||||
{ x: '2025-01-15T03:00:00', y: 10 },
|
||||
{ x: '2025-01-15T07:00:00', y: 12 },
|
||||
{ x: '2025-01-15T11:00:00', y: 10 },
|
||||
{ x: '2025-01-15T15:00:00', y: 30 },
|
||||
{ x: '2025-01-15T19:00:00', y: 40 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Store internal data
|
||||
this.internalChartData = chartSeries;
|
||||
|
||||
// Get current theme
|
||||
const isDark = !this.goBright;
|
||||
const theme = isDark ? 'dark' : 'light';
|
||||
|
||||
var options: ApexCharts.ApexOptions = {
|
||||
series: chartSeries,
|
||||
chart: {
|
||||
width: initialWidth || 100, // Use actual width or fallback
|
||||
height: initialHeight || 100, // Use actual height or fallback
|
||||
type: 'area',
|
||||
background: 'transparent', // Transparent background to inherit from container
|
||||
toolbar: {
|
||||
show: false, // This line disables the toolbar
|
||||
},
|
||||
animations: {
|
||||
enabled: !this.realtimeMode, // Disable animations in realtime mode
|
||||
speed: 400,
|
||||
animateGradually: {
|
||||
enabled: false, // Disable gradual animation for cleaner updates
|
||||
delay: 0
|
||||
},
|
||||
dynamicAnimation: {
|
||||
enabled: !this.realtimeMode,
|
||||
speed: 350
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
enabled: false, // Disable zoom for cleaner interaction
|
||||
},
|
||||
selection: {
|
||||
enabled: false, // Disable selection
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
stroke: {
|
||||
width: 2,
|
||||
curve: 'smooth',
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime', // Time-series data
|
||||
labels: {
|
||||
format: 'HH:mm:ss', // Time formatting with seconds
|
||||
datetimeUTC: false,
|
||||
style: {
|
||||
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], // Label color
|
||||
fontSize: '12px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
fontWeight: '400',
|
||||
},
|
||||
},
|
||||
axisBorder: {
|
||||
show: false, // Hide x-axis border
|
||||
},
|
||||
axisTicks: {
|
||||
show: false, // Hide x-axis ticks
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
min: 0,
|
||||
max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax,
|
||||
labels: {
|
||||
formatter: this.yAxisFormatter,
|
||||
style: {
|
||||
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], // Label color
|
||||
fontSize: '12px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
fontWeight: '400',
|
||||
},
|
||||
},
|
||||
axisBorder: {
|
||||
show: false, // Hide y-axis border
|
||||
},
|
||||
axisTicks: {
|
||||
show: false, // Hide y-axis ticks
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
shared: true, // Enables the tooltip to display across series
|
||||
intersect: false, // Allows hovering anywhere on the chart
|
||||
followCursor: true, // Makes tooltip follow mouse even between points
|
||||
x: {
|
||||
format: 'dd/MM/yy HH:mm',
|
||||
},
|
||||
custom: ({ series, dataPointIndex, w }: any) => {
|
||||
// Iterate through each series and get its value
|
||||
// Note: We can't access component instance here, so we'll use w.config.theme.mode
|
||||
const currentTheme = w.config.theme.mode;
|
||||
const isDarkMode = currentTheme === 'dark';
|
||||
const bgColor = isDarkMode ? 'hsl(0 0% 9%)' : 'hsl(0 0% 100%)';
|
||||
const textColor = isDarkMode ? 'hsl(0 0% 95%)' : 'hsl(0 0% 9%)';
|
||||
const borderColor = isDarkMode ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 89.8%)';
|
||||
|
||||
// Get formatter from chart config
|
||||
const formatter = w.config.yaxis[0]?.labels?.formatter || ((val: number) => val.toString());
|
||||
|
||||
let tooltipContent = `<div style="padding: 12px; background: ${bgColor}; color: ${textColor}; border-radius: 6px; box-shadow: 0 2px 8px 0 hsl(0 0% 0% / ${isDarkMode ? '0.2' : '0.1'}); border: 1px solid ${borderColor};font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 12px;">`;
|
||||
|
||||
series.forEach((s: number[], index: number) => {
|
||||
const label = w.globals.seriesNames[index]; // Get series label
|
||||
const value = s[dataPointIndex]; // Get value at data point
|
||||
const color = w.globals.colors[index];
|
||||
const formattedValue = formatter(value);
|
||||
tooltipContent += `<div style="display: flex; align-items: center; gap: 8px; margin: ${index > 0 ? '6px' : '0'} 0;">
|
||||
<span style="display: inline-block; width: 10px; height: 10px; background: ${color}; border-radius: 2px;"></span>
|
||||
<span style="font-weight: 500;">${label}:</span>
|
||||
<span style="margin-left: auto; font-weight: 600;">${formattedValue}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
tooltipContent += `</div>`;
|
||||
return tooltipContent;
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
lines: {
|
||||
show: false, // Hide vertical grid lines for cleaner look
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
lines: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
borderColor: isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 94%)', // Very subtle grid lines
|
||||
strokeDashArray: 0, // Solid line
|
||||
padding: {
|
||||
top: 10,
|
||||
right: 20,
|
||||
bottom: 10,
|
||||
left: 20,
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient', // Gradient fill for the area
|
||||
gradient: {
|
||||
shade: isDark ? 'dark' : 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0.1,
|
||||
opacityFrom: isDark ? 0.2 : 0.3,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
},
|
||||
},
|
||||
colors: isDark ? this.CHART_COLORS.dark : this.CHART_COLORS.light,
|
||||
theme: {
|
||||
mode: theme,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
this.chart = new ApexCharts(this.shadowRoot.querySelector('.chartContainer'), options);
|
||||
await this.chart.render();
|
||||
|
||||
// Give the chart a moment to fully initialize before resizing
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await this.resizeChart();
|
||||
|
||||
// Ensure resize observer is watching the mainbox
|
||||
const mainbox = this.shadowRoot.querySelector('.mainbox');
|
||||
if (mainbox && this.resizeObserver) {
|
||||
// Disconnect any previous observations
|
||||
this.resizeObserver.disconnect();
|
||||
// Start observing the mainbox
|
||||
this.resizeObserver.observe(mainbox);
|
||||
if (this.DEBUG_RESIZE) {
|
||||
console.log('DeesChartArea - ResizeObserver attached to mainbox');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize chart:', error);
|
||||
// Optionally, you could set an error state here
|
||||
// this.chartState = 'error';
|
||||
// this.errorMessage = 'Failed to initialize chart';
|
||||
}
|
||||
}
|
||||
|
||||
public async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
// Update chart theme when goBright changes
|
||||
if (changedProperties.has('goBright') && this.chart) {
|
||||
await this.updateChartTheme();
|
||||
}
|
||||
|
||||
// Update chart if series data changes
|
||||
if (changedProperties.has('series') && this.chart && this.series.length > 0) {
|
||||
await this.updateSeries(this.series);
|
||||
}
|
||||
|
||||
// Update y-axis formatter if it changes
|
||||
if (changedProperties.has('yAxisFormatter') && this.chart) {
|
||||
await this.chart.updateOptions({
|
||||
yaxis: {
|
||||
labels: {
|
||||
formatter: this.yAxisFormatter,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle realtime mode changes
|
||||
if (changedProperties.has('realtimeMode') && this.chart) {
|
||||
await this.chart.updateOptions({
|
||||
chart: {
|
||||
animations: {
|
||||
enabled: !this.realtimeMode,
|
||||
speed: 400,
|
||||
animateGradually: {
|
||||
enabled: false,
|
||||
delay: 0
|
||||
},
|
||||
dynamicAnimation: {
|
||||
enabled: !this.realtimeMode,
|
||||
speed: 350
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start/stop auto-scroll based on realtime mode
|
||||
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
|
||||
this.startAutoScroll();
|
||||
} else {
|
||||
this.stopAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle auto-scroll interval changes
|
||||
if (changedProperties.has('autoScrollInterval') && this.chart) {
|
||||
this.stopAutoScroll();
|
||||
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
|
||||
this.startAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle y-axis scaling changes
|
||||
if ((changedProperties.has('yAxisScaling') || changedProperties.has('yAxisMax')) && this.chart) {
|
||||
await this.chart.updateOptions({
|
||||
yaxis: {
|
||||
min: 0,
|
||||
max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async updateSeries(newSeries: ApexAxisChartSeries, animate: boolean = true) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Store the new data first
|
||||
this.internalChartData = newSeries;
|
||||
|
||||
// Handle rolling window if enabled
|
||||
if (this.rollingWindow > 0 && this.realtimeMode) {
|
||||
const now = Date.now();
|
||||
const cutoffTime = now - this.rollingWindow;
|
||||
|
||||
// Filter data to only include points within the rolling window
|
||||
const filteredSeries = newSeries.map(series => ({
|
||||
name: series.name,
|
||||
data: (series.data as any[]).filter(point => {
|
||||
if (typeof point === 'object' && point !== null && 'x' in point) {
|
||||
return new Date(point.x).getTime() > cutoffTime;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
}));
|
||||
|
||||
// Only update if we have data
|
||||
if (filteredSeries.some(s => s.data.length > 0)) {
|
||||
// Handle y-axis scaling first
|
||||
if (this.yAxisScaling === 'dynamic') {
|
||||
const allValues = filteredSeries.flatMap(s => (s.data as any[]).map(d => d.y));
|
||||
if (allValues.length > 0) {
|
||||
const maxValue = Math.max(...allValues);
|
||||
const dynamicMax = Math.ceil(maxValue * 1.1);
|
||||
await this.chart.updateOptions({
|
||||
yaxis: {
|
||||
min: 0,
|
||||
max: dynamicMax
|
||||
}
|
||||
}, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
await this.chart.updateSeries(filteredSeries, false);
|
||||
}
|
||||
} else {
|
||||
await this.chart.updateSeries(newSeries, animate);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update chart series:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update just the x-axis for smooth scrolling in realtime mode
|
||||
// Public for advanced usage in demos, but typically handled automatically
|
||||
public async updateTimeWindow() {
|
||||
if (!this.chart || this.rollingWindow <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cutoffTime = now - this.rollingWindow;
|
||||
|
||||
await this.chart.updateOptions({
|
||||
xaxis: {
|
||||
min: cutoffTime,
|
||||
max: now,
|
||||
labels: {
|
||||
format: 'HH:mm:ss',
|
||||
datetimeUTC: false,
|
||||
style: {
|
||||
colors: [!this.goBright ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'],
|
||||
fontSize: '12px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
fontWeight: '400',
|
||||
},
|
||||
},
|
||||
tickAmount: 6,
|
||||
}
|
||||
}, false, false);
|
||||
}
|
||||
|
||||
public async appendData(newData: { data: any[] }[]) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use ApexCharts' appendData method for smoother real-time updates
|
||||
this.chart.appendData(newData);
|
||||
}
|
||||
|
||||
public async updateOptions(options: ApexCharts.ApexOptions, redrawPaths?: boolean, animate?: boolean) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.chart.updateOptions(options, redrawPaths, animate);
|
||||
}
|
||||
|
||||
public async resizeChart() {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.DEBUG_RESIZE) {
|
||||
console.log('DeesChartArea - resizeChart called');
|
||||
}
|
||||
|
||||
try {
|
||||
const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox');
|
||||
const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer');
|
||||
|
||||
if (!mainbox || !chartContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force layout recalculation
|
||||
void mainbox.offsetHeight;
|
||||
|
||||
// Get computed style of the element
|
||||
const styleChartContainer = window.getComputedStyle(chartContainer);
|
||||
|
||||
// Extract padding values
|
||||
const paddingTop = parseInt(styleChartContainer.paddingTop, 10);
|
||||
const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10);
|
||||
const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10);
|
||||
const paddingRight = parseInt(styleChartContainer.paddingRight, 10);
|
||||
|
||||
// Calculate the actual width and height to use, subtracting padding
|
||||
const actualWidth = mainbox.clientWidth - paddingLeft - paddingRight;
|
||||
const actualHeight = mainbox.offsetHeight - paddingTop - paddingBottom;
|
||||
|
||||
// Validate dimensions
|
||||
if (actualWidth > 0 && actualHeight > 0) {
|
||||
if (this.DEBUG_RESIZE) {
|
||||
console.log('DeesChartArea - Updating chart dimensions:', {
|
||||
width: actualWidth,
|
||||
height: actualHeight
|
||||
});
|
||||
}
|
||||
|
||||
await this.chart.updateOptions({
|
||||
chart: {
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
},
|
||||
}, true, false); // Redraw paths but don't animate
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to resize chart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a chart resize. Useful when automatic detection doesn't work.
|
||||
* This is a convenience method that can be called from outside the component.
|
||||
*/
|
||||
public async forceResize() {
|
||||
await this.resizeChart();
|
||||
}
|
||||
|
||||
private startAutoScroll() {
|
||||
if (this.autoScrollTimer) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
this.autoScrollTimer = window.setInterval(() => {
|
||||
this.updateTimeWindow();
|
||||
}, this.autoScrollInterval);
|
||||
}
|
||||
|
||||
private stopAutoScroll() {
|
||||
if (this.autoScrollTimer) {
|
||||
window.clearInterval(this.autoScrollTimer);
|
||||
this.autoScrollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async updateChartTheme() {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDark = !this.goBright;
|
||||
const theme = isDark ? 'dark' : 'light';
|
||||
|
||||
await this.chart.updateOptions({
|
||||
theme: {
|
||||
mode: theme,
|
||||
},
|
||||
colors: isDark ? this.CHART_COLORS.dark : this.CHART_COLORS.light,
|
||||
xaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'],
|
||||
},
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'],
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 94%)',
|
||||
},
|
||||
fill: {
|
||||
gradient: {
|
||||
shade: isDark ? 'dark' : 'light',
|
||||
opacityFrom: isDark ? 0.2 : 0.3,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import type { DeesChartArea } from './dees-chart-area.js';
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import type { DeesChartArea } from './component.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import './component.js';
|
||||
|
||||
export const demoFunc = () => {
|
||||
// Initial dataset values
|
||||
@@ -402,7 +403,7 @@ export const demoFunc = () => {
|
||||
${css`
|
||||
.demoBox {
|
||||
position: relative;
|
||||
background: #000000;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 9%)')};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
@@ -425,9 +426,9 @@ export const demoFunc = () => {
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||
font-size: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Geist Sans', sans-serif;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
3
ts_web/elements/dees-chart-area/index.ts
Normal file
3
ts_web/elements/dees-chart-area/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './component.js';
|
||||
export { chartAreaStyles } from './styles.js';
|
||||
export { renderChartArea } from './template.js';
|
||||
60
ts_web/elements/dees-chart-area/styles.ts
Normal file
60
ts_web/elements/dees-chart-area/styles.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const chartAreaStyles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mainbox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
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;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 16px 24px;
|
||||
z-index: 10;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 63.9%)')};
|
||||
}
|
||||
.chartContainer {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
padding: 44px 16px 16px 0px;
|
||||
overflow: hidden;
|
||||
background: transparent; /* Ensure container doesn't override chart background */
|
||||
}
|
||||
|
||||
/* ApexCharts theme overrides */
|
||||
.apexcharts-canvas {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.apexcharts-inner {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.apexcharts-graphical {
|
||||
background: transparent !important;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
12
ts_web/elements/dees-chart-area/template.ts
Normal file
12
ts_web/elements/dees-chart-area/template.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import type { DeesChartArea } from './component.js';
|
||||
|
||||
export const renderChartArea = (component: DeesChartArea): TemplateResult => {
|
||||
return html`
|
||||
<div class="mainbox">
|
||||
<div class="chartTitle">${component.label}</div>
|
||||
<div class="chartContainer"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
};
|
||||
@@ -53,17 +53,17 @@ export class DeesChartLog extends DeesElement {
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
font-family: 'Geist Mono', 'Consolas', 'Monaco', monospace;
|
||||
color: #ccc;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.mainbox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#dee2e6', '#333')};
|
||||
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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -71,9 +71,9 @@ export class DeesChartLog extends DeesElement {
|
||||
}
|
||||
|
||||
.header {
|
||||
background: ${cssManager.bdTheme('#e9ecef', '#1a1a1a')};
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#dee2e6', '#333')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -81,8 +81,10 @@ export class DeesChartLog extends DeesElement {
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#212529', '#fff')};
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -91,44 +93,48 @@ export class DeesChartLog extends DeesElement {
|
||||
}
|
||||
|
||||
.control-button {
|
||||
background: ${cssManager.bdTheme('#e9ecef', '#2a2a2a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ced4da', '#444')};
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
color: ${cssManager.bdTheme('#495057', '#ccc')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.2s;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
background: ${cssManager.bdTheme('#dee2e6', '#3a3a3a')};
|
||||
border-color: ${cssManager.bdTheme('#adb5bd', '#555')};
|
||||
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%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
|
||||
}
|
||||
|
||||
.control-button.active {
|
||||
background: ${cssManager.bdTheme('#007bff', '#4a4a4a')};
|
||||
color: ${cssManager.bdTheme('#fff', '#fff')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 93.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')};
|
||||
}
|
||||
|
||||
.logContainer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 16px;
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logEntry {
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: ${cssManager.bdTheme('#6c757d', '#666')};
|
||||
margin-right: 8px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -143,38 +149,38 @@ export class DeesChartLog extends DeesElement {
|
||||
}
|
||||
|
||||
.level.debug {
|
||||
color: ${cssManager.bdTheme('#6c757d', '#999')};
|
||||
background: ${cssManager.bdTheme('rgba(108, 117, 125, 0.1)', '#333')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 45.1% / 0.1)', 'hsl(0 0% 63.9% / 0.1)')};
|
||||
}
|
||||
|
||||
.level.info {
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4a9eff')};
|
||||
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.1)', 'rgba(74, 158, 255, 0.1)')};
|
||||
color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
|
||||
}
|
||||
|
||||
.level.warn {
|
||||
color: ${cssManager.bdTheme('#ff8800', '#ffb84a')};
|
||||
background: ${cssManager.bdTheme('rgba(255, 136, 0, 0.1)', 'rgba(255, 184, 74, 0.1)')};
|
||||
color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
|
||||
background: ${cssManager.bdTheme('hsl(25 95% 53% / 0.1)', 'hsl(25 95% 63% / 0.1)')};
|
||||
}
|
||||
|
||||
.level.error {
|
||||
color: ${cssManager.bdTheme('#dc3545', '#ff4a4a')};
|
||||
background: ${cssManager.bdTheme('rgba(220, 53, 69, 0.1)', 'rgba(255, 74, 74, 0.1)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.1)', 'hsl(0 72.2% 50.6% / 0.1)')};
|
||||
}
|
||||
|
||||
.level.success {
|
||||
color: ${cssManager.bdTheme('#28a745', '#4aff88')};
|
||||
background: ${cssManager.bdTheme('rgba(40, 167, 69, 0.1)', 'rgba(74, 255, 136, 0.1)')};
|
||||
color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
|
||||
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')};
|
||||
}
|
||||
|
||||
.source {
|
||||
color: ${cssManager.bdTheme('#6c757d', '#888')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: ${cssManager.bdTheme('#212529', '#ddd')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -183,7 +189,7 @@ export class DeesChartLog extends DeesElement {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: ${cssManager.bdTheme('#6c757d', '#666')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -193,16 +199,16 @@ export class DeesChartLog extends DeesElement {
|
||||
}
|
||||
|
||||
.logContainer::-webkit-scrollbar-track {
|
||||
background: ${cssManager.bdTheme('#e9ecef', '#1a1a1a')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 10%)')};
|
||||
}
|
||||
|
||||
.logContainer::-webkit-scrollbar-thumb {
|
||||
background: ${cssManager.bdTheme('#adb5bd', '#444')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 70%)', 'hsl(0 0% 30%)')};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.logContainer::-webkit-scrollbar-thumb:hover {
|
||||
background: ${cssManager.bdTheme('#6c757d', '#555')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 40%)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -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">
|
||||
<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: 'account1', value: 'Payment Account 1' },
|
||||
{ key: 'account2', value: 'PaymentAccount2' },
|
||||
{ key: 'account3', value: 'Payment Account 3' },
|
||||
{ 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: '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' },
|
||||
{ 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>
|
||||
`;
|
||||
|
||||
@@ -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,20 +180,26 @@ 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') {
|
||||
return;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,139 +13,203 @@ export const demoFunc = () => html`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 40px;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
min-height: 400px;
|
||||
}
|
||||
.demo-area {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
text-align: center;
|
||||
cursor: context-menu;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.demo-area:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
</style>
|
||||
<div class="demo-container">
|
||||
<dees-panel heading="Basic Context Menu with Nested Submenus">
|
||||
<div class="demo-area" @contextmenu=${(eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'Cut',
|
||||
iconName: 'scissors',
|
||||
shortcut: 'Cmd+X',
|
||||
action: async () => {
|
||||
console.log('Cut action');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy',
|
||||
iconName: 'copy',
|
||||
shortcut: 'Cmd+C',
|
||||
action: async () => {
|
||||
console.log('Copy action');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Paste',
|
||||
iconName: 'clipboard',
|
||||
shortcut: 'Cmd+V',
|
||||
action: async () => {
|
||||
console.log('Paste action');
|
||||
},
|
||||
},
|
||||
name: 'File',
|
||||
iconName: 'fileText',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'New', iconName: 'filePlus', shortcut: 'Cmd+N', action: async () => console.log('New file') },
|
||||
{ name: 'Open', iconName: 'folderOpen', shortcut: 'Cmd+O', action: async () => console.log('Open file') },
|
||||
{ name: 'Save', iconName: 'save', shortcut: 'Cmd+S', action: async () => console.log('Save') },
|
||||
{ divider: true },
|
||||
{ name: 'Export as PDF', iconName: 'download', action: async () => console.log('Export PDF') },
|
||||
{ name: 'Export as HTML', iconName: 'code', action: async () => console.log('Export HTML') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'trash2',
|
||||
action: async () => {
|
||||
console.log('Delete action');
|
||||
},
|
||||
},
|
||||
name: 'Edit',
|
||||
iconName: 'edit3',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Cut', iconName: 'scissors', shortcut: 'Cmd+X', action: async () => console.log('Cut') },
|
||||
{ name: 'Copy', iconName: 'copy', shortcut: 'Cmd+C', action: async () => console.log('Copy') },
|
||||
{ name: 'Paste', iconName: 'clipboard', shortcut: 'Cmd+V', action: async () => console.log('Paste') },
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Select All',
|
||||
shortcut: 'Cmd+A',
|
||||
action: async () => {
|
||||
console.log('Select All action');
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}>
|
||||
<h3>Right-click anywhere in this area</h3>
|
||||
<p>A context menu will appear with various options</p>
|
||||
</div>
|
||||
|
||||
<dees-button @contextmenu=${(eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'Button Action 1',
|
||||
iconName: 'play',
|
||||
action: async () => {
|
||||
console.log('Button action 1');
|
||||
},
|
||||
{ name: 'Find', iconName: 'search', shortcut: 'Cmd+F', action: async () => console.log('Find') },
|
||||
{ name: 'Replace', iconName: 'repeat', shortcut: 'Cmd+H', action: async () => console.log('Replace') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Button Action 2',
|
||||
iconName: 'pause',
|
||||
action: async () => {
|
||||
console.log('Button action 2');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Disabled Action',
|
||||
iconName: 'ban',
|
||||
disabled: true,
|
||||
action: async () => {
|
||||
console.log('This should not run');
|
||||
},
|
||||
name: 'View',
|
||||
iconName: 'eye',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Zoom In', iconName: 'zoomIn', shortcut: 'Cmd++', action: async () => console.log('Zoom in') },
|
||||
{ name: 'Zoom Out', iconName: 'zoomOut', shortcut: 'Cmd+-', action: async () => console.log('Zoom out') },
|
||||
{ name: 'Reset Zoom', iconName: 'maximize2', shortcut: 'Cmd+0', action: async () => console.log('Reset zoom') },
|
||||
{ divider: true },
|
||||
{ name: 'Full Screen', iconName: 'maximize', shortcut: 'F11', action: async () => console.log('Full screen') },
|
||||
]
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Settings',
|
||||
iconName: 'settings',
|
||||
action: async () => {
|
||||
console.log('Settings');
|
||||
action: async () => console.log('Settings')
|
||||
},
|
||||
{
|
||||
name: 'Help',
|
||||
iconName: 'helpCircle',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Documentation', iconName: 'book', action: async () => console.log('Documentation') },
|
||||
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', action: async () => console.log('Shortcuts') },
|
||||
{ divider: true },
|
||||
{ name: 'About', iconName: 'info', action: async () => console.log('About') },
|
||||
]
|
||||
}
|
||||
]);
|
||||
}}>
|
||||
<h3>Right-click anywhere in this area</h3>
|
||||
<p>A context menu with nested submenus will appear</p>
|
||||
</div>
|
||||
</dees-panel>
|
||||
<dees-panel heading="Component-Specific Context Menu">
|
||||
<dees-button style="margin: 20px;" @contextmenu=${(eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'Button Actions',
|
||||
iconName: 'mousePointer',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Click', iconName: 'mouse', action: async () => console.log('Click action') },
|
||||
{ name: 'Double Click', iconName: 'zap', action: async () => console.log('Double click') },
|
||||
{ name: 'Long Press', iconName: 'clock', action: async () => console.log('Long press') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Button State',
|
||||
iconName: 'toggleLeft',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Enable', iconName: 'checkCircle', action: async () => console.log('Enable') },
|
||||
{ name: 'Disable', iconName: 'xCircle', action: async () => console.log('Disable') },
|
||||
{ divider: true },
|
||||
{ name: 'Show', iconName: 'eye', action: async () => console.log('Show') },
|
||||
{ name: 'Hide', iconName: 'eyeOff', action: async () => console.log('Hide') },
|
||||
]
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Disabled Action',
|
||||
iconName: 'ban',
|
||||
disabled: true,
|
||||
action: async () => console.log('This should not run'),
|
||||
},
|
||||
{
|
||||
name: 'Properties',
|
||||
iconName: 'settings',
|
||||
action: async () => console.log('Button properties'),
|
||||
},
|
||||
]);
|
||||
}}>Right-click on this button for a different menu</dees-button>
|
||||
}}>Right-click on this button</dees-button>
|
||||
</dees-panel>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<h4>Static Context Menu (always visible):</h4>
|
||||
<dees-panel heading="Advanced Context Menu Example">
|
||||
<div class="demo-area" @contextmenu=${(eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'Format',
|
||||
iconName: 'type',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Bold', iconName: 'bold', shortcut: 'Cmd+B', action: async () => console.log('Bold') },
|
||||
{ name: 'Italic', iconName: 'italic', shortcut: 'Cmd+I', action: async () => console.log('Italic') },
|
||||
{ name: 'Underline', iconName: 'underline', shortcut: 'Cmd+U', action: async () => console.log('Underline') },
|
||||
{ divider: true },
|
||||
{ name: 'Font Size', iconName: 'type', action: async () => console.log('Font size menu') },
|
||||
{ name: 'Font Color', iconName: 'palette', action: async () => console.log('Font color menu') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Transform',
|
||||
iconName: 'shuffle',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'To Uppercase', iconName: 'arrowUp', action: async () => console.log('Uppercase') },
|
||||
{ name: 'To Lowercase', iconName: 'arrowDown', action: async () => console.log('Lowercase') },
|
||||
{ name: 'Capitalize', iconName: 'type', action: async () => console.log('Capitalize') },
|
||||
]
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'trash2',
|
||||
action: async () => console.log('Delete')
|
||||
}
|
||||
]);
|
||||
}}>
|
||||
<h3>Advanced Nested Menu Example</h3>
|
||||
<p>This shows deeply nested submenus and various formatting options</p>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel heading="Static Context Menu (Always Visible)">
|
||||
<dees-contextmenu
|
||||
class="withMargin"
|
||||
.menuItems=${[
|
||||
{
|
||||
name: 'New File',
|
||||
iconName: 'filePlus',
|
||||
shortcut: 'Cmd+N',
|
||||
action: async () => console.log('New file'),
|
||||
name: 'Project',
|
||||
iconName: 'folder',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'New Project', iconName: 'folderPlus', shortcut: 'Cmd+Shift+N', action: async () => console.log('New project') },
|
||||
{ name: 'Open Project', iconName: 'folderOpen', shortcut: 'Cmd+Shift+O', action: async () => console.log('Open project') },
|
||||
{ divider: true },
|
||||
{ name: 'Recent Projects', iconName: 'clock', action: async () => {}, submenu: [
|
||||
{ name: 'Project Alpha', action: async () => console.log('Open Alpha') },
|
||||
{ name: 'Project Beta', action: async () => console.log('Open Beta') },
|
||||
{ name: 'Project Gamma', action: async () => console.log('Open Gamma') },
|
||||
]},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Open File',
|
||||
iconName: 'folderOpen',
|
||||
shortcut: 'Cmd+O',
|
||||
action: async () => console.log('Open file'),
|
||||
},
|
||||
{
|
||||
name: 'Save',
|
||||
iconName: 'save',
|
||||
shortcut: 'Cmd+S',
|
||||
action: async () => console.log('Save'),
|
||||
name: 'Tools',
|
||||
iconName: 'tool',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Terminal', iconName: 'terminal', shortcut: 'Cmd+T', action: async () => console.log('Terminal') },
|
||||
{ name: 'Console', iconName: 'monitor', shortcut: 'Cmd+K', action: async () => console.log('Console') },
|
||||
{ divider: true },
|
||||
{ name: 'Extensions', iconName: 'package', action: async () => console.log('Extensions') },
|
||||
]
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Export',
|
||||
iconName: 'download',
|
||||
action: async () => console.log('Export'),
|
||||
},
|
||||
{
|
||||
name: 'Import',
|
||||
iconName: 'upload',
|
||||
action: async () => console.log('Import'),
|
||||
name: 'Preferences',
|
||||
iconName: 'sliders',
|
||||
action: async () => console.log('Preferences'),
|
||||
},
|
||||
]}
|
||||
></dees-contextmenu>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
`;
|
||||
@@ -31,7 +31,7 @@ export class DeesContextmenu extends DeesElement {
|
||||
// STATIC
|
||||
// This will store all the accumulated menu items
|
||||
public static contextMenuDeactivated = false;
|
||||
public static accumulatedMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] = [];
|
||||
public static accumulatedMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] } | { divider: true })[] = [];
|
||||
|
||||
// Add a global event listener for the right-click context menu
|
||||
public static initializeGlobalListener() {
|
||||
@@ -41,16 +41,16 @@ export class DeesContextmenu extends DeesElement {
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
// Get the target element of the right-click
|
||||
let target: EventTarget | null = event.target;
|
||||
|
||||
// Clear previously accumulated items
|
||||
DeesContextmenu.accumulatedMenuItems = [];
|
||||
|
||||
// Traverse up the DOM tree to accumulate menu items
|
||||
while (target) {
|
||||
if ((target as any).getContextMenuItems) {
|
||||
const items = (target as any).getContextMenuItems();
|
||||
// Use composedPath to properly traverse shadow DOM boundaries
|
||||
const path = event.composedPath();
|
||||
|
||||
// Traverse the composed path to accumulate menu items
|
||||
for (const element of path) {
|
||||
if ((element as any).getContextMenuItems) {
|
||||
const items = (element as any).getContextMenuItems();
|
||||
if (items && items.length > 0) {
|
||||
if (DeesContextmenu.accumulatedMenuItems.length > 0) {
|
||||
DeesContextmenu.accumulatedMenuItems.push({ divider: true });
|
||||
@@ -58,7 +58,6 @@ export class DeesContextmenu extends DeesElement {
|
||||
DeesContextmenu.accumulatedMenuItems.push(...items);
|
||||
}
|
||||
}
|
||||
target = (target as Node).parentNode;
|
||||
}
|
||||
|
||||
// Open the context menu with the accumulated items
|
||||
@@ -67,7 +66,7 @@ export class DeesContextmenu extends DeesElement {
|
||||
}
|
||||
|
||||
// allows opening of a contextmenu with options
|
||||
public static async openContextMenuWithOptions(eventArg: MouseEvent, menuItemsArg: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[]) {
|
||||
public static async openContextMenuWithOptions(eventArg: MouseEvent, menuItemsArg: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] } | { divider: true })[]) {
|
||||
if (this.contextMenuDeactivated) {
|
||||
return;
|
||||
}
|
||||
@@ -80,8 +79,13 @@ export class DeesContextmenu extends DeesElement {
|
||||
contextMenu.style.transform = 'scale(0.95) translateY(-10px)';
|
||||
contextMenu.menuItems = menuItemsArg;
|
||||
contextMenu.windowLayer = await DeesWindowLayer.createAndShow();
|
||||
contextMenu.windowLayer.addEventListener('click', async () => {
|
||||
contextMenu.windowLayer.addEventListener('click', async (event) => {
|
||||
// Check if click is on the context menu or its submenus
|
||||
const clickedElement = event.target as HTMLElement;
|
||||
const isContextMenu = clickedElement.closest('dees-contextmenu');
|
||||
if (!isContextMenu) {
|
||||
await contextMenu.destroy();
|
||||
}
|
||||
})
|
||||
document.body.append(contextMenu);
|
||||
|
||||
@@ -123,9 +127,13 @@ export class DeesContextmenu extends DeesElement {
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; divider?: never } | { divider: true })[] = [];
|
||||
public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[]; divider?: never } | { divider: true })[] = [];
|
||||
windowLayer: DeesWindowLayer;
|
||||
|
||||
private submenu: DeesContextmenu | null = null;
|
||||
private submenuTimeout: any = null;
|
||||
private parentMenu: DeesContextmenu | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.tabIndex = 0;
|
||||
@@ -167,13 +175,22 @@ export class DeesContextmenu extends DeesElement {
|
||||
cursor: default;
|
||||
transition: background 0.1s;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menuitem:hover {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
|
||||
}
|
||||
|
||||
.menuitem:active {
|
||||
.menuitem.has-submenu::after {
|
||||
content: '›';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
font-size: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.menuitem:active:not(.has-submenu) {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
|
||||
}
|
||||
|
||||
@@ -215,14 +232,20 @@ export class DeesContextmenu extends DeesElement {
|
||||
return html`<div class="menu-divider"></div>`;
|
||||
}
|
||||
|
||||
const menuItem = menuItemArg as plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean };
|
||||
const menuItem = menuItemArg as plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: any };
|
||||
const hasSubmenu = menuItem.submenu && menuItem.submenu.length > 0;
|
||||
return html`
|
||||
<div class="menuitem ${menuItem.disabled ? 'disabled' : ''}" @click=${() => !menuItem.disabled && this.handleClick(menuItem)}>
|
||||
<div
|
||||
class="menuitem ${menuItem.disabled ? 'disabled' : ''} ${hasSubmenu ? 'has-submenu' : ''}"
|
||||
@click=${() => !menuItem.disabled && !hasSubmenu && this.handleClick(menuItem)}
|
||||
@mouseenter=${() => this.handleMenuItemHover(menuItem, hasSubmenu)}
|
||||
@mouseleave=${() => this.handleMenuItemLeave()}
|
||||
>
|
||||
${menuItem.iconName ? html`
|
||||
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>
|
||||
` : ''}
|
||||
<span class="menuitem-text">${menuItem.name}</span>
|
||||
${menuItem.shortcut ? html`
|
||||
${menuItem.shortcut && !hasSubmenu ? html`
|
||||
<span class="menuitem-shortcut">${menuItem.shortcut}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -282,18 +305,152 @@ export class DeesContextmenu extends DeesElement {
|
||||
|
||||
public async handleClick(menuItem: plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean }) {
|
||||
menuItem.action();
|
||||
await this.destroy();
|
||||
|
||||
// Close all menus in the chain (this menu and all parent menus)
|
||||
await this.destroyAll();
|
||||
}
|
||||
|
||||
private async handleMenuItemHover(menuItem: plugins.tsclass.website.IMenuItem & { submenu?: any }, hasSubmenu: boolean) {
|
||||
// Clear any existing timeout
|
||||
if (this.submenuTimeout) {
|
||||
clearTimeout(this.submenuTimeout);
|
||||
this.submenuTimeout = null;
|
||||
}
|
||||
|
||||
// Hide any existing submenu if hovering a different item
|
||||
if (this.submenu) {
|
||||
await this.hideSubmenu();
|
||||
}
|
||||
|
||||
// Show submenu if this item has one
|
||||
if (hasSubmenu && menuItem.submenu) {
|
||||
this.submenuTimeout = setTimeout(() => {
|
||||
this.showSubmenu(menuItem);
|
||||
}, 200); // Small delay to prevent accidental triggers
|
||||
}
|
||||
}
|
||||
|
||||
private handleMenuItemLeave() {
|
||||
// Add a delay before hiding to allow moving to submenu
|
||||
if (this.submenuTimeout) {
|
||||
clearTimeout(this.submenuTimeout);
|
||||
}
|
||||
|
||||
this.submenuTimeout = setTimeout(() => {
|
||||
if (this.submenu && !this.submenu.matches(':hover')) {
|
||||
this.hideSubmenu();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
private async showSubmenu(menuItem: plugins.tsclass.website.IMenuItem & { submenu?: any }) {
|
||||
if (!menuItem.submenu || menuItem.submenu.length === 0) return;
|
||||
|
||||
// Find the menu item element
|
||||
const menuItems = Array.from(this.shadowRoot.querySelectorAll('.menuitem'));
|
||||
const menuItemElement = menuItems.find(el => el.querySelector('.menuitem-text')?.textContent === menuItem.name) as HTMLElement;
|
||||
if (!menuItemElement) return;
|
||||
|
||||
// Create submenu
|
||||
this.submenu = new DeesContextmenu();
|
||||
this.submenu.menuItems = menuItem.submenu;
|
||||
this.submenu.parentMenu = this;
|
||||
this.submenu.style.position = 'fixed';
|
||||
this.submenu.style.zIndex = String(parseInt(this.style.zIndex) + 1);
|
||||
this.submenu.style.opacity = '0';
|
||||
this.submenu.style.transform = 'scale(0.95)';
|
||||
|
||||
// Don't create a window layer for submenus
|
||||
document.body.append(this.submenu);
|
||||
|
||||
// Position submenu
|
||||
await domtools.plugins.smartdelay.delayFor(0);
|
||||
const itemRect = menuItemElement.getBoundingClientRect();
|
||||
const menuRect = this.getBoundingClientRect();
|
||||
const submenuRect = this.submenu.getBoundingClientRect();
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
let left = menuRect.right - 4; // Slight overlap
|
||||
let top = itemRect.top;
|
||||
|
||||
// Check if submenu would go off right edge
|
||||
if (left + submenuRect.width > windowWidth - 10) {
|
||||
// Show on left side instead
|
||||
left = menuRect.left - submenuRect.width + 4;
|
||||
}
|
||||
|
||||
// Adjust vertical position if needed
|
||||
if (top + submenuRect.height > window.innerHeight - 10) {
|
||||
top = window.innerHeight - submenuRect.height - 10;
|
||||
}
|
||||
|
||||
this.submenu.style.left = `${left}px`;
|
||||
this.submenu.style.top = `${top}px`;
|
||||
|
||||
// Animate in
|
||||
await domtools.plugins.smartdelay.delayFor(0);
|
||||
this.submenu.style.opacity = '1';
|
||||
this.submenu.style.transform = 'scale(1)';
|
||||
|
||||
// Handle submenu hover
|
||||
this.submenu.addEventListener('mouseenter', () => {
|
||||
if (this.submenuTimeout) {
|
||||
clearTimeout(this.submenuTimeout);
|
||||
this.submenuTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.submenu.addEventListener('mouseleave', () => {
|
||||
this.handleMenuItemLeave();
|
||||
});
|
||||
}
|
||||
|
||||
private async hideSubmenu() {
|
||||
if (!this.submenu) return;
|
||||
|
||||
await this.submenu.destroy();
|
||||
this.submenu = null;
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
if (this.windowLayer) {
|
||||
// Clear timeout
|
||||
if (this.submenuTimeout) {
|
||||
clearTimeout(this.submenuTimeout);
|
||||
this.submenuTimeout = null;
|
||||
}
|
||||
|
||||
// Destroy submenu first
|
||||
if (this.submenu) {
|
||||
await this.submenu.destroy();
|
||||
this.submenu = null;
|
||||
}
|
||||
|
||||
// Only destroy window layer if this is not a submenu
|
||||
if (this.windowLayer && !this.parentMenu) {
|
||||
this.windowLayer.destroy();
|
||||
}
|
||||
|
||||
this.style.opacity = '0';
|
||||
this.style.transform = 'scale(0.95) translateY(-10px)';
|
||||
await domtools.plugins.smartdelay.delayFor(100);
|
||||
|
||||
if (this.parentElement) {
|
||||
this.parentElement.removeChild(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys this menu and all parent menus in the chain
|
||||
*/
|
||||
public async destroyAll() {
|
||||
// First destroy parent menus if they exist
|
||||
if (this.parentMenu) {
|
||||
await this.parentMenu.destroyAll();
|
||||
} else {
|
||||
// If we're at the top level, just destroy this menu
|
||||
await this.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DeesContextmenu.initializeGlobalListener();
|
||||
|
||||
47
ts_web/elements/dees-dashboardgrid/README.md
Normal file
47
ts_web/elements/dees-dashboardgrid/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# dees-dashboardgrid
|
||||
|
||||
`<dees-dashboardgrid>` renders a configurable dashboard layout with draggable and resizable tiles. The component is now grouped in its own folder alongside supporting utilities and styles.
|
||||
|
||||
## Key Features
|
||||
|
||||
- Pointer-driven drag and resize interactions with keyboard fallbacks (arrow keys to move, `Shift` + arrows to resize).
|
||||
- Collision-aware placement that swaps compatible tiles or displaces blocking tiles into the next free slot.
|
||||
- Context menu (right-click on a tile header) that exposes destructive actions such as tile removal via `dees-contextmenu`.
|
||||
- Layout persistence helpers via `getLayout()`, `setLayout(...)`, and the `layout-change` event.
|
||||
- Responsive presets through the `layouts` map and `applyBreakpointLayout(...)` helper to hydrate per-breakpoint arrangements.
|
||||
|
||||
## Public API Highlights
|
||||
|
||||
| Property | Description |
|
||||
| --- | --- |
|
||||
| `widgets` | Array of tile descriptors (`DashboardWidget`). |
|
||||
| `columns` | Number of grid columns. |
|
||||
| `layouts` | Optional record of named layout definitions. |
|
||||
| `activeBreakpoint` | Name of the currently applied breakpoint layout. |
|
||||
| `editable` | Toggles drag/resize affordances. |
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| `addWidget(widget, autoPosition?)` | Adds a tile, optionally auto-placing it into the next free slot. |
|
||||
| `removeWidget(id)` | Removes a tile and emits `widget-remove`. |
|
||||
| `applyBreakpointLayout(name)` | Applies a layout from the `layouts` map. |
|
||||
| `getLayout()` / `setLayout(layout)` | Retrieve or apply persisted layouts. |
|
||||
| `compact(direction?)` | Densifies the grid vertically (default) or horizontally. |
|
||||
|
||||
| Event | Detail payload |
|
||||
| --- | --- |
|
||||
| `widget-move` | `{ widget, displaced, swappedWith }` |
|
||||
| `widget-resize` | `{ widget, displaced, swappedWith }` |
|
||||
| `widget-remove` | `{ widget }` |
|
||||
| `layout-change` | `{ layout }` |
|
||||
|
||||
## Usage Notes
|
||||
|
||||
- **Right-click** a tile header to open the contextual menu and delete the tile.
|
||||
- When resizing, blocking tiles will automatically reflow into free space once the interaction completes.
|
||||
- Listen to `layout-change` to persist layouts to storage; rehydrate using `setLayout` or the `layouts` map.
|
||||
- For responsive dashboards, populate `grid.layouts = { base: [...], mobile: [...] }` and call `applyBreakpointLayout` based on your own breakpoint logic (see the co-located demo for an example).
|
||||
|
||||
## Demo
|
||||
|
||||
The updated `dees-dashboardgrid.demo.ts` showcases live breakpoint switching, layout persistence, and the context menu. Run the demo gallery to explore the interactions end-to-end.
|
||||
29
ts_web/elements/dees-dashboardgrid/contextmenu.ts
Normal file
29
ts_web/elements/dees-dashboardgrid/contextmenu.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { DashboardWidget } from './types.js';
|
||||
import { DeesContextmenu } from '../dees-contextmenu.js';
|
||||
import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
|
||||
import * as plugins from '../00plugins.js';
|
||||
|
||||
export interface WidgetContextMenuOptions {
|
||||
widget: DashboardWidget;
|
||||
host: DeesDashboardgrid;
|
||||
event: MouseEvent;
|
||||
}
|
||||
|
||||
export const openWidgetContextMenu = ({
|
||||
widget,
|
||||
host,
|
||||
event,
|
||||
}: WidgetContextMenuOptions) => {
|
||||
const items: (plugins.tsclass.website.IMenuItem | { divider: true })[] = [
|
||||
{
|
||||
name: 'Delete tile',
|
||||
iconName: 'lucide:trash2' as any,
|
||||
action: async () => {
|
||||
host.removeWidget(widget.id);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
DeesContextmenu.openContextMenuWithOptions(event, items as any);
|
||||
};
|
||||
405
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts
Normal file
405
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
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;
|
||||
|
||||
const seedWidgets = [
|
||||
{
|
||||
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>
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
grid.widgets = seedWidgets.map(widget => ({ ...widget }));
|
||||
grid.cellHeight = 80;
|
||||
grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
|
||||
grid.enableAnimation = true;
|
||||
grid.showGridLines = false;
|
||||
|
||||
const baseLayout = grid.getLayout().map(item => ({ ...item }));
|
||||
const mobileLayout = grid.widgets.map((widget, index) => ({
|
||||
id: widget.id,
|
||||
x: 0,
|
||||
y: index === 0 ? 0 : grid.widgets.slice(0, index).reduce((acc, prev) => acc + prev.h, 0),
|
||||
w: grid.columns,
|
||||
h: widget.h,
|
||||
}));
|
||||
|
||||
grid.layouts = {
|
||||
base: baseLayout,
|
||||
mobile: mobileLayout,
|
||||
};
|
||||
|
||||
const statusEl = elementArg.querySelector('#dashboardLayoutStatus') as HTMLElement;
|
||||
const updateStatus = () => {
|
||||
const layout = grid.getLayout();
|
||||
statusEl.textContent = `Active breakpoint: ${grid.activeBreakpoint} • Tiles: ${layout.length}`;
|
||||
};
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
const handleBreakpoint = () => {
|
||||
const target = mediaQuery.matches ? 'mobile' : 'base';
|
||||
grid.applyBreakpointLayout(target);
|
||||
updateStatus();
|
||||
};
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', handleBreakpoint);
|
||||
} else {
|
||||
(mediaQuery as MediaQueryList & {
|
||||
addListener?: (listener: (this: MediaQueryList, ev: MediaQueryListEvent) => void) => void;
|
||||
}).addListener?.(handleBreakpoint);
|
||||
}
|
||||
handleBreakpoint();
|
||||
|
||||
let widgetCounter = 4;
|
||||
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent?.trim();
|
||||
|
||||
switch (text) {
|
||||
case 'Toggle Animation':
|
||||
button.addEventListener('click', () => {
|
||||
grid.enableAnimation = !grid.enableAnimation;
|
||||
});
|
||||
break;
|
||||
case 'Toggle Grid Lines':
|
||||
button.addEventListener('click', () => {
|
||||
grid.showGridLines = !grid.showGridLines;
|
||||
});
|
||||
break;
|
||||
case '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);
|
||||
});
|
||||
break;
|
||||
case 'Compact Grid':
|
||||
button.addEventListener('click', () => {
|
||||
grid.compact();
|
||||
});
|
||||
break;
|
||||
case 'Toggle Edit Mode':
|
||||
button.addEventListener('click', () => {
|
||||
grid.editable = !grid.editable;
|
||||
button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
|
||||
});
|
||||
break;
|
||||
case 'Reset Layout':
|
||||
button.addEventListener('click', () => {
|
||||
grid.applyBreakpointLayout(grid.activeBreakpoint);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Enhanced logging for reflow events
|
||||
let lastPlaceholderPosition = null;
|
||||
let moveEventCounter = 0;
|
||||
|
||||
// Helper function to log grid state
|
||||
const logGridState = (eventName: string, details?: any) => {
|
||||
const layout = grid.getLayout();
|
||||
console.group(`🔄 ${eventName} [Event #${++moveEventCounter}]`);
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
console.log('Grid Configuration:', {
|
||||
columns: grid.columns,
|
||||
cellHeight: grid.cellHeight,
|
||||
margin: grid.margin,
|
||||
editable: grid.editable,
|
||||
activeBreakpoint: grid.activeBreakpoint
|
||||
});
|
||||
console.log('Current Layout:', layout);
|
||||
console.log('Widget Count:', layout.length);
|
||||
console.log('Grid Bounds:', {
|
||||
totalWidgets: grid.widgets.length,
|
||||
maxY: Math.max(...layout.map(w => w.y + w.h)),
|
||||
occupied: layout.map(w => `${w.id}: (${w.x},${w.y}) ${w.w}x${w.h}`).join(', ')
|
||||
});
|
||||
if (details) {
|
||||
console.log('Event Details:', details);
|
||||
}
|
||||
console.groupEnd();
|
||||
};
|
||||
|
||||
// Monitor placeholder position changes using MutationObserver
|
||||
const placeholderObserver = new MutationObserver(() => {
|
||||
const placeholder = grid.shadowRoot?.querySelector('.placeholder') as HTMLElement;
|
||||
if (placeholder) {
|
||||
const currentPosition = {
|
||||
left: placeholder.style.left,
|
||||
top: placeholder.style.top,
|
||||
width: placeholder.style.width,
|
||||
height: placeholder.style.height
|
||||
};
|
||||
|
||||
if (JSON.stringify(currentPosition) !== JSON.stringify(lastPlaceholderPosition)) {
|
||||
console.group('📍 Placeholder Position Changed');
|
||||
console.log('Previous:', lastPlaceholderPosition);
|
||||
console.log('Current:', currentPosition);
|
||||
|
||||
// Extract grid coordinates from style
|
||||
const gridInfo = grid.shadowRoot?.querySelector('.grid-container');
|
||||
if (gridInfo) {
|
||||
console.log('Grid Container Dimensions:', {
|
||||
width: gridInfo.clientWidth,
|
||||
height: gridInfo.clientHeight
|
||||
});
|
||||
}
|
||||
console.groupEnd();
|
||||
lastPlaceholderPosition = currentPosition;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing the shadow DOM for placeholder changes
|
||||
if (grid.shadowRoot) {
|
||||
placeholderObserver.observe(grid.shadowRoot, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style']
|
||||
});
|
||||
}
|
||||
|
||||
// Log initial state
|
||||
logGridState('Initial Grid State');
|
||||
|
||||
grid.addEventListener('widget-move', (e: CustomEvent) => {
|
||||
logGridState('Widget Move', {
|
||||
widget: e.detail.widget,
|
||||
displaced: e.detail.displaced,
|
||||
swappedWith: e.detail.swappedWith
|
||||
});
|
||||
});
|
||||
|
||||
grid.addEventListener('widget-resize', (e: CustomEvent) => {
|
||||
logGridState('Widget Resize', {
|
||||
widget: e.detail.widget,
|
||||
displaced: e.detail.displaced,
|
||||
swappedWith: e.detail.swappedWith
|
||||
});
|
||||
});
|
||||
|
||||
grid.addEventListener('widget-remove', (e: CustomEvent) => {
|
||||
logGridState('Widget Remove', {
|
||||
removedWidget: e.detail.widget
|
||||
});
|
||||
updateStatus();
|
||||
});
|
||||
|
||||
grid.addEventListener('layout-change', () => {
|
||||
logGridState('Layout Change');
|
||||
updateStatus();
|
||||
});
|
||||
|
||||
// Monitor during drag/resize operations using pointer events
|
||||
grid.addEventListener('pointerdown', (e: PointerEvent) => {
|
||||
const isHeader = (e.target as HTMLElement).closest('.widget-header');
|
||||
const isResizeHandle = (e.target as HTMLElement).closest('.resize-handle');
|
||||
|
||||
if (isHeader || isResizeHandle) {
|
||||
console.group(`🎯 Interaction Started: ${isHeader ? 'Drag' : 'Resize'}`);
|
||||
console.log('Target Widget:', (e.target as HTMLElement).closest('.widget')?.getAttribute('data-widget-id'));
|
||||
console.log('Pointer Position:', { x: e.clientX, y: e.clientY });
|
||||
console.groupEnd();
|
||||
|
||||
// Track pointer move during interaction
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const widget = (e.target as HTMLElement).closest('.widget');
|
||||
if (widget) {
|
||||
console.log(`↔️ Pointer Move:`, {
|
||||
widgetId: widget.getAttribute('data-widget-id'),
|
||||
position: { x: moveEvent.clientX, y: moveEvent.clientY },
|
||||
delta: {
|
||||
x: moveEvent.clientX - e.clientX,
|
||||
y: moveEvent.clientY - e.clientY
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
console.group('🏁 Interaction Ended');
|
||||
logGridState('Final State After Interaction');
|
||||
console.groupEnd();
|
||||
document.removeEventListener('pointermove', handlePointerMove);
|
||||
document.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove);
|
||||
document.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
});
|
||||
|
||||
// Log when widgets are added
|
||||
const originalAddWidget = grid.addWidget.bind(grid);
|
||||
grid.addWidget = (widget: any, autoPosition?: boolean) => {
|
||||
console.group('➕ Adding Widget');
|
||||
console.log('New Widget:', widget);
|
||||
console.log('Auto Position:', autoPosition);
|
||||
const result = originalAddWidget(widget, autoPosition);
|
||||
logGridState('After Widget Added');
|
||||
console.groupEnd();
|
||||
return result;
|
||||
};
|
||||
|
||||
// Log compact operations
|
||||
const originalCompact = grid.compact.bind(grid);
|
||||
grid.compact = (direction?: string) => {
|
||||
console.group('🗜️ Compacting Grid');
|
||||
console.log('Direction:', direction || 'vertical');
|
||||
logGridState('Before Compact');
|
||||
const result = originalCompact(direction);
|
||||
logGridState('After Compact');
|
||||
console.groupEnd();
|
||||
return result;
|
||||
};
|
||||
|
||||
updateStatus();
|
||||
}}>
|
||||
<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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#dashboardLayoutStatus {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
`}
|
||||
</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>Reset Layout</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">
|
||||
<div>Drag to reposition, resize from handles, or right-click a header to delete a tile.</div>
|
||||
<div id="dashboardLayoutStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
||||
796
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
Normal file
796
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
property,
|
||||
state,
|
||||
html,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import '../dees-icon.js';
|
||||
import '../dees-contextmenu.js';
|
||||
import { demoFunc } from './dees-dashboardgrid.demo.js';
|
||||
import { dashboardGridStyles } from './styles.js';
|
||||
import {
|
||||
resolveMargins,
|
||||
calculateCellMetrics,
|
||||
calculateGridHeight,
|
||||
findAvailablePosition,
|
||||
compactLayout,
|
||||
applyLayout,
|
||||
resolveWidgetPlacement,
|
||||
type PlacementResult,
|
||||
} from './layout.js';
|
||||
import {
|
||||
computeGridCoordinates,
|
||||
computeResizeDimensions,
|
||||
type PointerPosition,
|
||||
} from './interaction.js';
|
||||
import { openWidgetContextMenu } from './contextmenu.js';
|
||||
import type {
|
||||
DashboardWidget,
|
||||
DashboardMargin,
|
||||
DashboardResolvedMargins,
|
||||
GridCellMetrics,
|
||||
DashboardLayoutItem,
|
||||
LayoutDirection,
|
||||
CellHeightUnit,
|
||||
} from './types.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-dashboardgrid': DeesDashboardgrid;
|
||||
}
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
widgetId: string;
|
||||
pointerId: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
start: DashboardLayoutItem;
|
||||
previousPosition: DashboardLayoutItem;
|
||||
currentPointer: PointerPosition;
|
||||
lastPlacement: PlacementResult | null;
|
||||
};
|
||||
|
||||
type ResizeState = {
|
||||
widgetId: string;
|
||||
pointerId: number;
|
||||
handler: 'e' | 's' | 'se';
|
||||
startPointer: PointerPosition;
|
||||
start: DashboardLayoutItem;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
lastPlacement: PlacementResult | null;
|
||||
};
|
||||
|
||||
@customElement('dees-dashboardgrid')
|
||||
export class DeesDashboardgrid extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
public static styles = dashboardGridStyles;
|
||||
|
||||
@property({ type: Array })
|
||||
public widgets: DashboardWidget[] = [];
|
||||
|
||||
@property({ type: Number })
|
||||
public cellHeight: number = 80;
|
||||
|
||||
@property({ type: Object })
|
||||
public margin: DashboardMargin = 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: CellHeightUnit = 'px';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public rtl: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public showGridLines: boolean = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public layouts?: Record<string, DashboardLayoutItem[]>;
|
||||
|
||||
@property({ type: String })
|
||||
public activeBreakpoint: string = 'base';
|
||||
|
||||
@state()
|
||||
private placeholderPosition: DashboardLayoutItem | null = null;
|
||||
|
||||
@state()
|
||||
private metrics: GridCellMetrics | null = null;
|
||||
|
||||
@state()
|
||||
private resolvedMargins: DashboardResolvedMargins | null = null;
|
||||
|
||||
@state()
|
||||
private previewWidgets: DashboardWidget[] | null = null;
|
||||
|
||||
private containerBounds: DOMRect | null = null;
|
||||
private dragState: DragState | null = null;
|
||||
private resizeState: ResizeState | null = null;
|
||||
private resizeObserver?: ResizeObserver;
|
||||
private interactionActive = false;
|
||||
|
||||
public override async connectedCallback(): Promise<void> {
|
||||
await super.connectedCallback();
|
||||
this.computeMetrics();
|
||||
this.observeResize();
|
||||
}
|
||||
|
||||
public override async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
this.disconnectResizeObserver();
|
||||
this.releasePointerEvents();
|
||||
}
|
||||
|
||||
protected updated(changed: Map<string, unknown>): void {
|
||||
if (
|
||||
changed.has('margin') ||
|
||||
changed.has('columns') ||
|
||||
changed.has('cellHeight') ||
|
||||
changed.has('cellHeightUnit')
|
||||
) {
|
||||
this.computeMetrics();
|
||||
}
|
||||
|
||||
if (changed.has('widgets') && !this.interactionActive) {
|
||||
this.notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const baseWidgets = this.widgets;
|
||||
if (baseWidgets.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 metrics = this.ensureMetrics();
|
||||
const margins = this.resolvedMargins ?? resolveMargins(this.margin);
|
||||
const cellHeight = metrics.cellHeightPx;
|
||||
const layoutForHeight = this.previewWidgets ?? this.widgets;
|
||||
const gridHeight = calculateGridHeight(layoutForHeight, margins, cellHeight);
|
||||
const previewMap = this.previewWidgets ? new Map(this.previewWidgets.map(widget => [widget.id, widget])) : null;
|
||||
|
||||
return html`
|
||||
<div class="grid-container" style="height: ${gridHeight}px;">
|
||||
${this.showGridLines ? this.renderGridLines(metrics, gridHeight) : null}
|
||||
${baseWidgets.map(widget => this.renderWidget(widget, metrics, margins, previewMap))}
|
||||
${this.placeholderPosition ? this.renderPlaceholder(metrics, margins) : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGridLines(metrics: GridCellMetrics, gridHeight: number): TemplateResult {
|
||||
const vertical: TemplateResult[] = [];
|
||||
const horizontal: TemplateResult[] = [];
|
||||
const cellPlusMarginX = metrics.cellWidthPx + metrics.marginHorizontalPx;
|
||||
const cellPlusMarginY = metrics.cellHeightPx + metrics.marginVerticalPx;
|
||||
|
||||
for (let i = 0; i <= this.columns; i++) {
|
||||
const leftPx = i * cellPlusMarginX + metrics.marginHorizontalPx;
|
||||
const leftPercent = this.pxToPercent(leftPx, metrics.containerWidth);
|
||||
vertical.push(html`<div class="grid-line-vertical" style="left: ${leftPercent}%;"></div>`);
|
||||
}
|
||||
|
||||
const rows = Math.ceil(gridHeight / cellPlusMarginY);
|
||||
for (let row = 0; row <= rows; row++) {
|
||||
const top = row * cellPlusMarginY;
|
||||
horizontal.push(html`<div class="grid-line-horizontal" style="top: ${top}px;"></div>`);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="grid-lines">
|
||||
${vertical}
|
||||
${horizontal}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWidget(
|
||||
widget: DashboardWidget,
|
||||
metrics: GridCellMetrics,
|
||||
margins: DashboardResolvedMargins,
|
||||
previewMap: Map<string, DashboardWidget> | null,
|
||||
): TemplateResult {
|
||||
const isDragging = this.dragState?.widgetId === widget.id;
|
||||
const isResizing = this.resizeState?.widgetId === widget.id;
|
||||
const isLocked = widget.locked || !this.editable;
|
||||
const previewWidget = previewMap?.get(widget.id) ?? null;
|
||||
const layoutForRender = isDragging ? widget : previewWidget ?? widget;
|
||||
const rect = this.computeWidgetRect(layoutForRender, metrics, margins);
|
||||
|
||||
const sideProperty = this.rtl ? 'right' : 'left';
|
||||
const sideValue = this.pxToPercent(rect.left, metrics.containerWidth);
|
||||
const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth);
|
||||
|
||||
let transform = '';
|
||||
if (isDragging && this.dragState?.currentPointer) {
|
||||
const pointer = this.dragState.currentPointer;
|
||||
const bounds = this.containerBounds ?? this.getBoundingClientRect();
|
||||
const translateX = pointer.clientX - bounds.left - this.dragState.offsetX - rect.left;
|
||||
const translateY = pointer.clientY - bounds.top - this.dragState.offsetY - rect.top;
|
||||
transform = `transform: translate(${translateX}px, ${translateY}px);`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''}"
|
||||
style="
|
||||
${sideProperty}: ${sideValue}%;
|
||||
top: ${rect.top}px;
|
||||
width: ${widthPercent}%;
|
||||
height: ${rect.height}px;
|
||||
${transform}
|
||||
"
|
||||
data-widget-id=${widget.id}
|
||||
>
|
||||
<div class="widget-content">
|
||||
${widget.title
|
||||
? html`
|
||||
<div
|
||||
class="widget-header ${isLocked ? 'locked' : ''}"
|
||||
@pointerdown=${!isLocked && !widget.noMove
|
||||
? (evt: PointerEvent) => this.startDrag(evt, widget)
|
||||
: null}
|
||||
@contextmenu=${(evt: MouseEvent) => this.handleWidgetContextMenu(evt, widget)}
|
||||
tabindex=${!isLocked && !widget.noMove ? 0 : -1}
|
||||
@keydown=${(evt: KeyboardEvent) => this.handleHeaderKeydown(evt, widget)}
|
||||
>
|
||||
${widget.icon ? html`<dees-icon .icon=${widget.icon}></dees-icon>` : null}
|
||||
${widget.title}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
<div class="widget-body ${widget.title ? 'has-header' : ''}">
|
||||
${widget.content}
|
||||
</div>
|
||||
${!isLocked && !widget.noResize
|
||||
? html`
|
||||
<div
|
||||
class="resize-handle resize-handle-e"
|
||||
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 'e')}
|
||||
></div>
|
||||
<div
|
||||
class="resize-handle resize-handle-s"
|
||||
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 's')}
|
||||
></div>
|
||||
<div
|
||||
class="resize-handle resize-handle-se"
|
||||
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 'se')}
|
||||
></div>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(
|
||||
metrics: GridCellMetrics,
|
||||
margins: DashboardResolvedMargins,
|
||||
): TemplateResult {
|
||||
if (!this.placeholderPosition) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const rect = this.computeWidgetRect(this.placeholderPosition, metrics, margins);
|
||||
const sideProperty = this.rtl ? 'right' : 'left';
|
||||
const sideValue = this.pxToPercent(rect.left, metrics.containerWidth);
|
||||
const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget placeholder"
|
||||
style="
|
||||
${sideProperty}: ${sideValue}%;
|
||||
top: ${rect.top}px;
|
||||
width: ${widthPercent}%;
|
||||
height: ${rect.height}px;
|
||||
"
|
||||
>
|
||||
<div class="widget-content"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private startDrag(event: PointerEvent, widget: DashboardWidget): void {
|
||||
if (!this.editable || widget.noMove || widget.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const widgetElement = (event.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement | null;
|
||||
if (!widgetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const widgetRect = widgetElement.getBoundingClientRect();
|
||||
this.containerBounds = this.getBoundingClientRect();
|
||||
this.ensureMetrics();
|
||||
|
||||
this.dragState = {
|
||||
widgetId: widget.id,
|
||||
pointerId: event.pointerId,
|
||||
offsetX: event.clientX - widgetRect.left,
|
||||
offsetY: event.clientY - widgetRect.top,
|
||||
start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
||||
previousPosition: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
||||
currentPointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
lastPlacement: null,
|
||||
};
|
||||
|
||||
this.interactionActive = true;
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
document.addEventListener('pointermove', this.handleDragMove);
|
||||
document.addEventListener('pointerup', this.handleDragEnd);
|
||||
|
||||
this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h };
|
||||
}
|
||||
|
||||
private handleDragMove = (event: PointerEvent): void => {
|
||||
if (!this.dragState) return;
|
||||
const metrics = this.ensureMetrics();
|
||||
const activeWidgets = this.widgets;
|
||||
const widget = activeWidgets.find(item => item.id === this.dragState!.widgetId);
|
||||
if (!widget) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const previousPosition = this.dragState.previousPosition;
|
||||
|
||||
const coords = computeGridCoordinates({
|
||||
pointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
containerRect: this.containerBounds ?? this.getBoundingClientRect(),
|
||||
metrics,
|
||||
columns: this.columns,
|
||||
widget,
|
||||
rtl: this.rtl,
|
||||
dragOffsetX: this.dragState.offsetX,
|
||||
dragOffsetY: this.dragState.offsetY,
|
||||
});
|
||||
|
||||
const placement = resolveWidgetPlacement(
|
||||
activeWidgets,
|
||||
widget.id,
|
||||
{ x: coords.x, y: coords.y },
|
||||
this.columns,
|
||||
previousPosition,
|
||||
);
|
||||
if (placement) {
|
||||
const updatedWidget = placement.widgets.find(item => item.id === widget.id);
|
||||
this.dragState = {
|
||||
...this.dragState,
|
||||
currentPointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
lastPlacement: placement,
|
||||
previousPosition: updatedWidget
|
||||
? { id: updatedWidget.id, x: updatedWidget.x, y: updatedWidget.y, w: updatedWidget.w, h: updatedWidget.h }
|
||||
: { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h },
|
||||
};
|
||||
this.previewWidgets = placement.widgets;
|
||||
const previewWidget = placement.widgets.find(item => item.id === widget.id);
|
||||
if (previewWidget) {
|
||||
this.placeholderPosition = {
|
||||
id: previewWidget.id,
|
||||
x: previewWidget.x,
|
||||
y: previewWidget.y,
|
||||
w: previewWidget.w,
|
||||
h: previewWidget.h,
|
||||
};
|
||||
} else {
|
||||
this.placeholderPosition = { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h };
|
||||
}
|
||||
} else {
|
||||
this.previewWidgets = null;
|
||||
this.placeholderPosition = null;
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private handleDragEnd = (event: PointerEvent): void => {
|
||||
const dragState = this.dragState;
|
||||
if (!dragState || event.pointerId !== dragState.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutSource = this.widgets;
|
||||
this.previewWidgets = null;
|
||||
|
||||
// Always validate the final position, don't rely on lastPlacement from drag
|
||||
const target = this.placeholderPosition ?? dragState.start;
|
||||
const placement = resolveWidgetPlacement(
|
||||
layoutSource,
|
||||
dragState.widgetId,
|
||||
{ x: target.x, y: target.y },
|
||||
this.columns,
|
||||
dragState.previousPosition,
|
||||
);
|
||||
|
||||
if (placement) {
|
||||
// Verify that the placement doesn't result in overlapping widgets
|
||||
const finalWidget = placement.widgets.find(w => w.id === dragState.widgetId);
|
||||
if (finalWidget) {
|
||||
const hasOverlap = placement.widgets.some(w => {
|
||||
if (w.id === dragState.widgetId) return false;
|
||||
return (
|
||||
finalWidget.x < w.x + w.w &&
|
||||
finalWidget.x + finalWidget.w > w.x &&
|
||||
finalWidget.y < w.y + w.h &&
|
||||
finalWidget.y + finalWidget.h > w.y
|
||||
);
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
this.commitPlacement(placement, dragState.widgetId, 'widget-move');
|
||||
} else {
|
||||
// Return to start position if overlap detected
|
||||
this.widgets = this.widgets.map(widget =>
|
||||
widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Return to start position if no valid placement
|
||||
this.widgets = this.widgets.map(widget =>
|
||||
widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget,
|
||||
);
|
||||
}
|
||||
|
||||
this.placeholderPosition = null;
|
||||
this.dragState = null;
|
||||
this.interactionActive = false;
|
||||
this.releasePointerEvents();
|
||||
};
|
||||
|
||||
private startResize(event: PointerEvent, widget: DashboardWidget, handler: 'e' | 's' | 'se'): void {
|
||||
if (!this.editable || widget.noResize || widget.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.ensureMetrics();
|
||||
|
||||
this.resizeState = {
|
||||
widgetId: widget.id,
|
||||
pointerId: event.pointerId,
|
||||
handler,
|
||||
startPointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
||||
startWidth: widget.w,
|
||||
startHeight: widget.h,
|
||||
lastPlacement: null,
|
||||
};
|
||||
|
||||
this.interactionActive = true;
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
document.addEventListener('pointermove', this.handleResizeMove);
|
||||
document.addEventListener('pointerup', this.handleResizeEnd);
|
||||
|
||||
this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h };
|
||||
}
|
||||
|
||||
private handleResizeMove = (event: PointerEvent): void => {
|
||||
if (!this.resizeState) return;
|
||||
const metrics = this.ensureMetrics();
|
||||
const activeWidgets = this.widgets;
|
||||
const widget = activeWidgets.find(item => item.id === this.resizeState!.widgetId);
|
||||
if (!widget) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const nextSize = computeResizeDimensions({
|
||||
pointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
containerRect: this.containerBounds ?? this.getBoundingClientRect(),
|
||||
metrics,
|
||||
startWidth: this.resizeState.startWidth,
|
||||
startHeight: this.resizeState.startHeight,
|
||||
startPointer: this.resizeState.startPointer,
|
||||
handler: this.resizeState.handler,
|
||||
widget,
|
||||
columns: this.columns,
|
||||
});
|
||||
|
||||
const placement = resolveWidgetPlacement(
|
||||
activeWidgets,
|
||||
widget.id,
|
||||
{ x: widget.x, y: widget.y, w: nextSize.width, h: nextSize.height },
|
||||
this.columns,
|
||||
this.resizeState.start,
|
||||
);
|
||||
|
||||
if (placement) {
|
||||
this.resizeState = { ...this.resizeState, lastPlacement: placement };
|
||||
this.previewWidgets = placement.widgets;
|
||||
const previewWidget = placement.widgets.find(item => item.id === widget.id);
|
||||
if (previewWidget) {
|
||||
this.placeholderPosition = {
|
||||
id: previewWidget.id,
|
||||
x: previewWidget.x,
|
||||
y: previewWidget.y,
|
||||
w: previewWidget.w,
|
||||
h: previewWidget.h,
|
||||
};
|
||||
} else {
|
||||
this.placeholderPosition = {
|
||||
id: widget.id,
|
||||
x: widget.x,
|
||||
y: widget.y,
|
||||
w: nextSize.width,
|
||||
h: nextSize.height,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
this.previewWidgets = null;
|
||||
this.placeholderPosition = null;
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private handleResizeEnd = (event: PointerEvent): void => {
|
||||
const resizeState = this.resizeState;
|
||||
if (!resizeState || event.pointerId !== resizeState.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutSource = this.widgets;
|
||||
this.previewWidgets = null;
|
||||
const placement =
|
||||
resizeState.lastPlacement ??
|
||||
resolveWidgetPlacement(
|
||||
layoutSource,
|
||||
resizeState.widgetId,
|
||||
{
|
||||
x: this.placeholderPosition?.x ?? resizeState.start.x,
|
||||
y: this.placeholderPosition?.y ?? resizeState.start.y,
|
||||
w: this.placeholderPosition?.w ?? resizeState.start.w,
|
||||
h: this.placeholderPosition?.h ?? resizeState.start.h,
|
||||
},
|
||||
this.columns,
|
||||
resizeState.start,
|
||||
);
|
||||
|
||||
if (placement) {
|
||||
this.commitPlacement(placement, resizeState.widgetId, 'widget-resize');
|
||||
} else {
|
||||
this.widgets = this.widgets.map(widget =>
|
||||
widget.id === resizeState.widgetId ? { ...widget, w: resizeState.start.w, h: resizeState.start.h } : widget,
|
||||
);
|
||||
}
|
||||
|
||||
this.placeholderPosition = null;
|
||||
this.resizeState = null;
|
||||
this.interactionActive = false;
|
||||
this.releasePointerEvents();
|
||||
};
|
||||
|
||||
private handleHeaderKeydown(event: KeyboardEvent, widget: DashboardWidget): void {
|
||||
if (!this.editable || widget.noMove || widget.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key;
|
||||
const isResize = event.shiftKey;
|
||||
let placement: PlacementResult | null = null;
|
||||
|
||||
if (isResize && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(key)) {
|
||||
event.preventDefault();
|
||||
const delta = key === 'ArrowRight' || key === 'ArrowDown' ? 1 : -1;
|
||||
|
||||
if (key === 'ArrowLeft' || key === 'ArrowRight') {
|
||||
const maxWidth = widget.maxW ?? this.columns - widget.x;
|
||||
const nextWidth = Math.max(widget.minW ?? 1, Math.min(maxWidth, widget.w + delta));
|
||||
placement = resolveWidgetPlacement(
|
||||
this.widgets,
|
||||
widget.id,
|
||||
{ x: widget.x, y: widget.y, w: nextWidth, h: widget.h },
|
||||
this.columns,
|
||||
);
|
||||
} else {
|
||||
const maxHeight = widget.maxH ?? Number.POSITIVE_INFINITY;
|
||||
const nextHeight = Math.max(widget.minH ?? 1, Math.min(maxHeight, widget.h + delta));
|
||||
placement = resolveWidgetPlacement(
|
||||
this.widgets,
|
||||
widget.id,
|
||||
{ x: widget.x, y: widget.y, w: widget.w, h: nextHeight },
|
||||
this.columns,
|
||||
);
|
||||
}
|
||||
|
||||
if (placement) {
|
||||
this.commitPlacement(placement, widget.id, 'widget-resize');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const moveMap: Record<string, { dx: number; dy: number }> = {
|
||||
ArrowLeft: { dx: -1, dy: 0 },
|
||||
ArrowRight: { dx: 1, dy: 0 },
|
||||
ArrowUp: { dx: 0, dy: -1 },
|
||||
ArrowDown: { dx: 0, dy: 1 },
|
||||
};
|
||||
|
||||
const delta = moveMap[key];
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const targetX = Math.max(0, Math.min(this.columns - widget.w, widget.x + delta.dx));
|
||||
const targetY = Math.max(0, widget.y + delta.dy);
|
||||
|
||||
placement = resolveWidgetPlacement(this.widgets, widget.id, { x: targetX, y: targetY }, this.columns);
|
||||
if (placement) {
|
||||
this.commitPlacement(placement, widget.id, 'widget-move');
|
||||
}
|
||||
}
|
||||
|
||||
private handleWidgetContextMenu(event: MouseEvent, widget: DashboardWidget): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openWidgetContextMenu({ widget, host: this, event });
|
||||
}
|
||||
|
||||
private commitPlacement(result: PlacementResult, widgetId: string, type: 'widget-move' | 'widget-resize'): void {
|
||||
this.previewWidgets = null;
|
||||
this.widgets = result.widgets;
|
||||
const subject = this.widgets.find(item => item.id === widgetId);
|
||||
if (subject) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(type, {
|
||||
detail: {
|
||||
widget: subject,
|
||||
displaced: result.movedWidgets.filter(id => id !== widgetId),
|
||||
swappedWith: result.swappedWith,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public removeWidget(widgetId: string): void {
|
||||
const target = this.widgets.find(widget => widget.id === widgetId);
|
||||
if (!target) return;
|
||||
this.widgets = this.widgets.filter(widget => widget.id !== widgetId);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('widget-remove', {
|
||||
detail: { widget: target },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public updateWidget(widgetId: string, updates: Partial<DashboardWidget>): void {
|
||||
this.widgets = this.widgets.map(widget => (widget.id === widgetId ? { ...widget, ...updates } : widget));
|
||||
}
|
||||
|
||||
public getLayout(): DashboardLayoutItem[] {
|
||||
return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }));
|
||||
}
|
||||
|
||||
public setLayout(layout: DashboardLayoutItem[]): void {
|
||||
this.widgets = applyLayout(this.widgets, layout);
|
||||
}
|
||||
|
||||
public lockGrid(): void {
|
||||
this.editable = false;
|
||||
}
|
||||
|
||||
public unlockGrid(): void {
|
||||
this.editable = true;
|
||||
}
|
||||
|
||||
public addWidget(widget: DashboardWidget, autoPosition = false): void {
|
||||
const nextWidget = { ...widget };
|
||||
if (autoPosition || nextWidget.autoPosition) {
|
||||
const position = findAvailablePosition(this.widgets, nextWidget.w, nextWidget.h, this.columns);
|
||||
nextWidget.x = position.x;
|
||||
nextWidget.y = position.y;
|
||||
}
|
||||
|
||||
this.widgets = [...this.widgets, nextWidget];
|
||||
}
|
||||
|
||||
public compact(direction: LayoutDirection = 'vertical'): void {
|
||||
const nextWidgets = this.widgets.map(widget => ({ ...widget }));
|
||||
compactLayout(nextWidgets, direction);
|
||||
this.widgets = nextWidgets;
|
||||
}
|
||||
|
||||
public applyBreakpointLayout(breakpoint: string): void {
|
||||
this.activeBreakpoint = breakpoint;
|
||||
const layout = this.layouts?.[breakpoint];
|
||||
if (layout) {
|
||||
this.setLayout(layout);
|
||||
}
|
||||
}
|
||||
|
||||
public notifyLayoutChange(): void {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('layout-change', {
|
||||
detail: { layout: this.getLayout() },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private ensureMetrics(): GridCellMetrics {
|
||||
if (!this.metrics) {
|
||||
this.computeMetrics();
|
||||
}
|
||||
return this.metrics!;
|
||||
}
|
||||
|
||||
private computeMetrics(): void {
|
||||
if (!this.isConnected) return;
|
||||
const bounds = this.getBoundingClientRect();
|
||||
this.containerBounds = bounds;
|
||||
const margins = resolveMargins(this.margin);
|
||||
this.resolvedMargins = margins;
|
||||
this.metrics = calculateCellMetrics(bounds.width, this.columns, margins, this.cellHeight, this.cellHeightUnit);
|
||||
}
|
||||
|
||||
private observeResize(): void {
|
||||
if (this.resizeObserver) return;
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.computeMetrics();
|
||||
});
|
||||
this.resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
private disconnectResizeObserver(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = undefined;
|
||||
}
|
||||
|
||||
private releasePointerEvents(): void {
|
||||
document.removeEventListener('pointermove', this.handleDragMove);
|
||||
document.removeEventListener('pointerup', this.handleDragEnd);
|
||||
document.removeEventListener('pointermove', this.handleResizeMove);
|
||||
document.removeEventListener('pointerup', this.handleResizeEnd);
|
||||
}
|
||||
|
||||
private pxToPercent(value: number, container: number): number {
|
||||
if (!container) return 0;
|
||||
return Number(((value / container) * 100).toFixed(4));
|
||||
}
|
||||
|
||||
private computeWidgetRect(
|
||||
widget: Pick<DashboardWidget, 'x' | 'y' | 'w' | 'h'>,
|
||||
metrics: GridCellMetrics,
|
||||
margins: DashboardResolvedMargins,
|
||||
) {
|
||||
const cellWidth = metrics.cellWidthPx;
|
||||
const cellHeight = metrics.cellHeightPx;
|
||||
const left = widget.x * (cellWidth + margins.horizontal) + margins.horizontal;
|
||||
const top = widget.y * (cellHeight + margins.vertical) + margins.vertical;
|
||||
const width = widget.w * cellWidth + Math.max(0, widget.w - 1) * margins.horizontal;
|
||||
const height = widget.h * cellHeight + Math.max(0, widget.h - 1) * margins.vertical;
|
||||
|
||||
return { left, top, width, height };
|
||||
}
|
||||
}
|
||||
2
ts_web/elements/dees-dashboardgrid/index.ts
Normal file
2
ts_web/elements/dees-dashboardgrid/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dees-dashboardgrid.js';
|
||||
export * from './types.js';
|
||||
105
ts_web/elements/dees-dashboardgrid/interaction.ts
Normal file
105
ts_web/elements/dees-dashboardgrid/interaction.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { DashboardWidget, GridCellMetrics } from './types.js';
|
||||
|
||||
export interface PointerPosition {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
export interface DragComputationArgs {
|
||||
pointer: PointerPosition;
|
||||
containerRect: DOMRect;
|
||||
metrics: GridCellMetrics;
|
||||
columns: number;
|
||||
widget: DashboardWidget;
|
||||
rtl: boolean;
|
||||
dragOffsetX?: number;
|
||||
dragOffsetY?: number;
|
||||
}
|
||||
|
||||
export const computeGridCoordinates = ({
|
||||
pointer,
|
||||
containerRect,
|
||||
metrics,
|
||||
columns,
|
||||
widget,
|
||||
rtl,
|
||||
dragOffsetX = 0,
|
||||
dragOffsetY = 0,
|
||||
}: DragComputationArgs): { x: number; y: number } => {
|
||||
const relativeX = pointer.clientX - containerRect.left - dragOffsetX;
|
||||
const relativeY = pointer.clientY - containerRect.top - dragOffsetY;
|
||||
|
||||
const marginX = metrics.marginHorizontalPx;
|
||||
const marginY = metrics.marginVerticalPx;
|
||||
const cellWidth = metrics.cellWidthPx;
|
||||
const cellHeight = metrics.cellHeightPx;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const adjustedX = clamp(relativeX - marginX, 0, containerRect.width - marginX);
|
||||
const adjustedY = clamp(relativeY - marginY, 0, Number.POSITIVE_INFINITY);
|
||||
|
||||
const cellPlusMarginX = cellWidth + marginX;
|
||||
const cellPlusMarginY = cellHeight + marginY;
|
||||
|
||||
let gridX = Math.round(adjustedX / cellPlusMarginX);
|
||||
if (rtl) {
|
||||
gridX = columns - widget.w - gridX;
|
||||
}
|
||||
gridX = clamp(gridX, 0, columns - widget.w);
|
||||
|
||||
const gridY = clamp(Math.round(adjustedY / cellPlusMarginY), 0, Number.MAX_SAFE_INTEGER);
|
||||
|
||||
return { x: gridX, y: gridY };
|
||||
};
|
||||
|
||||
export interface ResizeComputationArgs {
|
||||
pointer: PointerPosition;
|
||||
containerRect: DOMRect;
|
||||
metrics: GridCellMetrics;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
startPointer: PointerPosition;
|
||||
handler: 'e' | 's' | 'se';
|
||||
widget: DashboardWidget;
|
||||
columns: number;
|
||||
}
|
||||
|
||||
export const computeResizeDimensions = ({
|
||||
pointer,
|
||||
containerRect,
|
||||
metrics,
|
||||
startWidth,
|
||||
startHeight,
|
||||
startPointer,
|
||||
handler,
|
||||
widget,
|
||||
columns,
|
||||
}: ResizeComputationArgs): { width: number; height: number } => {
|
||||
const deltaX = pointer.clientX - startPointer.clientX;
|
||||
const deltaY = pointer.clientY - startPointer.clientY;
|
||||
|
||||
let width = startWidth;
|
||||
let height = startHeight;
|
||||
|
||||
const cellPlusMarginX = metrics.cellWidthPx + metrics.marginHorizontalPx;
|
||||
const cellPlusMarginY = metrics.cellHeightPx + metrics.marginVerticalPx;
|
||||
|
||||
if (handler.includes('e')) {
|
||||
const deltaCols = Math.round(deltaX / cellPlusMarginX);
|
||||
width = startWidth + deltaCols;
|
||||
}
|
||||
|
||||
if (handler.includes('s')) {
|
||||
const deltaRows = Math.round(deltaY / cellPlusMarginY);
|
||||
height = startHeight + deltaRows;
|
||||
}
|
||||
|
||||
const clampedWidth = Math.max(widget.minW || 1, Math.min(width, widget.maxW || columns - widget.x));
|
||||
const clampedHeight = Math.max(widget.minH || 1, Math.min(height, widget.maxH || Number.MAX_SAFE_INTEGER));
|
||||
|
||||
return {
|
||||
width: clampedWidth,
|
||||
height: clampedHeight,
|
||||
};
|
||||
};
|
||||
246
ts_web/elements/dees-dashboardgrid/layout.ts
Normal file
246
ts_web/elements/dees-dashboardgrid/layout.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type {
|
||||
DashboardResolvedMargins,
|
||||
DashboardMargin,
|
||||
DashboardWidget,
|
||||
DashboardLayoutItem,
|
||||
GridCellMetrics,
|
||||
LayoutDirection,
|
||||
} from './types.js';
|
||||
|
||||
export const DEFAULT_MARGIN = 10;
|
||||
|
||||
export const resolveMargins = (margin: DashboardMargin): DashboardResolvedMargins => {
|
||||
if (typeof margin === 'number') {
|
||||
return {
|
||||
horizontal: margin,
|
||||
vertical: margin,
|
||||
top: margin,
|
||||
right: margin,
|
||||
bottom: margin,
|
||||
left: margin,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = {
|
||||
top: margin.top ?? DEFAULT_MARGIN,
|
||||
right: margin.right ?? DEFAULT_MARGIN,
|
||||
bottom: margin.bottom ?? DEFAULT_MARGIN,
|
||||
left: margin.left ?? DEFAULT_MARGIN,
|
||||
};
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
horizontal: (resolved.left + resolved.right) / 2,
|
||||
vertical: (resolved.top + resolved.bottom) / 2,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateCellMetrics = (
|
||||
containerWidth: number,
|
||||
columns: number,
|
||||
margins: DashboardResolvedMargins,
|
||||
cellHeight: number,
|
||||
cellHeightUnit: string,
|
||||
): GridCellMetrics => {
|
||||
const totalMarginWidth = margins.horizontal * (columns + 1);
|
||||
const availableWidth = Math.max(containerWidth - totalMarginWidth, 0);
|
||||
const cellWidthPx = columns > 0 ? availableWidth / columns : 0;
|
||||
const cellHeightPx = cellHeightUnit === 'auto' ? cellWidthPx : cellHeight;
|
||||
|
||||
return {
|
||||
containerWidth,
|
||||
cellWidthPx,
|
||||
marginHorizontalPx: margins.horizontal,
|
||||
cellHeightPx,
|
||||
marginVerticalPx: margins.vertical,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateGridHeight = (
|
||||
widgets: DashboardWidget[],
|
||||
margins: DashboardResolvedMargins,
|
||||
cellHeight: number,
|
||||
): number => {
|
||||
if (widgets.length === 0) return 0;
|
||||
const maxY = Math.max(...widgets.map(widget => widget.y + widget.h), 0);
|
||||
return maxY * cellHeight + (maxY + 1) * margins.vertical;
|
||||
};
|
||||
|
||||
const overlaps = (
|
||||
widget: DashboardWidget,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
) => x < widget.x + widget.w && x + w > widget.x && y < widget.y + widget.h && y + h > widget.y;
|
||||
|
||||
export const collectCollisions = (
|
||||
widgets: DashboardWidget[],
|
||||
target: DashboardWidget,
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
nextW: number = target.w,
|
||||
nextH: number = target.h,
|
||||
): DashboardWidget[] => {
|
||||
return widgets.filter(widget => {
|
||||
if (widget.id === target.id) return false;
|
||||
return overlaps(widget, nextX, nextY, nextW, nextH);
|
||||
});
|
||||
};
|
||||
|
||||
export const checkCollision = (
|
||||
widgets: DashboardWidget[],
|
||||
target: DashboardWidget,
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
): boolean => collectCollisions(widgets, target, nextX, nextY).length > 0;
|
||||
|
||||
export const cloneWidget = (widget: DashboardWidget): DashboardWidget => ({ ...widget });
|
||||
|
||||
export const cloneWidgets = (widgets: DashboardWidget[]): DashboardWidget[] => widgets.map(cloneWidget);
|
||||
|
||||
export const findAvailablePosition = (
|
||||
widgets: DashboardWidget[],
|
||||
width: number,
|
||||
height: number,
|
||||
columns: number,
|
||||
): { x: number; y: number } => {
|
||||
for (let y = 0; y < 200; y++) {
|
||||
for (let x = 0; x <= columns - width; x++) {
|
||||
const isFree = !widgets.some(widget => overlaps(widget, x, y, width, height));
|
||||
if (isFree) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxY = widgets.reduce((acc, widget) => Math.max(acc, widget.y + widget.h), 0);
|
||||
return { x: 0, y: maxY };
|
||||
};
|
||||
|
||||
export interface PlacementResult {
|
||||
widgets: DashboardWidget[];
|
||||
movedWidgets: string[];
|
||||
swappedWith?: string;
|
||||
}
|
||||
|
||||
export const resolveWidgetPlacement = (
|
||||
widgets: DashboardWidget[],
|
||||
widgetId: string,
|
||||
next: { x: number; y: number; w?: number; h?: number },
|
||||
columns: number,
|
||||
previousPosition?: DashboardLayoutItem,
|
||||
): PlacementResult | null => {
|
||||
const sourceWidgets = cloneWidgets(widgets);
|
||||
const moving = sourceWidgets.find(widget => widget.id === widgetId);
|
||||
const original = widgets.find(widget => widget.id === widgetId);
|
||||
if (!moving || !original) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = {
|
||||
x: next.x,
|
||||
y: next.y,
|
||||
w: next.w ?? moving.w,
|
||||
h: next.h ?? moving.h,
|
||||
};
|
||||
|
||||
moving.x = target.x;
|
||||
moving.y = target.y;
|
||||
moving.w = target.w;
|
||||
moving.h = target.h;
|
||||
|
||||
const collisions = collectCollisions(sourceWidgets, moving, target.x, target.y, target.w, target.h);
|
||||
|
||||
if (collisions.length === 0) {
|
||||
return { widgets: sourceWidgets, movedWidgets: [moving.id] };
|
||||
}
|
||||
|
||||
if (collisions.length === 1) {
|
||||
const other = collisions[0];
|
||||
if (!other.locked && !other.noMove && other.w === moving.w && other.h === moving.h) {
|
||||
const otherClone = sourceWidgets.find(widget => widget.id === other.id);
|
||||
if (otherClone) {
|
||||
// Use the original position of the moving widget for a clean swap
|
||||
// This prevents the "snapping together" issue where both widgets end up at the same position
|
||||
const swapTarget = original;
|
||||
const previousOtherPosition = { x: otherClone.x, y: otherClone.y };
|
||||
otherClone.x = swapTarget.x;
|
||||
otherClone.y = swapTarget.y;
|
||||
|
||||
const swapValid =
|
||||
collectCollisions(sourceWidgets, moving, moving.x, moving.y, moving.w, moving.h).length === 0 &&
|
||||
collectCollisions(sourceWidgets, otherClone, otherClone.x, otherClone.y, otherClone.w, otherClone.h).length === 0;
|
||||
|
||||
if (swapValid) {
|
||||
return { widgets: sourceWidgets, movedWidgets: [moving.id, otherClone.id], swappedWith: otherClone.id };
|
||||
}
|
||||
|
||||
otherClone.x = previousOtherPosition.x;
|
||||
otherClone.y = previousOtherPosition.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attempt displacement cascade
|
||||
const movedIds = new Set<string>([moving.id]);
|
||||
for (const offending of collisions) {
|
||||
if (offending.locked || offending.noMove) {
|
||||
return null;
|
||||
}
|
||||
const clone = sourceWidgets.find(widget => widget.id === offending.id);
|
||||
if (!clone) continue;
|
||||
const remaining = sourceWidgets.filter(widget => widget.id !== offending.id);
|
||||
const position = findAvailablePosition(remaining, clone.w, clone.h, columns);
|
||||
clone.x = position.x;
|
||||
clone.y = position.y;
|
||||
movedIds.add(clone.id);
|
||||
}
|
||||
|
||||
// verify no overlaps remain
|
||||
const verify = collectCollisions(sourceWidgets, moving, moving.x, moving.y, moving.w, moving.h);
|
||||
if (verify.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { widgets: sourceWidgets, movedWidgets: Array.from(movedIds) };
|
||||
};
|
||||
|
||||
export const compactLayout = (
|
||||
widgets: DashboardWidget[],
|
||||
direction: LayoutDirection = 'vertical',
|
||||
) => {
|
||||
const sorted = [...widgets].sort((a, b) => {
|
||||
if (direction === 'vertical') {
|
||||
if (a.y !== b.y) return a.y - b.y;
|
||||
return a.x - b.x;
|
||||
}
|
||||
|
||||
if (a.x !== b.x) return a.x - b.x;
|
||||
return a.y - b.y;
|
||||
});
|
||||
|
||||
for (const widget of sorted) {
|
||||
if (widget.locked || widget.noMove) continue;
|
||||
|
||||
if (direction === 'vertical') {
|
||||
while (widget.y > 0 && !checkCollision(widgets, widget, widget.x, widget.y - 1)) {
|
||||
widget.y -= 1;
|
||||
}
|
||||
} else {
|
||||
while (widget.x > 0 && !checkCollision(widgets, widget, widget.x - 1, widget.y)) {
|
||||
widget.x -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const applyLayout = (
|
||||
widgets: DashboardWidget[],
|
||||
layout: DashboardLayoutItem[],
|
||||
): DashboardWidget[] => {
|
||||
return widgets.map(widget => {
|
||||
const layoutItem = layout.find(item => item.id === widget.id);
|
||||
return layoutItem ? { ...widget, ...layoutItem } : widget;
|
||||
});
|
||||
};
|
||||
249
ts_web/elements/dees-dashboardgrid/styles.ts
Normal file
249
ts_web/elements/dees-dashboardgrid/styles.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const dashboardGridStyles = [
|
||||
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-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')};
|
||||
}
|
||||
|
||||
.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 {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
53
ts_web/elements/dees-dashboardgrid/types.ts
Normal file
53
ts_web/elements/dees-dashboardgrid/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { TemplateResult } from '@design.estate/dees-element';
|
||||
|
||||
export type CellHeightUnit = 'px' | 'em' | 'rem' | 'auto';
|
||||
|
||||
export interface DashboardMarginObject {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
}
|
||||
|
||||
export type DashboardMargin = number | DashboardMarginObject;
|
||||
|
||||
export interface DashboardResolvedMargins {
|
||||
horizontal: number;
|
||||
vertical: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export interface DashboardLayoutItem {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface DashboardWidget extends DashboardLayoutItem {
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
maxW?: number;
|
||||
maxH?: number;
|
||||
content: TemplateResult | string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
noMove?: boolean;
|
||||
noResize?: boolean;
|
||||
locked?: boolean;
|
||||
autoPosition?: boolean;
|
||||
}
|
||||
|
||||
export type LayoutDirection = 'vertical' | 'horizontal';
|
||||
|
||||
export interface GridCellMetrics {
|
||||
containerWidth: number;
|
||||
cellWidthPx: number;
|
||||
marginHorizontalPx: number;
|
||||
cellHeightPx: number;
|
||||
marginVerticalPx: number;
|
||||
}
|
||||
@@ -1,18 +1,199 @@
|
||||
import { html } from '@design.estate/dees-element';
|
||||
import { html, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html` <style>
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
.demoWrapper {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
background: none;
|
||||
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">
|
||||
import * as text from './hello'; const hiThere = 'nice'; const myFunction = async () => {
|
||||
console.log('nice one'); }
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
`
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import { cssGeistFontFamily, cssMonoFontFamily } from './00fonts.js';
|
||||
|
||||
import hlight from 'highlight.js';
|
||||
|
||||
@@ -48,27 +49,27 @@ export class DeesDataviewCodebox extends DeesElement {
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
font-family: ${cssGeistFontFamily};
|
||||
}
|
||||
.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;
|
||||
}
|
||||
@@ -81,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 {
|
||||
@@ -115,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 {
|
||||
@@ -128,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;
|
||||
}
|
||||
@@ -142,27 +150,43 @@ export class DeesDataviewCodebox extends DeesElement {
|
||||
.lineNumbers {
|
||||
line-height: 1.4em;
|
||||
font-weight: 200;
|
||||
font-family: 'Intel One Mono', 'Geist Mono', 'monospace';
|
||||
font-family: ${cssMonoFontFamily};
|
||||
}
|
||||
|
||||
.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
|
||||
@@ -197,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>
|
||||
|
||||
@@ -3,47 +3,162 @@ import * as tsclass from '@tsclass/tsclass';
|
||||
|
||||
export const demoFunc = () => html` <style>
|
||||
.demo {
|
||||
background: ${cssManager.bdTheme('#eeeeeb', '#000000')};
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
|
||||
display: block;
|
||||
content: '';
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
margin-bottom: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.demo-note {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
</style>
|
||||
<div class="demo">
|
||||
<div class="demo-note">
|
||||
Right-click on any detail row to copy the value, key, or key:value combination
|
||||
</div>
|
||||
<div class="demo-grid">
|
||||
<div class="demo-section">
|
||||
<div class="demo-title">Service Health Status</div>
|
||||
<dees-dataview-statusobject
|
||||
.statusObject=${{
|
||||
id: '1',
|
||||
name: 'Demo Item',
|
||||
combinedStatus: 'partly_ok',
|
||||
combinedStatusText: 'partly_ok',
|
||||
name: 'API Gateway Service',
|
||||
combinedStatus: 'ok',
|
||||
combinedStatusText: 'All systems operational',
|
||||
details: [
|
||||
{
|
||||
name: 'Detail 1',
|
||||
value: 'Value 1',
|
||||
name: 'Response Time',
|
||||
value: '45ms (avg)',
|
||||
status: 'ok',
|
||||
statusText: 'OK',
|
||||
statusText: 'Within normal range',
|
||||
},
|
||||
{
|
||||
name: 'Detail 2',
|
||||
value: 'Value 2',
|
||||
status: 'partly_ok',
|
||||
statusText: 'partly_ok',
|
||||
},
|
||||
{
|
||||
name: 'Detail 3',
|
||||
value: 'Value 3',
|
||||
status: 'not_ok',
|
||||
statusText: 'not_ok',
|
||||
},
|
||||
{
|
||||
name: 'Detail 4',
|
||||
value:
|
||||
'Value 4 jhdkfjhalskdfjhfdjskalsdkfjhfdjskalskdjfhjdkslaksjdhfjdkslaskdfjhfjdkslaskdjfhjdskalskdjhfdjskalskdjfhdjskl',
|
||||
name: 'Uptime',
|
||||
value: '99.99% (30 days)',
|
||||
status: 'ok',
|
||||
statusText: 'OK',
|
||||
statusText: 'Excellent uptime',
|
||||
},
|
||||
{
|
||||
name: 'Active Connections',
|
||||
value: '1,234 / 10,000',
|
||||
status: 'ok',
|
||||
statusText: 'Normal load',
|
||||
},
|
||||
{
|
||||
name: 'SSL Certificate',
|
||||
value: 'Valid until 2024-12-31',
|
||||
status: 'ok',
|
||||
statusText: 'Certificate valid',
|
||||
},
|
||||
],
|
||||
} as tsclass.code.IStatusObject}
|
||||
>
|
||||
</dees-dataview-statusobject>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<div class="demo-title">Database Cluster Status</div>
|
||||
<dees-dataview-statusobject
|
||||
.statusObject=${{
|
||||
id: '2',
|
||||
name: 'PostgreSQL Cluster',
|
||||
combinedStatus: 'partly_ok',
|
||||
combinedStatusText: 'Minor issues detected',
|
||||
details: [
|
||||
{
|
||||
name: 'Primary Node',
|
||||
value: 'db-primary-01 (healthy)',
|
||||
status: 'ok',
|
||||
statusText: 'Operating normally',
|
||||
},
|
||||
{
|
||||
name: 'Replica Lag',
|
||||
value: '2.5 seconds',
|
||||
status: 'partly_ok',
|
||||
statusText: 'Slightly elevated',
|
||||
},
|
||||
{
|
||||
name: 'Disk Usage',
|
||||
value: '78% (312GB / 400GB)',
|
||||
status: 'partly_ok',
|
||||
statusText: 'Approaching threshold',
|
||||
},
|
||||
{
|
||||
name: 'Connection Pool',
|
||||
value: '89 / 100 connections',
|
||||
status: 'ok',
|
||||
statusText: 'Within limits',
|
||||
},
|
||||
],
|
||||
} as tsclass.code.IStatusObject}
|
||||
>
|
||||
</dees-dataview-statusobject>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<div class="demo-title">Build Pipeline Status</div>
|
||||
<dees-dataview-statusobject
|
||||
.statusObject=${{
|
||||
id: '3',
|
||||
name: 'CI/CD Pipeline',
|
||||
combinedStatus: 'not_ok',
|
||||
combinedStatusText: 'Build failure',
|
||||
details: [
|
||||
{
|
||||
name: 'Last Build',
|
||||
value: 'Build #1234 - Failed',
|
||||
status: 'not_ok',
|
||||
statusText: 'Test failures',
|
||||
},
|
||||
{
|
||||
name: 'Failed Tests',
|
||||
value: '3 tests failed: auth.spec.ts, user.spec.ts, api.spec.ts',
|
||||
status: 'not_ok',
|
||||
statusText: 'Unit test failures',
|
||||
},
|
||||
{
|
||||
name: 'Code Coverage',
|
||||
value: '82.5% (target: 85%)',
|
||||
status: 'partly_ok',
|
||||
statusText: 'Below target',
|
||||
},
|
||||
{
|
||||
name: 'Build Duration',
|
||||
value: '12m 34s',
|
||||
status: 'ok',
|
||||
statusText: 'Normal duration',
|
||||
},
|
||||
],
|
||||
} as tsclass.code.IStatusObject}
|
||||
>
|
||||
</dees-dataview-statusobject>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
import { DeesContextmenu } from './dees-contextmenu.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -31,109 +32,128 @@ export class DeesDataviewStatusobject extends DeesElement {
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.mainbox {
|
||||
border-radius: 8px;
|
||||
background: ${cssManager.bdTheme('#fff', '#1b1b1b')};
|
||||
box-shadow: 0px 1px 3px #00000030;
|
||||
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%)')};
|
||||
box-shadow: 0 1px 3px 0 hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
min-height: 48px;
|
||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
||||
border-top: ${cssManager.bdTheme('none', '1px solid #ffffff10')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
|
||||
cursor: default;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: 40px auto 120px;
|
||||
grid-template-columns: 48px auto 100px;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
h1 {
|
||||
display: block;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
height: 48px;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
line-height: 48px;
|
||||
padding: 0px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
|
||||
}
|
||||
|
||||
.statusdot {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border-radius: 6px;
|
||||
background: grey;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
border-radius: 50%;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
margin: auto;
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(0 0% 63.9% / 0.2)', 'hsl(0 0% 45.1% / 0.2)')};
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.copyMain {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444')};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
border-radius: 3px;
|
||||
margin-right: 16px;
|
||||
color: ${cssManager.bdTheme('#333', '#ffffff80')};
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.copyMain:hover {
|
||||
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
|
||||
border: 1px solid ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
|
||||
color: #fff;
|
||||
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%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
|
||||
}
|
||||
|
||||
.copyMain:active {
|
||||
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
|
||||
border: 1px solid ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
|
||||
color: #fff;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 91%)', 'hsl(0 0% 14.9%)')};
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.statusdot.ok {
|
||||
background: green;
|
||||
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.2)', 'hsl(142.1 70.6% 45.3% / 0.2)')};
|
||||
}
|
||||
|
||||
.statusdot.not_ok {
|
||||
background: red;
|
||||
background: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.2)', 'hsl(0 72.2% 50.6% / 0.2)')};
|
||||
}
|
||||
|
||||
.statusdot.partly_ok {
|
||||
background: orange;
|
||||
background: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(25 95% 53% / 0.2)', 'hsl(25 95% 63% / 0.2)')};
|
||||
}
|
||||
|
||||
.detail {
|
||||
min-height: 60px;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 40px auto;
|
||||
border-top: 1px dotted ${cssManager.bdTheme('#e0e0e0', '#282828')};
|
||||
transition: all 0.2s;
|
||||
grid-template-columns: 48px auto;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(0 0% 14.9%)')};
|
||||
transition: background-color 0.15s ease;
|
||||
padding-right: 16px;
|
||||
cursor: context-menu;
|
||||
}
|
||||
|
||||
.detail:hover {
|
||||
background: #ffffff05;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
|
||||
}
|
||||
|
||||
.detail:active {
|
||||
background: #ffffff10;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 9%)')};
|
||||
}
|
||||
|
||||
.detail .detailsText {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
padding-right: 8px;
|
||||
padding: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail .detailsText .label {
|
||||
font-size: 12px;
|
||||
color: #ffffff80
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}
|
||||
margin-bottom: 2px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.detail .detailsText .value {
|
||||
font-size: 14px;
|
||||
font-family: 'Intel One Mono', 'Geist Mono';
|
||||
font-family: 'Intel One Mono', 'Geist Mono', monospace;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
line-height: 1.5;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -143,12 +163,40 @@ export class DeesDataviewStatusobject extends DeesElement {
|
||||
<div class="mainbox">
|
||||
<div class="heading">
|
||||
<div class="statusdot ${this.statusObject?.combinedStatus}"></div>
|
||||
<h1>${this.statusObject?.name || 'no status object assigned'}</h1>
|
||||
<div class="copyMain">Copy as JSON</div>
|
||||
<h1>${this.statusObject?.name || 'No status object assigned'}</h1>
|
||||
<div class="copyMain" @click=${this.handleCopyAsJson}>Copy JSON</div>
|
||||
</div>
|
||||
${this.statusObject?.details?.map((detailArg) => {
|
||||
return html`
|
||||
<div class="detail">
|
||||
<div
|
||||
class="detail"
|
||||
@contextmenu=${(event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'Copy Value',
|
||||
iconName: 'lucide:copy',
|
||||
action: async () => {
|
||||
await this.copyToClipboard(detailArg.value, 'Value');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Key',
|
||||
iconName: 'lucide:key',
|
||||
action: async () => {
|
||||
await this.copyToClipboard(detailArg.name, 'Key');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Key:Value',
|
||||
iconName: 'lucide:copy-plus',
|
||||
action: async () => {
|
||||
await this.copyToClipboard(`${detailArg.name}: ${detailArg.value}`, 'Key:Value');
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<div class="statusdot ${detailArg.status}"></div>
|
||||
<div class="detailsText">
|
||||
<div class="label">${detailArg.name}</div>
|
||||
@@ -162,4 +210,42 @@ export class DeesDataviewStatusobject extends DeesElement {
|
||||
}
|
||||
|
||||
async firstUpdated() {}
|
||||
|
||||
private async copyToClipboard(text: string, type: string = 'Text') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
console.log(`${type} copied to clipboard`);
|
||||
// You could add visual feedback here if needed
|
||||
} catch (err) {
|
||||
console.error(`Failed to copy ${type}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCopyAsJson() {
|
||||
if (!this.statusObject) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(this.statusObject, null, 2));
|
||||
|
||||
// Show feedback
|
||||
const button = this.shadowRoot.querySelector('.copyMain') as HTMLElement;
|
||||
const originalText = button.textContent;
|
||||
button.textContent = 'Copied!';
|
||||
|
||||
// Apply success styles based on theme
|
||||
const isDark = !this.goBright;
|
||||
button.style.background = isDark ? 'hsl(142.1 70.6% 45.3% / 0.1)' : 'hsl(142.1 76.2% 36.3% / 0.1)';
|
||||
button.style.borderColor = isDark ? 'hsl(142.1 70.6% 45.3%)' : 'hsl(142.1 76.2% 36.3%)';
|
||||
button.style.color = isDark ? 'hsl(142.1 70.6% 45.3%)' : 'hsl(142.1 76.2% 36.3%)';
|
||||
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.style.background = '';
|
||||
button.style.borderColor = '';
|
||||
button.style.color = '';
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { MONACO_VERSION } from './version.js';
|
||||
|
||||
import type * as monaco from 'monaco-editor';
|
||||
|
||||
@@ -80,10 +81,11 @@ export class DeesEditor extends DeesElement {
|
||||
): Promise<void> {
|
||||
super.firstUpdated(_changedProperties);
|
||||
const container = this.shadowRoot.getElementById('container');
|
||||
const monacoCdnBase = `https://cdn.jsdelivr.net/npm/monaco-editor@${MONACO_VERSION}`;
|
||||
|
||||
if (!DeesEditor.monacoDeferred) {
|
||||
DeesEditor.monacoDeferred = domtools.plugins.smartpromise.defer();
|
||||
const scriptUrl = `https://cdn.jsdelivr.net/npm/monaco-editor/min/vs/loader.js`;
|
||||
const scriptUrl = `${monacoCdnBase}/min/vs/loader.js`;
|
||||
const script = document.createElement('script');
|
||||
script.src = scriptUrl;
|
||||
script.onload = () => {
|
||||
@@ -94,7 +96,7 @@ export class DeesEditor extends DeesElement {
|
||||
await DeesEditor.monacoDeferred.promise;
|
||||
|
||||
(window as any).require.config({
|
||||
paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor/min/vs' },
|
||||
paths: { vs: `${monacoCdnBase}/min/vs` },
|
||||
});
|
||||
(window as any).require(['vs/editor/editor.main'], async () => {
|
||||
const editor = ((window as any).monaco.editor as typeof monaco.editor).create(container, {
|
||||
@@ -109,7 +111,7 @@ export class DeesEditor extends DeesElement {
|
||||
this.editorDeferred.resolve(editor);
|
||||
});
|
||||
const css = await (
|
||||
await fetch('https://cdn.jsdelivr.net/npm/monaco-editor/min/vs/editor/editor.main.css')
|
||||
await fetch(`${monacoCdnBase}/min/vs/editor/editor.main.css`)
|
||||
).text();
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = css;
|
||||
2
ts_web/elements/dees-editor/index.ts
Normal file
2
ts_web/elements/dees-editor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dees-editor.js';
|
||||
export * from './version.js';
|
||||
2
ts_web/elements/dees-editor/version.ts
Normal file
2
ts_web/elements/dees-editor/version.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Auto-generated by scripts/update-monaco-version.cjs
|
||||
export const MONACO_VERSION = '0.52.2';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { DeesForm } from './dees-form.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
@@ -22,21 +21,73 @@ export const demoFunc = () => html`
|
||||
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-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...');
|
||||
<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);
|
||||
form.setStatus('success', 'Form submitted successfully!');
|
||||
|
||||
// Show success
|
||||
form.setStatus('success', 'Registration completed successfully!');
|
||||
|
||||
// Reset form after delay
|
||||
await domtools.plugins.smartdelay.delayFor(2000);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
outputDiv.innerHTML = '<em>Form has been reset</em>';
|
||||
});
|
||||
|
||||
// Track individual field changes
|
||||
const inputs = form.querySelectorAll('dees-input-text, dees-input-dropdown, dees-input-checkbox');
|
||||
inputs.forEach((input) => {
|
||||
input.addEventListener('changeSubject', () => {
|
||||
console.log('Field changed:', input.getAttribute('key'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .heading="Complete Form Example" .description="A comprehensive form with various input types, validation, and form submission handling">
|
||||
<dees-form>
|
||||
<dees-input-text
|
||||
.required=${true}
|
||||
key="firstName"
|
||||
@@ -92,13 +143,47 @@ export const demoFunc = () => html`
|
||||
|
||||
<dees-form-submit>Create Account</dees-form-submit>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
|
||||
<div class="form-output">
|
||||
<em>Submit the form to see the collected data...</em>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const form = elementArg.querySelector('dees-form') as DeesForm;
|
||||
|
||||
if (form) {
|
||||
// Track horizontal layout behavior
|
||||
console.log('Horizontal form layout active');
|
||||
|
||||
// Monitor filter changes
|
||||
form.addEventListener('formData', (event: CustomEvent) => {
|
||||
const filters = event.detail.data;
|
||||
console.log('Filter applied:', filters);
|
||||
|
||||
// Simulate search
|
||||
const resultsCount = Math.floor(Math.random() * 100) + 1;
|
||||
console.log(`Found ${resultsCount} results with filters:`, filters);
|
||||
});
|
||||
|
||||
// Setup real-time filter updates
|
||||
const inputs = form.querySelectorAll('[key]');
|
||||
inputs.forEach((input) => {
|
||||
input.addEventListener('changeSubject', async () => {
|
||||
// Get current form data
|
||||
const formData = await form.collectFormData();
|
||||
console.log('Live filter update:', formData);
|
||||
});
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .heading="Horizontal Form Layout" .description="Compact form with inputs arranged horizontally - perfect for filters and quick forms">
|
||||
<dees-form horizontal-layout>
|
||||
<dees-input-text
|
||||
key="search"
|
||||
label="Search"
|
||||
placeholder="Enter keywords..."
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-dropdown
|
||||
@@ -132,16 +217,55 @@ export const demoFunc = () => html`
|
||||
></dees-input-checkbox>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-panel .heading="Advanced Form Features" .description="Form with specialized input types and complex validation">
|
||||
<dees-form
|
||||
@formData=${async (eventArg) => {
|
||||
const form: DeesForm = eventArg.currentTarget;
|
||||
<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('Form data:', data);
|
||||
form.setStatus('success', 'Data logged to console!');
|
||||
}}
|
||||
>
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
@@ -9,22 +9,24 @@ import {
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
|
||||
import { DeesInputCheckbox } from './dees-input-checkbox.js';
|
||||
import { DeesInputDatepicker } from './dees-input-datepicker/index.js';
|
||||
import { DeesInputText } from './dees-input-text.js';
|
||||
import { DeesInputQuantitySelector } from './dees-input-quantityselector.js';
|
||||
import { DeesInputRadiogroup } from './dees-input-radiogroup.js';
|
||||
import { DeesInputDropdown } from './dees-input-dropdown.js';
|
||||
import { DeesInputFileupload } from './dees-input-fileupload.js';
|
||||
import { DeesInputFileupload } from './dees-input-fileupload/index.js';
|
||||
import { DeesInputIban } from './dees-input-iban.js';
|
||||
import { DeesInputMultitoggle } from './dees-input-multitoggle.js';
|
||||
import { DeesInputPhone } from './dees-input-phone.js';
|
||||
import { DeesInputTypelist } from './dees-input-typelist.js';
|
||||
import { DeesFormSubmit } from './dees-form-submit.js';
|
||||
import { DeesTable } from './dees-table.js';
|
||||
import { DeesTable } from './dees-table/index.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
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { demoFunc } from './dees-heading.demo.js';
|
||||
import { cssCalSansFontFamily } from './00fonts.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -39,7 +40,7 @@ export class DeesHeading extends DeesElement {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
||||
}
|
||||
h1 { font-size: 32px; font-family: 'Cal Sans'; letter-spacing: 0.025em;}
|
||||
h1 { font-size: 32px; font-family: ${cssCalSansFontFamily}; letter-spacing: 0.025em;}
|
||||
h2 { font-size: 28px; }
|
||||
h3 { font-size: 24px; }
|
||||
h4 { font-size: 20px; }
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import './dees-panel.js';
|
||||
import type { DeesInputCheckbox } from './dees-input-checkbox.js';
|
||||
import './dees-button.js';
|
||||
|
||||
@@ -41,62 +42,49 @@ export const demoFunc = () => html`
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.demo-section {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-section h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: #0069f2;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.demo-section p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.demo-section p {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
.horizontal-checkboxes {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.interactive-section {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-list {
|
||||
background: #0a0a0a;
|
||||
.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;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
@@ -104,14 +92,26 @@ export const demoFunc = () => html`
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 11.8%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="demo-section">
|
||||
<h3>Basic Checkboxes</h3>
|
||||
<p>Standard checkbox inputs for boolean selections</p>
|
||||
|
||||
<dees-panel .title=${'Basic Checkboxes'} .subtitle=${'Simple checkbox examples with various labels'}>
|
||||
<div class="checkbox-group">
|
||||
<dees-input-checkbox
|
||||
.label=${'I agree to the Terms and Conditions'}
|
||||
.value=${true}
|
||||
@@ -126,48 +126,78 @@ export const demoFunc = () => html`
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Enable notifications'}
|
||||
.required=${true}
|
||||
.value=${false}
|
||||
.description=${'Receive email updates about your account'}
|
||||
.key=${'notifications'}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>Horizontal Layout</h3>
|
||||
<p>Checkboxes arranged horizontally for compact forms</p>
|
||||
<dees-panel .title=${'Checkbox States'} .subtitle=${'Different checkbox states and configurations'}>
|
||||
<div class="checkbox-group">
|
||||
<dees-input-checkbox
|
||||
.label=${'Default state'}
|
||||
.value=${false}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<div class="horizontal-group">
|
||||
<dees-input-checkbox
|
||||
.label=${'Checked state'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Disabled unchecked'}
|
||||
.value=${false}
|
||||
.disabled=${true}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Disabled checked'}
|
||||
.value=${true}
|
||||
.disabled=${true}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Required checkbox'}
|
||||
.required=${true}
|
||||
.key=${'required'}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Horizontal Layout'} .subtitle=${'Checkboxes arranged horizontally for compact forms'}>
|
||||
<div class="horizontal-checkboxes">
|
||||
<dees-input-checkbox
|
||||
.label=${'Option A'}
|
||||
.value=${false}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'optionA'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Option B'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.value=${true}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'optionB'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Option C'}
|
||||
.value=${false}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'optionC'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Option D'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.value=${true}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'optionD'}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>Feature Selection Example</h3>
|
||||
<p>Common use case for feature toggles with batch operations</p>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Feature Selection Example'} .subtitle=${'Common use case for feature toggles with batch operations'}>
|
||||
<div class="button-group">
|
||||
<dees-button id="select-all-btn" type="secondary">Select All</dees-button>
|
||||
<dees-button id="clear-all-btn" type="secondary">Clear All</dees-button>
|
||||
@@ -206,62 +236,72 @@ export const demoFunc = () => html`
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>States</h3>
|
||||
<p>Different checkbox states and configurations</p>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Disabled Unchecked'}
|
||||
.disabled=${true}
|
||||
.key=${'disabled1'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Disabled Checked'}
|
||||
.disabled=${true}
|
||||
.value=${true}
|
||||
.key=${'disabled2'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Required Checkbox'}
|
||||
.required=${true}
|
||||
.key=${'required'}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>Real-world Examples</h3>
|
||||
<p>Common checkbox patterns in applications</p>
|
||||
<dees-panel .title=${'Privacy Settings Example'} .subtitle=${'Checkboxes in a typical form context'}>
|
||||
<div class="form-section">
|
||||
<h4 class="section-title">Privacy Preferences</h4>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<dees-input-checkbox
|
||||
.label=${'Remember me on this device'}
|
||||
.label=${'Share analytics data'}
|
||||
.value=${true}
|
||||
.key=${'rememberMe'}
|
||||
.description=${'Help us improve by sharing anonymous usage data'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Make my profile public'}
|
||||
.value=${false}
|
||||
.key=${'publicProfile'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Allow others to find me by email'}
|
||||
.value=${false}
|
||||
.key=${'findByEmail'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Send me product updates and announcements'}
|
||||
.label=${'Personalized recommendations'}
|
||||
.value=${true}
|
||||
.key=${'productUpdates'}
|
||||
.description=${'Get suggestions based on your activity'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Marketing communications'}
|
||||
.value=${false}
|
||||
.description=${'Receive promotional emails and special offers'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Third-party integrations'}
|
||||
.value=${false}
|
||||
.description=${'Allow approved partners to access your data'}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Interactive Example'} .subtitle=${'Click checkboxes to see value changes'}>
|
||||
<div class="checkbox-group">
|
||||
<dees-input-checkbox
|
||||
.label=${'Feature toggle'}
|
||||
.value=${false}
|
||||
@changeSubject=${(event: CustomEvent) => {
|
||||
const output = document.querySelector('#checkbox-output');
|
||||
if (output && event.detail) {
|
||||
const isChecked = event.detail.getValue();
|
||||
output.textContent = `Feature is ${isChecked ? 'enabled' : 'disabled'}`;
|
||||
}
|
||||
}}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Debug mode'}
|
||||
.value=${false}
|
||||
@changeSubject=${(event: CustomEvent) => {
|
||||
const output = document.querySelector('#debug-output');
|
||||
if (output && event.detail) {
|
||||
const isChecked = event.detail.getValue();
|
||||
output.textContent = `Debug mode: ${isChecked ? 'ON' : 'OFF'}`;
|
||||
}
|
||||
}}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="interactive-section">
|
||||
<div id="checkbox-output" class="output-text">Feature is disabled</div>
|
||||
<div id="debug-output" class="output-text" style="margin-top: 8px;">Debug mode: OFF</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from './dees-input-base.js';
|
||||
import { demoFunc } from './dees-input-checkbox.demo.js';
|
||||
import { cssGeistFontFamily } from './00fonts.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -27,6 +28,9 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
|
||||
})
|
||||
public value: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public indeterminate: boolean = false;
|
||||
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -44,120 +48,106 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
|
||||
:host {
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
:host(:hover) {
|
||||
filter: ${cssManager.bdTheme('brightness(0.95)', 'brightness(1.1)')};
|
||||
font-family: ${cssGeistFontFamily};
|
||||
}
|
||||
|
||||
.maincontainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0px;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.maincontainer:hover {
|
||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
||||
}
|
||||
|
||||
.maincontainer:hover .checkbox {
|
||||
border-color: ${cssManager.bdTheme('#999', '#888')};
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-bottom: 1px solid #e4002b;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
transition: all 0.1s;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid ${cssManager.bdTheme('#CCC', '#999')};
|
||||
border-radius: 2px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
display: inline-block;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#222')};
|
||||
position: relative;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
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% 3.9%)')};
|
||||
transition: all 0.15s ease;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.maincontainer:hover .checkbox {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
.checkbox.selected {
|
||||
background: #0050b9;
|
||||
border: 1px solid #0050b9;
|
||||
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
}
|
||||
|
||||
.checkbox.disabled {
|
||||
background: none;
|
||||
border: 1px dashed ${cssManager.bdTheme('#666666', '#666666')};
|
||||
.checkbox:focus-visible {
|
||||
outline: none;
|
||||
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)')};
|
||||
}
|
||||
|
||||
/* Checkmark using Lucide icon style */
|
||||
.checkbox .checkmark {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
-ms-transform: rotate(45deg); /* IE 9 */
|
||||
-webkit-transform: rotate(45deg); /* Chrome, Safari, Opera */
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.checkbox .checkmark_stem {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 9px;
|
||||
background-color: #fff;
|
||||
left: 11px;
|
||||
top: 6px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.checkbox .checkmark_kick {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background-color: #fff;
|
||||
left: 8px;
|
||||
top: 12px;
|
||||
.checkbox.selected .checkmark {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.checkbox.disabled .checkmark_stem, .checkbox.disabled .checkmark_kick {
|
||||
background-color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
}
|
||||
|
||||
img {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: 14px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.maincontainer:hover .checkbox-label {
|
||||
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
|
||||
.checkbox .checkmark svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: white;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
.maincontainer.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.maincontainer.disabled:hover {
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
.checkbox.disabled {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.maincontainer.disabled:hover .checkbox {
|
||||
border-color: ${cssManager.bdTheme('#ccc', '#333')};
|
||||
/* Label */
|
||||
.label-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
transition: color 0.15s ease;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.maincontainer:hover .checkbox-label {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.maincontainer.disabled:hover .checkbox-label {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.description-text {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
padding-left: 36px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
line-height: 1.5;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -166,21 +156,34 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<div class="maincontainer ${this.disabled ? 'disabled' : ''}" @click="${this.toggleSelected}">
|
||||
<div class="checkbox ${this.value ? 'selected' : ''} ${this.disabled ? 'disabled' : ''}" tabindex="0">
|
||||
<div
|
||||
class="checkbox ${this.value ? 'selected' : ''} ${this.disabled ? 'disabled' : ''}"
|
||||
tabindex="${this.disabled ? '-1' : '0'}"
|
||||
@keydown="${this.handleKeydown}"
|
||||
>
|
||||
${this.value
|
||||
? html`
|
||||
<span class="checkmark">
|
||||
<div class="checkmark_stem"></div>
|
||||
<div class="checkmark_kick"></div>
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
`
|
||||
: 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>` : ''}
|
||||
${this.description ? html`<div class="description-text">${this.description}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${this.description ? html`
|
||||
<div class="description-text">${this.description}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -213,4 +216,11 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
|
||||
(checkboxDiv as any).focus();
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.toggleSelected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
624
ts_web/elements/dees-input-datepicker/component.ts
Normal file
624
ts_web/elements/dees-input-datepicker/component.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base.js';
|
||||
import { demoFunc } from './demo.js';
|
||||
import { datepickerStyles } from './styles.js';
|
||||
import { renderDatepicker } from './template.js';
|
||||
import type { IDateEvent } from './types.js';
|
||||
import '../dees-icon.js';
|
||||
import '../dees-label.js';
|
||||
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-datepicker': DeesInputDatepicker;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-datepicker')
|
||||
export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({ type: String })
|
||||
public value: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public enableTime: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
public timeFormat: '24h' | '12h' = '24h';
|
||||
|
||||
@property({ type: Number })
|
||||
public minuteIncrement: number = 1;
|
||||
|
||||
@property({ type: String })
|
||||
public dateFormat: string = 'YYYY-MM-DD';
|
||||
|
||||
@property({ type: String })
|
||||
public minDate: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public maxDate: string = '';
|
||||
|
||||
@property({ type: Array })
|
||||
public disabledDates: string[] = [];
|
||||
|
||||
@property({ type: Number })
|
||||
public weekStartsOn: 0 | 1 = 1; // Default to Monday
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder: string = 'YYYY-MM-DD';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public enableTimezone: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
public timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
@property({ type: Array })
|
||||
public events: IDateEvent[] = [];
|
||||
|
||||
@state()
|
||||
public isOpened: boolean = false;
|
||||
|
||||
@state()
|
||||
public opensToTop: boolean = false;
|
||||
|
||||
@state()
|
||||
public selectedDate: Date | null = null;
|
||||
|
||||
@state()
|
||||
public viewDate: Date = new Date();
|
||||
|
||||
@state()
|
||||
public selectedHour: number = 0;
|
||||
|
||||
@state()
|
||||
public selectedMinute: number = 0;
|
||||
|
||||
public static styles = datepickerStyles;
|
||||
|
||||
|
||||
|
||||
public getTimezones(): { value: string; label: string }[] {
|
||||
// Common timezones with their display names
|
||||
return [
|
||||
{ value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
|
||||
{ value: 'America/New_York', label: 'Eastern Time (US & Canada)' },
|
||||
{ value: 'America/Chicago', label: 'Central Time (US & Canada)' },
|
||||
{ value: 'America/Denver', label: 'Mountain Time (US & Canada)' },
|
||||
{ value: 'America/Los_Angeles', label: 'Pacific Time (US & Canada)' },
|
||||
{ value: 'America/Phoenix', label: 'Arizona' },
|
||||
{ value: 'America/Anchorage', label: 'Alaska' },
|
||||
{ value: 'Pacific/Honolulu', label: 'Hawaii' },
|
||||
{ value: 'Europe/London', label: 'London' },
|
||||
{ value: 'Europe/Paris', label: 'Paris' },
|
||||
{ value: 'Europe/Berlin', label: 'Berlin' },
|
||||
{ value: 'Europe/Moscow', label: 'Moscow' },
|
||||
{ value: 'Asia/Dubai', label: 'Dubai' },
|
||||
{ value: 'Asia/Kolkata', label: 'India Standard Time' },
|
||||
{ value: 'Asia/Shanghai', label: 'China Standard Time' },
|
||||
{ value: 'Asia/Tokyo', label: 'Tokyo' },
|
||||
{ value: 'Australia/Sydney', label: 'Sydney' },
|
||||
{ value: 'Pacific/Auckland', label: 'Auckland' },
|
||||
];
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return renderDatepicker(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
// Initialize with empty value if not set
|
||||
if (!this.value) {
|
||||
this.value = '';
|
||||
}
|
||||
|
||||
// Initialize view date and selected time
|
||||
if (this.value) {
|
||||
try {
|
||||
const date = new Date(this.value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
this.selectedDate = date;
|
||||
this.viewDate = new Date(date);
|
||||
this.selectedHour = date.getHours();
|
||||
this.selectedMinute = date.getMinutes();
|
||||
}
|
||||
} catch {
|
||||
// Invalid date
|
||||
}
|
||||
} else {
|
||||
const now = new Date();
|
||||
this.viewDate = new Date(now);
|
||||
this.selectedHour = now.getHours();
|
||||
this.selectedMinute = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public formatDate(isoString: string): string {
|
||||
if (!isoString) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
let formatted = this.dateFormat;
|
||||
|
||||
// Basic date formatting
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear().toString();
|
||||
|
||||
// Replace in correct order to avoid conflicts
|
||||
formatted = formatted.replace('YYYY', year);
|
||||
formatted = formatted.replace('YY', year.slice(-2));
|
||||
formatted = formatted.replace('MM', month);
|
||||
formatted = formatted.replace('DD', day);
|
||||
|
||||
// Time formatting if enabled
|
||||
if (this.enableTime) {
|
||||
const hours24 = date.getHours();
|
||||
const hours12 = hours24 === 0 ? 12 : hours24 > 12 ? hours24 - 12 : hours24;
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const ampm = hours24 >= 12 ? 'PM' : 'AM';
|
||||
|
||||
if (this.timeFormat === '12h') {
|
||||
formatted += ` ${hours12}:${minutes} ${ampm}`;
|
||||
} else {
|
||||
formatted += ` ${hours24.toString().padStart(2, '0')}:${minutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Timezone formatting if enabled
|
||||
if (this.enableTimezone) {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZoneName: 'short',
|
||||
timeZone: this.timezone
|
||||
});
|
||||
const parts = formatter.formatToParts(date);
|
||||
const tzPart = parts.find(part => part.type === 'timeZoneName');
|
||||
if (tzPart) {
|
||||
formatted += ` ${tzPart.value}`;
|
||||
}
|
||||
}
|
||||
|
||||
return formatted;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private handleClickOutside = (event: MouseEvent) => {
|
||||
const path = event.composedPath();
|
||||
if (!path.includes(this)) {
|
||||
this.isOpened = false;
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
};
|
||||
|
||||
public async toggleCalendar(): Promise<void> {
|
||||
if (this.disabled) return;
|
||||
|
||||
this.isOpened = !this.isOpened;
|
||||
|
||||
if (this.isOpened) {
|
||||
// Check available space and set position
|
||||
const inputContainer = this.shadowRoot!.querySelector('.input-container') as HTMLElement;
|
||||
const rect = inputContainer.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
|
||||
// Determine if we should open upwards (approximate height of 400px)
|
||||
this.opensToTop = spaceBelow < 400 && spaceAbove > spaceBelow;
|
||||
|
||||
// Add click outside listener
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
}, 0);
|
||||
} else {
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
}
|
||||
|
||||
public getDaysInMonth(): Date[] {
|
||||
const year = this.viewDate.getFullYear();
|
||||
const month = this.viewDate.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const days: Date[] = [];
|
||||
|
||||
// Adjust for week start
|
||||
const startOffset = this.weekStartsOn === 1
|
||||
? (firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1)
|
||||
: firstDay.getDay();
|
||||
|
||||
// Add days from previous month
|
||||
for (let i = startOffset; i > 0; i--) {
|
||||
days.push(new Date(year, month, 1 - i));
|
||||
}
|
||||
|
||||
// Add days of current month
|
||||
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||
days.push(new Date(year, month, i));
|
||||
}
|
||||
|
||||
// Add days from next month to complete the grid (6 rows)
|
||||
const remainingDays = 42 - days.length;
|
||||
for (let i = 1; i <= remainingDays; i++) {
|
||||
days.push(new Date(year, month + 1, i));
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
public isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
}
|
||||
|
||||
public isSelected(date: Date): boolean {
|
||||
if (!this.selectedDate) return false;
|
||||
return date.getDate() === this.selectedDate.getDate() &&
|
||||
date.getMonth() === this.selectedDate.getMonth() &&
|
||||
date.getFullYear() === this.selectedDate.getFullYear();
|
||||
}
|
||||
|
||||
public isDisabled(date: Date): boolean {
|
||||
// Check min date
|
||||
if (this.minDate) {
|
||||
const min = new Date(this.minDate);
|
||||
if (date < min) return true;
|
||||
}
|
||||
|
||||
// Check max date
|
||||
if (this.maxDate) {
|
||||
const max = new Date(this.maxDate);
|
||||
if (date > max) return true;
|
||||
}
|
||||
|
||||
// Check disabled dates
|
||||
if (this.disabledDates && this.disabledDates.length > 0) {
|
||||
return this.disabledDates.some(disabledStr => {
|
||||
try {
|
||||
const disabled = new Date(disabledStr);
|
||||
return date.getDate() === disabled.getDate() &&
|
||||
date.getMonth() === disabled.getMonth() &&
|
||||
date.getFullYear() === disabled.getFullYear();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public getEventsForDate(date: Date): IDateEvent[] {
|
||||
if (!this.events || this.events.length === 0) return [];
|
||||
|
||||
const dateStr = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
|
||||
return this.events.filter(event => event.date === dateStr);
|
||||
}
|
||||
|
||||
public selectDate(date: Date): void {
|
||||
this.selectedDate = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
this.selectedHour,
|
||||
this.selectedMinute
|
||||
);
|
||||
|
||||
this.value = this.formatValueWithTimezone(this.selectedDate);
|
||||
this.changeSubject.next(this);
|
||||
|
||||
if (!this.enableTime) {
|
||||
this.isOpened = false;
|
||||
}
|
||||
}
|
||||
|
||||
public selectToday(): void {
|
||||
const today = new Date();
|
||||
this.selectedDate = today;
|
||||
this.viewDate = new Date(today);
|
||||
this.selectedHour = today.getHours();
|
||||
this.selectedMinute = today.getMinutes();
|
||||
|
||||
this.value = this.formatValueWithTimezone(this.selectedDate);
|
||||
this.changeSubject.next(this);
|
||||
|
||||
if (!this.enableTime) {
|
||||
this.isOpened = false;
|
||||
}
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.value = '';
|
||||
this.selectedDate = null;
|
||||
this.changeSubject.next(this);
|
||||
this.isOpened = false;
|
||||
}
|
||||
|
||||
public previousMonth(): void {
|
||||
this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() - 1, 1);
|
||||
}
|
||||
|
||||
public nextMonth(): void {
|
||||
this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() + 1, 1);
|
||||
}
|
||||
|
||||
public handleHourInput(e: InputEvent): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
let value = parseInt(input.value) || 0;
|
||||
|
||||
if (this.timeFormat === '12h') {
|
||||
value = Math.max(1, Math.min(12, value));
|
||||
// Convert to 24h format
|
||||
if (this.selectedHour >= 12 && value !== 12) {
|
||||
this.selectedHour = value + 12;
|
||||
} else if (this.selectedHour < 12 && value === 12) {
|
||||
this.selectedHour = 0;
|
||||
} else {
|
||||
this.selectedHour = value;
|
||||
}
|
||||
} else {
|
||||
this.selectedHour = Math.max(0, Math.min(23, value));
|
||||
}
|
||||
|
||||
this.updateSelectedDateTime();
|
||||
}
|
||||
|
||||
public handleMinuteInput(e: InputEvent): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
let value = parseInt(input.value) || 0;
|
||||
value = Math.max(0, Math.min(59, value));
|
||||
|
||||
if (this.minuteIncrement && this.minuteIncrement > 1) {
|
||||
value = Math.round(value / this.minuteIncrement) * this.minuteIncrement;
|
||||
}
|
||||
|
||||
this.selectedMinute = value;
|
||||
this.updateSelectedDateTime();
|
||||
}
|
||||
|
||||
public setAMPM(period: 'am' | 'pm'): void {
|
||||
if (period === 'am' && this.selectedHour >= 12) {
|
||||
this.selectedHour -= 12;
|
||||
} else if (period === 'pm' && this.selectedHour < 12) {
|
||||
this.selectedHour += 12;
|
||||
}
|
||||
this.updateSelectedDateTime();
|
||||
}
|
||||
|
||||
private updateSelectedDateTime(): void {
|
||||
if (this.selectedDate) {
|
||||
this.selectedDate = new Date(
|
||||
this.selectedDate.getFullYear(),
|
||||
this.selectedDate.getMonth(),
|
||||
this.selectedDate.getDate(),
|
||||
this.selectedHour,
|
||||
this.selectedMinute
|
||||
);
|
||||
this.value = this.formatValueWithTimezone(this.selectedDate);
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
public handleTimezoneChange(e: Event): void {
|
||||
const select = e.target as HTMLSelectElement;
|
||||
this.timezone = select.value;
|
||||
this.updateSelectedDateTime();
|
||||
}
|
||||
|
||||
private formatValueWithTimezone(date: Date): string {
|
||||
if (!this.enableTimezone) {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
// Format the date with timezone offset
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: this.timezone,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(date);
|
||||
const dateParts: any = {};
|
||||
parts.forEach(part => {
|
||||
dateParts[part.type] = part.value;
|
||||
});
|
||||
|
||||
// Create ISO-like format with timezone
|
||||
const isoString = `${dateParts.year}-${dateParts.month}-${dateParts.day}T${dateParts.hour}:${dateParts.minute}:${dateParts.second}`;
|
||||
|
||||
// Get timezone offset
|
||||
const tzOffset = this.getTimezoneOffset(date, this.timezone);
|
||||
return `${isoString}${tzOffset}`;
|
||||
}
|
||||
|
||||
private getTimezoneOffset(date: Date, timezone: string): string {
|
||||
// Create a date in the target timezone
|
||||
const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
|
||||
const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));
|
||||
|
||||
const offsetMinutes = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60);
|
||||
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
|
||||
const minutes = Math.abs(offsetMinutes) % 60;
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-';
|
||||
|
||||
return `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
public handleKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.toggleCalendar();
|
||||
} else if (e.key === 'Escape' && this.isOpened) {
|
||||
e.preventDefault();
|
||||
this.isOpened = false;
|
||||
}
|
||||
}
|
||||
|
||||
public clearValue(e: Event): void {
|
||||
e.stopPropagation();
|
||||
this.value = '';
|
||||
this.selectedDate = null;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public handleManualInput(e: InputEvent): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const inputValue = input.value.trim();
|
||||
|
||||
if (!inputValue) {
|
||||
// Clear the value if input is empty
|
||||
this.value = '';
|
||||
this.selectedDate = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedDate = this.parseManualDate(inputValue);
|
||||
if (parsedDate && !isNaN(parsedDate.getTime())) {
|
||||
// Update internal state without triggering re-render of input
|
||||
this.value = parsedDate.toISOString();
|
||||
this.selectedDate = parsedDate;
|
||||
this.viewDate = new Date(parsedDate);
|
||||
this.selectedHour = parsedDate.getHours();
|
||||
this.selectedMinute = parsedDate.getMinutes();
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
public handleInputBlur(e: FocusEvent): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const inputValue = input.value.trim();
|
||||
|
||||
if (!inputValue) {
|
||||
this.value = '';
|
||||
this.selectedDate = null;
|
||||
this.changeSubject.next(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedDate = this.parseManualDate(inputValue);
|
||||
if (parsedDate && !isNaN(parsedDate.getTime())) {
|
||||
this.value = parsedDate.toISOString();
|
||||
this.selectedDate = parsedDate;
|
||||
this.viewDate = new Date(parsedDate);
|
||||
this.selectedHour = parsedDate.getHours();
|
||||
this.selectedMinute = parsedDate.getMinutes();
|
||||
this.changeSubject.next(this);
|
||||
// Update the input with formatted date
|
||||
input.value = this.formatDate(this.value);
|
||||
} else {
|
||||
// Revert to previous valid value on blur if parsing failed
|
||||
input.value = this.formatDate(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
private parseManualDate(input: string): Date | null {
|
||||
if (!input) return null;
|
||||
|
||||
// Split date and time parts if present
|
||||
const parts = input.split(' ');
|
||||
let datePart = parts[0];
|
||||
let timePart = parts[1] || '';
|
||||
|
||||
let parsedDate: Date | null = null;
|
||||
|
||||
// Try different date formats
|
||||
// Format 1: YYYY-MM-DD (ISO-like)
|
||||
const isoMatch = datePart.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||
if (isoMatch) {
|
||||
const [_, year, month, day] = isoMatch;
|
||||
parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
|
||||
// Format 2: DD.MM.YYYY (European)
|
||||
if (!parsedDate) {
|
||||
const euMatch = datePart.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||||
if (euMatch) {
|
||||
const [_, day, month, year] = euMatch;
|
||||
parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
}
|
||||
|
||||
// Format 3: MM/DD/YYYY (US)
|
||||
if (!parsedDate) {
|
||||
const usMatch = datePart.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (usMatch) {
|
||||
const [_, month, day, year] = usMatch;
|
||||
parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
}
|
||||
|
||||
// If no date was parsed, return null
|
||||
if (!parsedDate || isNaN(parsedDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse time if present (HH:MM format)
|
||||
if (timePart) {
|
||||
const timeMatch = timePart.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (timeMatch) {
|
||||
const [_, hours, minutes] = timeMatch;
|
||||
parsedDate.setHours(parseInt(hours));
|
||||
parsedDate.setMinutes(parseInt(minutes));
|
||||
}
|
||||
} else if (!this.enableTime) {
|
||||
// If time is not enabled and not provided, use current time
|
||||
const now = new Date();
|
||||
parsedDate.setHours(now.getHours());
|
||||
parsedDate.setMinutes(now.getMinutes());
|
||||
parsedDate.setSeconds(0);
|
||||
parsedDate.setMilliseconds(0);
|
||||
}
|
||||
|
||||
return parsedDate;
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
if (value) {
|
||||
try {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
this.selectedDate = date;
|
||||
this.viewDate = new Date(date);
|
||||
this.selectedHour = date.getHours();
|
||||
this.selectedMinute = date.getMinutes();
|
||||
}
|
||||
} catch {
|
||||
// Invalid date
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
410
ts_web/elements/dees-input-datepicker/demo.ts
Normal file
410
ts_web/elements/dees-input-datepicker/demo.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import '../dees-panel.js';
|
||||
import './component.js';
|
||||
import type { DeesInputDatepicker } from './component.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>
|
||||
`;
|
||||
4
ts_web/elements/dees-input-datepicker/index.ts
Normal file
4
ts_web/elements/dees-input-datepicker/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './component.js';
|
||||
export { datepickerStyles } from './styles.js';
|
||||
export { renderDatepicker } from './template.js';
|
||||
export type { IDateEvent } from './types.js';
|
||||
514
ts_web/elements/dees-input-datepicker/styles.ts
Normal file
514
ts_web/elements/dees-input-datepicker/styles.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base.js';
|
||||
|
||||
export const datepickerStyles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.date-input::placeholder {
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.date-input:hover:not(:disabled) {
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
}
|
||||
|
||||
.date-input:focus,
|
||||
.date-input.open {
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')},
|
||||
0 0 0 4px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')};
|
||||
}
|
||||
|
||||
.date-input:disabled {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Icon container using flexbox for better positioning */
|
||||
.icon-container {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon-container > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.calendar-icon {
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
}
|
||||
|
||||
.clear-button:disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Calendar Popup Styles */
|
||||
.calendar-popup {
|
||||
will-change: transform, opacity;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 10px 15px -3px hsl(0 0% 0% / 0.1), 0 4px 6px -4px hsl(0 0% 0% / 0.1)',
|
||||
'0 10px 15px -3px hsl(0 0% 0% / 0.2), 0 4px 6px -4px hsl(0 0% 0% / 0.2)'
|
||||
)};
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
margin-top: 4px;
|
||||
z-index: 50;
|
||||
left: 0;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.calendar-popup.top {
|
||||
bottom: calc(100% + 4px);
|
||||
top: auto;
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.calendar-popup.bottom {
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.calendar-popup.show {
|
||||
pointer-events: all;
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Calendar Header */
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.month-year-display {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
}
|
||||
|
||||
.nav-button:active {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
/* Weekday headers */
|
||||
.weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
padding: 0 0 8px 0;
|
||||
}
|
||||
|
||||
/* Days grid */
|
||||
.days-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
border: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.day:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
}
|
||||
|
||||
.day.other-month {
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.day.today {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.day.selected {
|
||||
background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.day.disabled {
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Event indicators */
|
||||
.day.has-event {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.event-indicator {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.event-dot.info {
|
||||
background: ${cssManager.bdTheme('hsl(211 70% 52%)', 'hsl(211 70% 62%)')};
|
||||
}
|
||||
|
||||
.event-dot.warning {
|
||||
background: ${cssManager.bdTheme('hsl(45 90% 45%)', 'hsl(45 90% 55%)')};
|
||||
}
|
||||
|
||||
.event-dot.success {
|
||||
background: ${cssManager.bdTheme('hsl(142 69% 45%)', 'hsl(142 69% 55%)')};
|
||||
}
|
||||
|
||||
.event-dot.error {
|
||||
background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')};
|
||||
}
|
||||
|
||||
.event-count {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')};
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Tooltip for event details */
|
||||
.event-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 0%)')};
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.event-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
.day.has-event:hover .event-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Time selector */
|
||||
.time-selector {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
.time-selector-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.time-inputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
width: 65px;
|
||||
height: 36px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.time-input:hover {
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
}
|
||||
|
||||
.time-input:focus {
|
||||
outline: none;
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')};
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.am-pm-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.am-pm-button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.am-pm-button.selected {
|
||||
background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
}
|
||||
|
||||
.am-pm-button:hover:not(.selected) {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.calendar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
.action-button {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.today-button {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
}
|
||||
|
||||
.today-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
.today-button:active {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 30.6% / 0.1)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
|
||||
}
|
||||
|
||||
.clear-button:active {
|
||||
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.2)', 'hsl(0 62.8% 30.6% / 0.2)')};
|
||||
}
|
||||
|
||||
/* Timezone selector */
|
||||
.timezone-selector {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
.timezone-selector-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.timezone-select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.timezone-select:hover {
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
}
|
||||
|
||||
.timezone-select:focus {
|
||||
outline: none;
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
179
ts_web/elements/dees-input-datepicker/template.ts
Normal file
179
ts_web/elements/dees-input-datepicker/template.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import type { DeesInputDatepicker } from './component.js';
|
||||
|
||||
export const renderDatepicker = (component: DeesInputDatepicker): TemplateResult => {
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const weekDays = component.weekStartsOn === 1
|
||||
? ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
|
||||
const days = component.getDaysInMonth();
|
||||
const isAM = component.selectedHour < 12;
|
||||
const timezones = component.getTimezones();
|
||||
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label .label=${component.label} .description=${component.description} .required=${component.required}></dees-label>
|
||||
<div class="input-container">
|
||||
<input
|
||||
type="text"
|
||||
class="date-input ${component.isOpened ? 'open' : ''}"
|
||||
.value=${component.formatDate(component.value)}
|
||||
.placeholder=${component.placeholder}
|
||||
?disabled=${component.disabled}
|
||||
@click=${component.toggleCalendar}
|
||||
@keydown=${component.handleKeydown}
|
||||
@input=${component.handleManualInput}
|
||||
@blur=${component.handleInputBlur}
|
||||
style="padding-right: ${component.value ? '64px' : '40px'}"
|
||||
/>
|
||||
<div class="icon-container">
|
||||
${component.value && !component.disabled ? html`
|
||||
<button class="clear-button" @click=${component.clearValue} title="Clear">
|
||||
<dees-icon icon="lucide:x" iconSize="14"></dees-icon>
|
||||
</button>
|
||||
` : ''}
|
||||
<dees-icon class="calendar-icon" icon="lucide:calendar" iconSize="16"></dees-icon>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Popup -->
|
||||
<div class="calendar-popup ${component.isOpened ? 'show' : ''} ${component.opensToTop ? 'top' : 'bottom'}">
|
||||
<!-- Month/Year Navigation -->
|
||||
<div class="calendar-header">
|
||||
<button class="nav-button" @click=${component.previousMonth}>
|
||||
<dees-icon icon="lucide:chevronLeft" iconSize="16"></dees-icon>
|
||||
</button>
|
||||
<div class="month-year-display">
|
||||
${monthNames[component.viewDate.getMonth()]} ${component.viewDate.getFullYear()}
|
||||
</div>
|
||||
<button class="nav-button" @click=${component.nextMonth}>
|
||||
<dees-icon icon="lucide:chevronRight" iconSize="16"></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Weekday Headers -->
|
||||
<div class="weekdays">
|
||||
${weekDays.map(day => html`<div class="weekday">${day}</div>`)}
|
||||
</div>
|
||||
|
||||
<!-- Days Grid -->
|
||||
<div class="days-grid">
|
||||
${days.map(day => {
|
||||
const isToday = component.isToday(day);
|
||||
const isSelected = component.isSelected(day);
|
||||
const isOtherMonth = day.getMonth() !== component.viewDate.getMonth();
|
||||
const isDisabled = component.isDisabled(day);
|
||||
const dayEvents = component.getEventsForDate(day);
|
||||
const hasEvents = dayEvents.length > 0;
|
||||
const totalEventCount = dayEvents.reduce((sum, event) => sum + (event.count || 1), 0);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="day ${isOtherMonth ? 'other-month' : ''} ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''} ${isDisabled ? 'disabled' : ''} ${hasEvents ? 'has-event' : ''}"
|
||||
@click=${() => !isDisabled && component.selectDate(day)}
|
||||
>
|
||||
${day.getDate()}
|
||||
${hasEvents ? html`
|
||||
${totalEventCount > 3 ? html`
|
||||
<div class="event-count">${totalEventCount}</div>
|
||||
` : html`
|
||||
<div class="event-indicator">
|
||||
${dayEvents.slice(0, 3).map(event => html`
|
||||
<div class="event-dot ${event.type || 'info'}"></div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
${dayEvents[0].title ? html`
|
||||
<div class="event-tooltip">
|
||||
${dayEvents[0].title}
|
||||
${totalEventCount > 1 ? html` (+${totalEventCount - 1} more)` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- Time Selector -->
|
||||
${component.enableTime ? html`
|
||||
<div class="time-selector">
|
||||
<div class="time-selector-title">Time</div>
|
||||
<div class="time-inputs">
|
||||
<input
|
||||
type="number"
|
||||
class="time-input"
|
||||
.value=${component.timeFormat === '12h'
|
||||
? (component.selectedHour === 0 ? 12 : component.selectedHour > 12 ? component.selectedHour - 12 : component.selectedHour).toString().padStart(2, '0')
|
||||
: component.selectedHour.toString().padStart(2, '0')}
|
||||
@input=${(e: InputEvent) => component.handleHourInput(e)}
|
||||
min="${component.timeFormat === '12h' ? 1 : 0}"
|
||||
max="${component.timeFormat === '12h' ? 12 : 23}"
|
||||
/>
|
||||
<span class="time-separator">:</span>
|
||||
<input
|
||||
type="number"
|
||||
class="time-input"
|
||||
.value=${component.selectedMinute.toString().padStart(2, '0')}
|
||||
@input=${(e: InputEvent) => component.handleMinuteInput(e)}
|
||||
min="0"
|
||||
max="59"
|
||||
step="${component.minuteIncrement || 1}"
|
||||
/>
|
||||
${component.timeFormat === '12h' ? html`
|
||||
<div class="am-pm-selector">
|
||||
<button
|
||||
class="am-pm-button ${isAM ? 'selected' : ''}"
|
||||
@click=${() => component.setAMPM('am')}
|
||||
>
|
||||
AM
|
||||
</button>
|
||||
<button
|
||||
class="am-pm-button ${!isAM ? 'selected' : ''}"
|
||||
@click=${() => component.setAMPM('pm')}
|
||||
>
|
||||
PM
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Timezone Selector -->
|
||||
${component.enableTimezone ? html`
|
||||
<div class="timezone-selector">
|
||||
<div class="timezone-selector-title">Timezone</div>
|
||||
<select
|
||||
class="timezone-select"
|
||||
.value=${component.timezone}
|
||||
@change=${(e: Event) => component.handleTimezoneChange(e)}
|
||||
>
|
||||
${timezones.map(tz => html`
|
||||
<option value="${tz.value}" ?selected=${tz.value === component.timezone}>
|
||||
${tz.label}
|
||||
</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="calendar-actions">
|
||||
<button class="action-button today-button" @click=${component.selectToday}>
|
||||
Today
|
||||
</button>
|
||||
<button class="action-button clear-button" @click=${component.clear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
};
|
||||
7
ts_web/elements/dees-input-datepicker/types.ts
Normal file
7
ts_web/elements/dees-input-datepicker/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface IDateEvent {
|
||||
date: string; // ISO date string (YYYY-MM-DD)
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: 'info' | 'warning' | 'success' | 'error';
|
||||
count?: number; // Number of events on this day
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import './dees-form.js';
|
||||
import './dees-form-submit.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
@@ -44,6 +43,25 @@ export const demoFunc = () => html`
|
||||
</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');
|
||||
|
||||
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>
|
||||
</div>
|
||||
`
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
} from '@design.estate/dees-element';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { demoFunc } from './dees-input-dropdown.demo.js';
|
||||
import { DeesWindowLayer } from './dees-windowlayer.js';
|
||||
import { DeesInputBase } from './dees-input-base.js';
|
||||
import { cssGeistFontFamily } from './00fonts.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -39,13 +39,11 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
|
||||
this.selectedOption = val;
|
||||
}
|
||||
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
public enableSearch: boolean = true;
|
||||
|
||||
|
||||
@state()
|
||||
public opensToTop: boolean = false;
|
||||
|
||||
@@ -58,6 +56,9 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
|
||||
@state()
|
||||
public isOpened = false;
|
||||
|
||||
@state()
|
||||
private searchValue: string = '';
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
@@ -67,123 +68,201 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
|
||||
}
|
||||
|
||||
:host {
|
||||
font-family: Roboto;
|
||||
font-family: ${cssGeistFontFamily};
|
||||
position: relative;
|
||||
color: ${cssManager.bdTheme('#222', '#fff')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
.maincontainer {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.selectedBox {
|
||||
user-select: none;
|
||||
position: relative;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
padding: 0px 8px;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#222222')};
|
||||
box-shadow: ${cssManager.bdTheme('0px 1px 4px rgba(0,0,0,0.3)', 'none')};
|
||||
border-radius: 3px;
|
||||
border-top: ${cssManager.bdTheme('1px solid #CCC', '1px solid #ffffff10')};
|
||||
border-bottom: ${cssManager.bdTheme('1px solid #CCC', '1px solid #222')};
|
||||
transition: all 0.2s ease;
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#222', '#ccc')};
|
||||
line-height: 38px;
|
||||
padding: 0 40px 0 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selectedBox:hover {
|
||||
filter: ${cssManager.bdTheme('brightness(0.95)', 'brightness(1.1)')};
|
||||
.selectedBox:hover:not(.disabled) {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
.accentBottom {
|
||||
filter: none !important;
|
||||
.selectedBox:focus-visible {
|
||||
outline: none;
|
||||
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)')};
|
||||
}
|
||||
|
||||
.accentTop {
|
||||
filter: none !important;
|
||||
.selectedBox.disabled {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Dropdown arrow */
|
||||
.selectedBox::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.selectedBox.open::after {
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
}
|
||||
|
||||
.selectionBox {
|
||||
will-change: transform;
|
||||
will-change: transform, opacity;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.15s ease;
|
||||
opacity: 0;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#222222')};
|
||||
max-width: 420px;
|
||||
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(-8px) scale(0.98);
|
||||
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%)')};
|
||||
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1), 0 2px 4px -2px hsl(0 0% 0% / 0.1);
|
||||
min-height: 40px;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
margin: 3px 0px 0px 0px;
|
||||
border-top: ${cssManager.bdTheme('1px solid #CCC', '1px solid #ffffff10')};
|
||||
margin-top: 4px;
|
||||
z-index: 50;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.selectionBox.top {
|
||||
transform: translate(0px, 4px);
|
||||
bottom: calc(100% + 4px);
|
||||
top: auto;
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
|
||||
.selectionBox.bottom {
|
||||
transform: translate(0px, -4px);
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.selectionBox.show {
|
||||
pointer-events: all;
|
||||
transform: scale(1, 1) translate(0px, 0px);
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Options container */
|
||||
.options-container {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Options */
|
||||
.option {
|
||||
transition: all 0.1s;
|
||||
transition: all 0.15s ease;
|
||||
line-height: 32px;
|
||||
padding: 0px 4px;
|
||||
border-radius: 3px;
|
||||
margin: 4px 0px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
margin: 2px 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
.option.highlighted {
|
||||
border-left: 2px solid #0069f2;
|
||||
padding-left: 6px;
|
||||
background: #ffffff20;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
color: #fff;
|
||||
padding-left: 8px;
|
||||
background: #0069f2;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.search.top {
|
||||
padding-top: 4px;
|
||||
/* No options message */
|
||||
.no-options {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search {
|
||||
padding: 4px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.search.bottom {
|
||||
padding-bottom: 4px;
|
||||
border-bottom: none;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
margin-bottom: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.search input {
|
||||
display: block;
|
||||
background: none;
|
||||
border: none;
|
||||
height: 24px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.search.top input {
|
||||
border-bottom: 1px dotted #333;
|
||||
}
|
||||
.search.bottom input {
|
||||
border-top: 1px dotted #333;
|
||||
.search input::placeholder {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
}
|
||||
|
||||
.search input:focus {
|
||||
outline: none;
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.options-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.options-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.options-container::-webkit-scrollbar-thumb {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.options-container::-webkit-scrollbar-thumb:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -191,61 +270,78 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label .label=${this.label}></dees-label>
|
||||
<div class="maincontainer" @keydown="${this.isOpened ? this.handleKeyDown : undefined}">
|
||||
<div class="selectionBox">
|
||||
${this.enableSearch && !this.opensToTop
|
||||
<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>
|
||||
<div class="maincontainer">
|
||||
<div
|
||||
class="selectedBox ${this.isOpened ? 'open' : ''} ${this.disabled ? 'disabled' : ''}"
|
||||
@click="${() => !this.disabled && this.toggleSelectionBox()}"
|
||||
tabindex="${this.disabled ? '-1' : '0'}"
|
||||
@keydown="${this.handleSelectedBoxKeydown}"
|
||||
>
|
||||
${this.selectedOption?.option || 'Select an option'}
|
||||
</div>
|
||||
<div class="selectionBox ${this.isOpened ? 'show' : ''} ${this.opensToTop ? 'top' : 'bottom'}">
|
||||
${this.enableSearch
|
||||
? html`
|
||||
<div class="search top">
|
||||
<input type="text" placeholder="Search" @input="${this.handleSearch}" />
|
||||
<div class="search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search options..."
|
||||
.value="${this.searchValue}"
|
||||
@input="${this.handleSearch}"
|
||||
@click="${(e: Event) => e.stopPropagation()}"
|
||||
@keydown="${this.handleSearchKeydown}"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
${this.filteredOptions.map((option, index) => {
|
||||
<div class="options-container">
|
||||
${this.filteredOptions.length === 0
|
||||
? html`<div class="no-options">No options found</div>`
|
||||
: this.filteredOptions.map((option, index) => {
|
||||
const isHighlighted = this.highlightedIndex === index;
|
||||
return html`
|
||||
<div
|
||||
class="option ${isHighlighted ? 'highlighted' : ''}"
|
||||
@click=${() => {
|
||||
this.updateSelection(option);
|
||||
}}
|
||||
@click="${() => this.updateSelection(option)}"
|
||||
@mouseenter="${() => this.highlightedIndex = index}"
|
||||
>
|
||||
${option.option}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
${this.enableSearch && this.opensToTop
|
||||
? html`
|
||||
<div class="search bottom">
|
||||
<input type="text" placeholder="Search" @input="${this.handleSearch}" />
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
<div
|
||||
class="selectedBox"
|
||||
@click="${(event) => {
|
||||
if (!this.isElevated) {
|
||||
this.toggleSelectionBox();
|
||||
} else {
|
||||
this.updateSelection(this.selectedOption);
|
||||
})
|
||||
}
|
||||
}}"
|
||||
>
|
||||
${this.selectedOption?.option || 'Select...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.selectedOption = this.selectedOption || null;
|
||||
this.filteredOptions = this.options; // Initialize filteredOptions
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||
}
|
||||
|
||||
public async updateSelection(selectedOption) {
|
||||
firstUpdated() {
|
||||
this.selectedOption = this.selectedOption || null;
|
||||
this.filteredOptions = this.options;
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('options')) {
|
||||
this.filteredOptions = this.options;
|
||||
}
|
||||
}
|
||||
|
||||
public async updateSelection(selectedOption: { option: string; key: string; payload?: any }) {
|
||||
this.selectedOption = selectedOption;
|
||||
this.isOpened = false;
|
||||
this.searchValue = '';
|
||||
this.filteredOptions = this.options;
|
||||
this.highlightedIndex = 0;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('selectedOption', {
|
||||
@@ -253,135 +349,105 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
if (this.isElevated) {
|
||||
this.toggleSelectionBox();
|
||||
}
|
||||
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
private isElevated: boolean = false;
|
||||
private windowOverlay: DeesWindowLayer;
|
||||
private handleClickOutside = (event: MouseEvent) => {
|
||||
const path = event.composedPath();
|
||||
if (!path.includes(this)) {
|
||||
this.isOpened = false;
|
||||
this.searchValue = '';
|
||||
this.filteredOptions = this.options;
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
};
|
||||
|
||||
public async toggleSelectionBox() {
|
||||
this.isOpened = !this.isOpened;
|
||||
const domtoolsInstance = await this.domtoolsPromise;
|
||||
const selectedBox: HTMLElement = this.shadowRoot.querySelector('.selectedBox');
|
||||
const selectionBox: HTMLElement = this.shadowRoot.querySelector('.selectionBox');
|
||||
if (!this.isElevated) {
|
||||
this.windowOverlay = await DeesWindowLayer.createAndShow({
|
||||
blur: false,
|
||||
});
|
||||
const elevatedDropdown = new DeesInputDropdown();
|
||||
elevatedDropdown.isElevated = true;
|
||||
elevatedDropdown.label = this.label;
|
||||
elevatedDropdown.enableSearch = this.enableSearch;
|
||||
elevatedDropdown.required = this.required;
|
||||
elevatedDropdown.disabled = this.disabled;
|
||||
elevatedDropdown.style.position = 'fixed';
|
||||
elevatedDropdown.style.top = this.getBoundingClientRect().top + 'px';
|
||||
elevatedDropdown.style.left = this.getBoundingClientRect().left + 'px';
|
||||
elevatedDropdown.style.width = this.clientWidth + 'px';
|
||||
|
||||
// Get z-index from registry for the elevated dropdown
|
||||
const dropdownZIndex = (await import('./00zindex.js')).zIndexRegistry.getNextZIndex();
|
||||
elevatedDropdown.style.zIndex = dropdownZIndex.toString();
|
||||
(await import('./00zindex.js')).zIndexRegistry.register(elevatedDropdown, dropdownZIndex);
|
||||
elevatedDropdown.options = this.options;
|
||||
elevatedDropdown.selectedOption = this.selectedOption;
|
||||
elevatedDropdown.highlightedIndex = elevatedDropdown.selectedOption ? elevatedDropdown.options.indexOf(
|
||||
elevatedDropdown.options.find((option) => option.key === this.selectedOption.key)
|
||||
) : -1;
|
||||
console.log(elevatedDropdown.options);
|
||||
console.log(elevatedDropdown.selectedOption);
|
||||
console.log(elevatedDropdown.highlightedIndex);
|
||||
this.windowOverlay.appendChild(elevatedDropdown);
|
||||
if (this.isOpened) {
|
||||
// Check available space and set position
|
||||
const selectedBox = this.shadowRoot.querySelector('.selectedBox') as HTMLElement;
|
||||
const rect = selectedBox.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
|
||||
// Prevent clicks on the dropdown from closing it
|
||||
elevatedDropdown.addEventListener('click', (e: Event) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
// Determine if we should open upwards
|
||||
this.opensToTop = spaceBelow < 300 && spaceAbove > spaceBelow;
|
||||
|
||||
await domtoolsInstance.convenience.smartdelay.delayFor(0);
|
||||
elevatedDropdown.toggleSelectionBox();
|
||||
const destroyOverlay = async () => {
|
||||
(elevatedDropdown.shadowRoot.querySelector('.selectionBox') as HTMLElement).style.opacity =
|
||||
'0';
|
||||
elevatedDropdown.removeEventListener('selectedOption', handleSelection);
|
||||
this.windowOverlay.removeEventListener('clicked', destroyOverlay);
|
||||
|
||||
// Unregister elevated dropdown from z-index registry
|
||||
(await import('./00zindex.js')).zIndexRegistry.unregister(elevatedDropdown);
|
||||
|
||||
this.windowOverlay.destroy();
|
||||
};
|
||||
const handleSelection = async () => {
|
||||
await this.updateSelection(elevatedDropdown.selectedOption);
|
||||
destroyOverlay();
|
||||
};
|
||||
elevatedDropdown.addEventListener('selectedOption', handleSelection);
|
||||
this.windowOverlay.addEventListener('clicked', destroyOverlay);
|
||||
} else {
|
||||
if (!selectionBox.classList.contains('show')) {
|
||||
selectionBox.style.width = selectedBox.clientWidth + 'px';
|
||||
const spaceData = selectedBox.getBoundingClientRect();
|
||||
if (300 > window.innerHeight - spaceData.bottom) {
|
||||
this.opensToTop = true;
|
||||
selectedBox.classList.add('accentTop');
|
||||
selectionBox.classList.add('top');
|
||||
selectionBox.style.bottom = selectedBox.clientHeight + 2 + 'px';
|
||||
} else {
|
||||
selectedBox.classList.add('accentBottom');
|
||||
selectionBox.classList.add('bottom');
|
||||
this.opensToTop = false;
|
||||
const labelOffset = this.label ? 24 : 0;
|
||||
selectionBox.style.top = selectedBox.clientHeight + labelOffset + 'px';
|
||||
// Focus search input if present
|
||||
await this.updateComplete;
|
||||
const searchInput = this.shadowRoot.querySelector('.search input') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
await domtoolsInstance.convenience.smartdelay.delayFor(0);
|
||||
const searchInput = selectionBox.querySelector('input');
|
||||
searchInput?.focus();
|
||||
|
||||
// Get z-index from registry for the selection box
|
||||
const selectionBoxZIndex = (await import('./00zindex.js')).zIndexRegistry.getNextZIndex();
|
||||
selectionBox.style.zIndex = selectionBoxZIndex.toString();
|
||||
(await import('./00zindex.js')).zIndexRegistry.register(selectionBox as HTMLElement, selectionBoxZIndex);
|
||||
|
||||
selectionBox.classList.add('show');
|
||||
// Add click outside listener
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
}, 0);
|
||||
} else {
|
||||
selectedBox.style.pointerEvents = 'none';
|
||||
selectionBox.classList.remove('show');
|
||||
|
||||
// Unregister selection box from z-index registry
|
||||
(await import('./00zindex.js')).zIndexRegistry.unregister(selectionBox as HTMLElement);
|
||||
|
||||
// selectedBox.style.opacity = '0';
|
||||
}
|
||||
// Cleanup
|
||||
this.searchValue = '';
|
||||
this.filteredOptions = this.options;
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSearch(event: Event): void {
|
||||
const searchTerm = (event.target as HTMLInputElement).value.toLowerCase();
|
||||
const searchTerm = (event.target as HTMLInputElement).value;
|
||||
this.searchValue = searchTerm;
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
this.filteredOptions = this.options.filter((option) =>
|
||||
option.option.toLowerCase().includes(searchTerm)
|
||||
option.option.toLowerCase().includes(searchLower)
|
||||
);
|
||||
this.highlightedIndex = 0; // Reset highlighted index
|
||||
this.highlightedIndex = 0;
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent): void {
|
||||
if (!this.isOpened) {
|
||||
console.log('discarded key event. Check why this function is called.');
|
||||
return;
|
||||
}
|
||||
const key = event.key;
|
||||
const maxIndex = this.filteredOptions.length - 1;
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.highlightedIndex = this.highlightedIndex + 1 > maxIndex ? 0 : this.highlightedIndex + 1;
|
||||
event.preventDefault();
|
||||
} else if (key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.highlightedIndex = this.highlightedIndex - 1 < 0 ? maxIndex : this.highlightedIndex - 1;
|
||||
event.preventDefault();
|
||||
} else if (key === 'Enter') {
|
||||
this.updateSelection(this.filteredOptions[this.highlightedIndex]);
|
||||
event.preventDefault();
|
||||
if (this.filteredOptions[this.highlightedIndex]) {
|
||||
this.updateSelection(this.filteredOptions[this.highlightedIndex]);
|
||||
}
|
||||
} else if (key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.isOpened = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleSearchKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
|
||||
this.handleKeyDown(event);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSelectedBoxKeydown(event: KeyboardEvent) {
|
||||
if (this.disabled) return;
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.toggleSelectionBox();
|
||||
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (!this.isOpened) {
|
||||
this.toggleSelectionBox();
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (this.isOpened) {
|
||||
this.isOpened = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,4 +458,9 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
|
||||
public setValue(value: { option: string; key: string; payload?: any }): void {
|
||||
this.selectedOption = value;
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.upload-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#fff', '#2a2a2a')};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444')};
|
||||
}
|
||||
|
||||
.upload-box h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-top: 32px;
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#fff3cd', '#332701')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ffeaa7', '#664400')};
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('#856404', '#ffecb5')};
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'1. Basic File Upload'} .subtitle=${'Simple file upload with drag and drop support'}>
|
||||
<dees-input-fileupload
|
||||
.label=${'Attachments'}
|
||||
.description=${'Upload any files by clicking or dragging them here'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Single File Only'}
|
||||
.description=${'Only one file can be uploaded at a time'}
|
||||
.multiple=${false}
|
||||
.buttonText=${'Choose File'}
|
||||
></dees-input-fileupload>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'2. File Type Restrictions'} .subtitle=${'Upload areas with specific file type requirements'}>
|
||||
<div class="upload-grid">
|
||||
<div class="upload-box">
|
||||
<h4>Images Only</h4>
|
||||
<dees-input-fileupload
|
||||
.label=${'Profile Picture'}
|
||||
.description=${'JPG, PNG or GIF (max 5MB)'}
|
||||
.accept=${'image/jpeg,image/png,image/gif'}
|
||||
.maxSize=${5 * 1024 * 1024}
|
||||
.multiple=${false}
|
||||
.buttonText=${'Select Image'}
|
||||
></dees-input-fileupload>
|
||||
</div>
|
||||
|
||||
<div class="upload-box">
|
||||
<h4>Documents Only</h4>
|
||||
<dees-input-fileupload
|
||||
.label=${'Resume'}
|
||||
.description=${'PDF or Word documents only'}
|
||||
.accept=${".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"}
|
||||
.buttonText=${'Select Document'}
|
||||
></dees-input-fileupload>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'3. Validation & Limits'} .subtitle=${'File size limits and validation examples'}>
|
||||
<dees-input-fileupload
|
||||
.label=${'Small Files Only'}
|
||||
.description=${'Maximum file size: 1MB'}
|
||||
.maxSize=${1024 * 1024}
|
||||
.buttonText=${'Upload Small File'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Limited Upload'}
|
||||
.description=${'Maximum 3 files, each up to 2MB'}
|
||||
.maxFiles=${3}
|
||||
.maxSize=${2 * 1024 * 1024}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Required Upload'}
|
||||
.description=${'This field is required'}
|
||||
.required=${true}
|
||||
></dees-input-fileupload>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'4. States & Styling'} .subtitle=${'Different states and validation feedback'}>
|
||||
<dees-input-fileupload
|
||||
.label=${'Disabled Upload'}
|
||||
.description=${'File upload is currently disabled'}
|
||||
.disabled=${true}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Pre-filled Example'}
|
||||
.description=${'Component with pre-loaded files'}
|
||||
.value=${[
|
||||
new File(['Hello World'], 'example.txt', { type: 'text/plain' }),
|
||||
new File(['Test Data'], 'data.json', { type: 'application/json' })
|
||||
]}
|
||||
></dees-input-fileupload>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'5. Form Integration'} .subtitle=${'Complete form with various file upload scenarios'}>
|
||||
<dees-form>
|
||||
<h3 style="margin-top: 0; margin-bottom: 24px; color: ${cssManager.bdTheme('#333', '#fff')};">Job Application Form</h3>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Full Name'}
|
||||
.required=${true}
|
||||
.key=${'fullName'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Email'}
|
||||
.inputType=${'email'}
|
||||
.required=${true}
|
||||
.key=${'email'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Resume'}
|
||||
.description=${'Required: PDF format only (max 10MB)'}
|
||||
.required=${true}
|
||||
.accept=${'application/pdf'}
|
||||
.maxSize=${10 * 1024 * 1024}
|
||||
.multiple=${false}
|
||||
.key=${'resume'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Portfolio'}
|
||||
.description=${'Optional: Upload up to 5 work samples (images or PDFs, max 5MB each)'}
|
||||
.accept=${'image/*,application/pdf'}
|
||||
.maxFiles=${5}
|
||||
.maxSize=${5 * 1024 * 1024}
|
||||
.key=${'portfolio'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'References'}
|
||||
.description=${'Upload reference letters (optional)'}
|
||||
.accept=${".pdf,.doc,.docx"}
|
||||
.key=${'references'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Additional Comments'}
|
||||
.inputType=${'textarea'}
|
||||
.description=${'Any additional information you would like to share'}
|
||||
.key=${'comments'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-form-submit .text=${'Submit Application'}></dees-form-submit>
|
||||
</dees-form>
|
||||
|
||||
<div class="info-section">
|
||||
<h4 style="margin-top: 0;">Enhanced Features:</h4>
|
||||
<ul style="margin: 0; padding-left: 20px;">
|
||||
<li>Drag & drop with visual feedback</li>
|
||||
<li>File type restrictions via accept attribute</li>
|
||||
<li>File size validation with custom limits</li>
|
||||
<li>Maximum file count restrictions</li>
|
||||
<li>Image preview thumbnails</li>
|
||||
<li>File type-specific icons</li>
|
||||
<li>Clear all button for multiple files</li>
|
||||
<li>Proper validation states and messages</li>
|
||||
<li>Keyboard accessible</li>
|
||||
<li>Single or multiple file modes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -1,642 +0,0 @@
|
||||
import * as colors from './00colors.js';
|
||||
import * as plugins from './00plugins.js';
|
||||
|
||||
import { DeesContextmenu } from './dees-contextmenu.js';
|
||||
import { DeesInputBase } from './dees-input-base.js';
|
||||
import { demoFunc } from './dees-input-fileupload.demo.js';
|
||||
|
||||
import {
|
||||
customElement,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
html,
|
||||
css,
|
||||
unsafeCSS,
|
||||
cssManager,
|
||||
type CSSResult,
|
||||
domtools,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-fileupload': DeesInputFileupload;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-fileupload')
|
||||
export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
|
||||
@property({
|
||||
attribute: false,
|
||||
})
|
||||
public value: File[] = [];
|
||||
|
||||
@property()
|
||||
public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle';
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public buttonText: string = 'Upload File...';
|
||||
|
||||
@property({ type: String })
|
||||
public accept: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public multiple: boolean = true;
|
||||
|
||||
@property({ type: Number })
|
||||
public maxSize: number = 0; // 0 means no limit
|
||||
|
||||
@property({ type: Number })
|
||||
public maxFiles: number = 0; // 0 means no limit
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.maincontainer {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.maincontainer:hover {
|
||||
border-color: ${cssManager.bdTheme('#ccc', '#444')};
|
||||
}
|
||||
|
||||
:host([disabled]) .maincontainer {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:host([validationState="invalid"]) .maincontainer {
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
|
||||
:host([validationState="valid"]) .maincontainer {
|
||||
border-color: #27ae60;
|
||||
}
|
||||
|
||||
:host([validationState="warn"]) .maincontainer {
|
||||
border-color: #f39c12;
|
||||
}
|
||||
|
||||
.maincontainer::after {
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
transform: scale3d(0.98, 0.9, 1);
|
||||
position: absolute;
|
||||
content: '';
|
||||
display: block;
|
||||
border: 2px dashed transparent;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.maincontainer.dragOver {
|
||||
border-color: ${cssManager.bdTheme('#0084ff', '#0084ff')};
|
||||
background: ${cssManager.bdTheme('#f0f8ff', '#001933')};
|
||||
}
|
||||
|
||||
.maincontainer.dragOver::after {
|
||||
transform: scale3d(1, 1, 1);
|
||||
border: 2px dashed ${cssManager.bdTheme('#0084ff', '#0084ff')};
|
||||
}
|
||||
|
||||
.uploadButton {
|
||||
position: relative;
|
||||
padding: 12px 24px;
|
||||
background: ${cssManager.bdTheme('#0084ff', '#0084ff')};
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.uploadButton:hover {
|
||||
background: ${cssManager.bdTheme('#0073e6', '#0073e6')};
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 132, 255, 0.3);
|
||||
}
|
||||
|
||||
.uploadButton:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.uploadButton dees-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.files-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.uploadCandidate {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#2a2a2a')};
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
cursor: default;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.uploadCandidate:hover {
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#333')};
|
||||
border-color: ${cssManager.bdTheme('#ccc', '#444')};
|
||||
}
|
||||
|
||||
.uploadCandidate .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.uploadCandidate.image-file .icon {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.uploadCandidate.pdf-file .icon {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.uploadCandidate.doc-file .icon {
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
.uploadCandidate .info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.uploadCandidate .filename {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.uploadCandidate .filesize {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.uploadCandidate .actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background: ${cssManager.bdTheme('#fee', '#4a1c1c')};
|
||||
color: ${cssManager.bdTheme('#e74c3c', '#ff6b6b')};
|
||||
}
|
||||
|
||||
.clear-all-button {
|
||||
margin-bottom: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.clear-all-button button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.clear-all-button button:hover {
|
||||
background: ${cssManager.bdTheme('#fee', '#4a1c1c')};
|
||||
color: ${cssManager.bdTheme('#e74c3c', '#ff6b6b')};
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.drop-hint {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.drop-hint dees-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const hasFiles = this.value.length > 0;
|
||||
const showClearAll = hasFiles && this.value.length > 1;
|
||||
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
${this.label ? html`
|
||||
<dees-label .label=${this.label}></dees-label>
|
||||
` : ''}
|
||||
<div class="hidden">
|
||||
<input
|
||||
type="file"
|
||||
?multiple=${this.multiple}
|
||||
accept="${this.accept}"
|
||||
>
|
||||
</div>
|
||||
<div class="maincontainer ${this.state === 'dragOver' ? 'dragOver' : ''}">
|
||||
${hasFiles ? html`
|
||||
${showClearAll ? html`
|
||||
<div class="clear-all-button">
|
||||
<button @click=${this.clearAll}>Clear All</button>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="files-container">
|
||||
${this.value.map((fileArg) => {
|
||||
const fileType = this.getFileType(fileArg);
|
||||
const isImage = fileType === 'image';
|
||||
return html`
|
||||
<div class="uploadCandidate ${fileType}-file">
|
||||
<div class="icon">
|
||||
${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>
|
||||
`}
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="filename" title="${fileArg.name}">${fileArg.name}</div>
|
||||
<div class="filesize">${this.formatFileSize(fileArg.size)}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="remove-button"
|
||||
@click=${() => this.removeFile(fileArg)}
|
||||
title="Remove file"
|
||||
>
|
||||
<dees-icon .iconName=${'lucide:x'}></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
` : html`
|
||||
<div class="drop-hint">
|
||||
<dees-icon .iconName=${'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>
|
||||
</div>
|
||||
${this.description ? html`
|
||||
<div class="description-text">${this.description}</div>
|
||||
` : ''}
|
||||
${this.validationState === 'invalid' && this.validationMessage ? html`
|
||||
<div class="validation-message">${this.validationMessage}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private validationMessage: string = '';
|
||||
|
||||
// Utility methods
|
||||
private formatFileSize(bytes: number): string {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
private getFileType(file: File): string {
|
||||
const type = file.type.toLowerCase();
|
||||
if (type.startsWith('image/')) return 'image';
|
||||
if (type === 'application/pdf') return 'pdf';
|
||||
if (type.includes('word') || type.includes('document')) return 'doc';
|
||||
if (type.includes('sheet') || type.includes('excel')) return 'spreadsheet';
|
||||
if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation';
|
||||
if (type.startsWith('video/')) return 'video';
|
||||
if (type.startsWith('audio/')) return 'audio';
|
||||
if (type.includes('zip') || type.includes('compressed')) return 'archive';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
private getFileIcon(file: File): string {
|
||||
const type = this.getFileType(file);
|
||||
const iconMap = {
|
||||
'image': 'lucide:image',
|
||||
'pdf': 'lucide:file-text',
|
||||
'doc': 'lucide:file-text',
|
||||
'spreadsheet': 'lucide:table',
|
||||
'presentation': 'lucide:presentation',
|
||||
'video': 'lucide:video',
|
||||
'audio': 'lucide:music',
|
||||
'archive': 'lucide:archive',
|
||||
'file': 'lucide:file'
|
||||
};
|
||||
return iconMap[type] || 'lucide:file';
|
||||
}
|
||||
|
||||
private canShowPreview(file: File): boolean {
|
||||
return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024; // 5MB limit for previews
|
||||
}
|
||||
|
||||
private validateFile(file: File): boolean {
|
||||
// Check file size
|
||||
if (this.maxSize > 0 && file.size > this.maxSize) {
|
||||
this.validationMessage = `File "${file.name}" exceeds maximum size of ${this.formatFileSize(this.maxSize)}`;
|
||||
this.validationState = 'invalid';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if (this.accept) {
|
||||
const acceptedTypes = this.accept.split(',').map(s => s.trim());
|
||||
let isAccepted = false;
|
||||
|
||||
for (const acceptType of acceptedTypes) {
|
||||
if (acceptType.startsWith('.')) {
|
||||
// Extension check
|
||||
if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) {
|
||||
isAccepted = true;
|
||||
break;
|
||||
}
|
||||
} else if (acceptType.endsWith('/*')) {
|
||||
// MIME type wildcard check
|
||||
const mimePrefix = acceptType.slice(0, -2);
|
||||
if (file.type.startsWith(mimePrefix)) {
|
||||
isAccepted = true;
|
||||
break;
|
||||
}
|
||||
} else if (file.type === acceptType) {
|
||||
// Exact MIME type check
|
||||
isAccepted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAccepted) {
|
||||
this.validationMessage = `File type not accepted. Please upload: ${this.accept}`;
|
||||
this.validationState = 'invalid';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async openFileSelector() {
|
||||
if (this.disabled) return;
|
||||
const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]');
|
||||
inputFile.click();
|
||||
}
|
||||
|
||||
private removeFile(file: File) {
|
||||
const index = this.value.indexOf(file);
|
||||
if (index > -1) {
|
||||
this.value.splice(index, 1);
|
||||
this.requestUpdate();
|
||||
this.validate();
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
private clearAll() {
|
||||
this.value = [];
|
||||
this.requestUpdate();
|
||||
this.validate();
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public async updateValue(eventArg: Event) {
|
||||
const target: any = eventArg.target;
|
||||
this.value = target.value;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
|
||||
super.firstUpdated(_changedProperties);
|
||||
const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]');
|
||||
inputFile.addEventListener('change', async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newFiles = Array.from(target.files);
|
||||
await this.addFiles(newFiles);
|
||||
// Reset the input value to allow selecting the same file again if needed
|
||||
target.value = '';
|
||||
});
|
||||
|
||||
// Handle drag and drop
|
||||
const dropArea = this.shadowRoot.querySelector('.maincontainer');
|
||||
const handlerFunction = async (eventArg: DragEvent) => {
|
||||
eventArg.preventDefault();
|
||||
eventArg.stopPropagation();
|
||||
|
||||
switch (eventArg.type) {
|
||||
case 'dragenter':
|
||||
case 'dragover':
|
||||
this.state = 'dragOver';
|
||||
break;
|
||||
case 'dragleave':
|
||||
// Check if we're actually leaving the drop area
|
||||
const rect = dropArea.getBoundingClientRect();
|
||||
const x = eventArg.clientX;
|
||||
const y = eventArg.clientY;
|
||||
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
|
||||
this.state = 'idle';
|
||||
}
|
||||
break;
|
||||
case 'drop':
|
||||
this.state = 'idle';
|
||||
const files = Array.from(eventArg.dataTransfer.files);
|
||||
await this.addFiles(files);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
dropArea.addEventListener('dragenter', handlerFunction, false);
|
||||
dropArea.addEventListener('dragleave', handlerFunction, false);
|
||||
dropArea.addEventListener('dragover', handlerFunction, false);
|
||||
dropArea.addEventListener('drop', handlerFunction, false);
|
||||
}
|
||||
|
||||
private async addFiles(files: File[]) {
|
||||
const filesToAdd: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (this.validateFile(file)) {
|
||||
filesToAdd.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToAdd.length === 0) return;
|
||||
|
||||
// Check max files limit
|
||||
if (this.maxFiles > 0) {
|
||||
const totalFiles = this.value.length + filesToAdd.length;
|
||||
if (totalFiles > this.maxFiles) {
|
||||
const allowedCount = this.maxFiles - this.value.length;
|
||||
if (allowedCount <= 0) {
|
||||
this.validationMessage = `Maximum ${this.maxFiles} files allowed`;
|
||||
this.validationState = 'invalid';
|
||||
return;
|
||||
}
|
||||
filesToAdd.splice(allowedCount);
|
||||
this.validationMessage = `Only ${allowedCount} more file(s) can be added`;
|
||||
this.validationState = 'warn';
|
||||
}
|
||||
}
|
||||
|
||||
// Add files
|
||||
if (!this.multiple && filesToAdd.length > 0) {
|
||||
this.value = [filesToAdd[0]];
|
||||
} else {
|
||||
this.value.push(...filesToAdd);
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
this.validate();
|
||||
this.changeSubject.next(this);
|
||||
|
||||
// Update button text
|
||||
if (this.value.length > 0) {
|
||||
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
|
||||
}
|
||||
}
|
||||
|
||||
public async validate(): Promise<boolean> {
|
||||
this.validationMessage = '';
|
||||
|
||||
if (this.required && this.value.length === 0) {
|
||||
this.validationState = 'invalid';
|
||||
this.validationMessage = 'Please select at least one file';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate all files
|
||||
for (const file of this.value) {
|
||||
if (!this.validateFile(file)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.validationState = 'valid';
|
||||
return true;
|
||||
}
|
||||
|
||||
public getValue(): File[] {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: File[]): void {
|
||||
this.value = value;
|
||||
this.requestUpdate();
|
||||
if (value.length > 0) {
|
||||
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
|
||||
} else {
|
||||
this.buttonText = 'Upload File...';
|
||||
}
|
||||
}
|
||||
|
||||
public updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('value')) {
|
||||
this.validate();
|
||||
}
|
||||
}
|
||||
}
|
||||
619
ts_web/elements/dees-input-fileupload/component.ts
Normal file
619
ts_web/elements/dees-input-fileupload/component.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import { DeesInputBase } from '../dees-input-base.js';
|
||||
import { demoFunc } from './demo.js';
|
||||
import { fileuploadStyles } from './styles.js';
|
||||
import '../dees-icon.js';
|
||||
import '../dees-label.js';
|
||||
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-fileupload': DeesInputFileupload;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-fileupload')
|
||||
export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({ attribute: false })
|
||||
public value: File[] = [];
|
||||
|
||||
@state()
|
||||
public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle';
|
||||
|
||||
@state()
|
||||
public isLoading: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
public buttonText: string = 'Select files';
|
||||
|
||||
@property({ type: String })
|
||||
public accept: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public multiple: boolean = true;
|
||||
|
||||
@property({ type: Number })
|
||||
public maxSize: number = 0; // 0 means no limit
|
||||
|
||||
@property({ type: Number })
|
||||
public maxFiles: number = 0; // 0 means no limit
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null;
|
||||
|
||||
public validationMessage: string = '';
|
||||
|
||||
private previewUrlMap: WeakMap<File, string> = new WeakMap();
|
||||
private dropArea: HTMLElement | null = null;
|
||||
|
||||
public static styles = fileuploadStyles;
|
||||
|
||||
public render(): TemplateResult {
|
||||
const acceptedSummary = this.getAcceptedSummary();
|
||||
const metaEntries: string[] = [
|
||||
this.multiple ? 'Multiple files supported' : 'Single file only',
|
||||
this.maxSize > 0 ? `Max ${this.formatFileSize(this.maxSize)}` : 'No size limit',
|
||||
];
|
||||
|
||||
if (acceptedSummary) {
|
||||
metaEntries.push(`Accepts ${acceptedSummary}`);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label
|
||||
.label=${this.label}
|
||||
.description=${this.description}
|
||||
.required=${this.required}
|
||||
></dees-label>
|
||||
<div
|
||||
class="dropzone ${this.state === 'dragOver' ? 'dropzone--active' : ''} ${this.disabled ? 'dropzone--disabled' : ''} ${this.value.length > 0 ? 'dropzone--has-files' : ''}"
|
||||
role="button"
|
||||
tabindex=${this.disabled ? -1 : 0}
|
||||
aria-disabled=${this.disabled}
|
||||
aria-label=${`Select files${acceptedSummary ? ` (${acceptedSummary})` : ''}`}
|
||||
@click=${this.handleDropzoneClick}
|
||||
@keydown=${this.handleDropzoneKeydown}
|
||||
>
|
||||
<input
|
||||
class="file-input"
|
||||
style="position: absolute; opacity: 0; pointer-events: none; width: 1px; height: 1px; top: 0; left: 0; overflow: hidden;"
|
||||
type="file"
|
||||
?multiple=${this.multiple}
|
||||
accept=${this.accept || ''}
|
||||
?disabled=${this.disabled}
|
||||
@change=${this.handleFileInputChange}
|
||||
tabindex="-1"
|
||||
/>
|
||||
<div class="dropzone__body">
|
||||
<div class="dropzone__icon">
|
||||
${this.isLoading
|
||||
? html`<span class="dropzone__loader" aria-hidden="true"></span>`
|
||||
: html`<dees-icon icon="lucide:FolderOpen"></dees-icon>`}
|
||||
</div>
|
||||
<div class="dropzone__content">
|
||||
<span class="dropzone__headline">${this.buttonText || 'Select files'}</span>
|
||||
<span class="dropzone__subline">
|
||||
Drag and drop files here or
|
||||
<button
|
||||
type="button"
|
||||
class="dropzone__browse"
|
||||
@click=${this.handleBrowseClick}
|
||||
?disabled=${this.disabled}
|
||||
>
|
||||
browse
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropzone__meta">
|
||||
${metaEntries.map((entry) => html`<span>${entry}</span>`)}
|
||||
</div>
|
||||
${this.renderFileList()}
|
||||
</div>
|
||||
${this.validationMessage
|
||||
? html`<div class="validation-message" aria-live="polite">${this.validationMessage}</div>`
|
||||
: html``}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFileList(): TemplateResult {
|
||||
if (this.value.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="file-list">
|
||||
<div class="file-list__header">
|
||||
<span>${this.value.length} file${this.value.length === 1 ? '' : 's'} selected</span>
|
||||
${this.value.length > 0
|
||||
? html`<button type="button" class="file-list__clear" @click=${this.handleClearAll}>Clear ${this.value.length > 1 ? 'all' : ''}</button>`
|
||||
: html``}
|
||||
</div>
|
||||
<div class="file-list__items">
|
||||
${this.value.map((file) => this.renderFileRow(file))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFileRow(file: File): TemplateResult {
|
||||
const fileType = this.getFileType(file);
|
||||
const previewUrl = this.canShowPreview(file) ? this.getPreviewUrl(file) : null;
|
||||
|
||||
return html`
|
||||
<div class="file-row ${fileType}-file">
|
||||
<div class="file-thumb" aria-hidden="true">
|
||||
${previewUrl
|
||||
? html`<img class="thumb-image" src=${previewUrl} alt=${`Preview of ${file.name}`}>`
|
||||
: html`<dees-icon icon=${this.getFileIcon(file)}></dees-icon>`}
|
||||
</div>
|
||||
<div class="file-meta">
|
||||
<div class="file-name" title=${file.name}>${file.name}</div>
|
||||
<div class="file-details">
|
||||
<span class="file-size">${this.formatFileSize(file.size)}</span>
|
||||
${fileType !== 'file' ? html`<span class="file-type">${fileType}</span>` : html``}
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
@click=${() => this.removeFile(file)}
|
||||
aria-label=${`Remove ${file.name}`}
|
||||
>
|
||||
<dees-icon icon="lucide:X"></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleFileInputChange = async (event: Event) => {
|
||||
this.isLoading = false;
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = Array.from(target.files ?? []);
|
||||
if (files.length > 0) {
|
||||
await this.addFiles(files);
|
||||
}
|
||||
target.value = '';
|
||||
};
|
||||
|
||||
private handleDropzoneClick = (event: MouseEvent) => {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
// Don't open file selector if clicking on the browse button or file list
|
||||
if ((event.target as HTMLElement).closest('.dropzone__browse, .file-list')) {
|
||||
return;
|
||||
}
|
||||
this.openFileSelector();
|
||||
};
|
||||
|
||||
private handleBrowseClick = (event: MouseEvent) => {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation(); // Stop propagation to prevent double trigger
|
||||
this.openFileSelector();
|
||||
};
|
||||
|
||||
private handleDropzoneKeydown = (event: KeyboardEvent) => {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.openFileSelector();
|
||||
}
|
||||
};
|
||||
|
||||
private handleClearAll = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
this.clearAll();
|
||||
};
|
||||
|
||||
private handleDragEvent = async (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'dragenter' || event.type === 'dragover') {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
this.state = 'dragOver';
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'dragleave') {
|
||||
if (!this.dropArea) {
|
||||
this.state = 'idle';
|
||||
return;
|
||||
}
|
||||
const rect = this.dropArea.getBoundingClientRect();
|
||||
const { clientX = 0, clientY = 0 } = event;
|
||||
if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) {
|
||||
this.state = 'idle';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'drop') {
|
||||
this.state = 'idle';
|
||||
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (files.length > 0) {
|
||||
await this.addFiles(files);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private attachDropListeners(): void {
|
||||
if (!this.dropArea) {
|
||||
return;
|
||||
}
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
|
||||
this.dropArea!.addEventListener(eventName, this.handleDragEvent);
|
||||
});
|
||||
}
|
||||
|
||||
private detachDropListeners(): void {
|
||||
if (!this.dropArea) {
|
||||
return;
|
||||
}
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
|
||||
this.dropArea!.removeEventListener(eventName, this.handleDragEvent);
|
||||
});
|
||||
}
|
||||
|
||||
private rebindInteractiveElements(): void {
|
||||
const newDropArea = this.shadowRoot?.querySelector('.dropzone') as HTMLElement | null;
|
||||
|
||||
if (newDropArea !== this.dropArea) {
|
||||
this.detachDropListeners();
|
||||
this.dropArea = newDropArea;
|
||||
this.attachDropListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public formatFileSize(bytes: number): string {
|
||||
const units = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const size = bytes / Math.pow(1024, exponent);
|
||||
return `${Math.round(size * 100) / 100} ${units[exponent]}`;
|
||||
}
|
||||
|
||||
public getFileType(file: File): string {
|
||||
const type = file.type.toLowerCase();
|
||||
if (type.startsWith('image/')) return 'image';
|
||||
if (type === 'application/pdf') return 'pdf';
|
||||
if (type.includes('word') || type.includes('document')) return 'doc';
|
||||
if (type.includes('sheet') || type.includes('excel')) return 'spreadsheet';
|
||||
if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation';
|
||||
if (type.startsWith('video/')) return 'video';
|
||||
if (type.startsWith('audio/')) return 'audio';
|
||||
if (type.includes('zip') || type.includes('compressed')) return 'archive';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
public getFileIcon(file: File): string {
|
||||
const fileType = this.getFileType(file);
|
||||
const iconMap: Record<string, string> = {
|
||||
image: 'lucide:FileImage',
|
||||
pdf: 'lucide:FileText',
|
||||
doc: 'lucide:FileText',
|
||||
spreadsheet: 'lucide:FileSpreadsheet',
|
||||
presentation: 'lucide:FileBarChart',
|
||||
video: 'lucide:FileVideo',
|
||||
audio: 'lucide:FileAudio',
|
||||
archive: 'lucide:FileArchive',
|
||||
file: 'lucide:File',
|
||||
};
|
||||
return iconMap[fileType] ?? 'lucide:File';
|
||||
}
|
||||
|
||||
public canShowPreview(file: File): boolean {
|
||||
return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024;
|
||||
}
|
||||
|
||||
private validateFile(file: File): boolean {
|
||||
if (this.maxSize > 0 && file.size > this.maxSize) {
|
||||
this.validationMessage = `File "${file.name}" exceeds the maximum size of ${this.formatFileSize(this.maxSize)}`;
|
||||
this.validationState = 'invalid';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.accept) {
|
||||
const acceptedTypes = this.accept
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
if (acceptedTypes.length > 0) {
|
||||
let isAccepted = false;
|
||||
for (const acceptType of acceptedTypes) {
|
||||
if (acceptType.startsWith('.')) {
|
||||
if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) {
|
||||
isAccepted = true;
|
||||
break;
|
||||
}
|
||||
} else if (acceptType.endsWith('/*')) {
|
||||
const prefix = acceptType.slice(0, -2);
|
||||
if (file.type.startsWith(prefix)) {
|
||||
isAccepted = true;
|
||||
break;
|
||||
}
|
||||
} else if (file.type === acceptType) {
|
||||
isAccepted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAccepted) {
|
||||
this.validationMessage = `File type not accepted. Allowed: ${acceptedTypes.join(', ')}`;
|
||||
this.validationState = 'invalid';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getPreviewUrl(file: File): string {
|
||||
let url = this.previewUrlMap.get(file);
|
||||
if (!url) {
|
||||
url = URL.createObjectURL(file);
|
||||
this.previewUrlMap.set(file, url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private releasePreview(file: File): void {
|
||||
const url = this.previewUrlMap.get(file);
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
this.previewUrlMap.delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
private getAcceptedSummary(): string | null {
|
||||
if (!this.accept) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatted = Array.from(
|
||||
new Set(
|
||||
this.accept
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0)
|
||||
.map((token) => this.formatAcceptToken(token))
|
||||
)
|
||||
).filter(Boolean);
|
||||
|
||||
if (formatted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (formatted.length === 1) {
|
||||
return formatted[0];
|
||||
}
|
||||
|
||||
if (formatted.length === 2) {
|
||||
return `${formatted[0]}, ${formatted[1]}`;
|
||||
}
|
||||
|
||||
return `${formatted.slice(0, 2).join(', ')}…`;
|
||||
}
|
||||
|
||||
private formatAcceptToken(token: string): string {
|
||||
if (token === '*/*') {
|
||||
return 'All files';
|
||||
}
|
||||
|
||||
if (token.endsWith('/*')) {
|
||||
const family = token.split('/')[0];
|
||||
if (!family) {
|
||||
return 'All files';
|
||||
}
|
||||
return `${family.charAt(0).toUpperCase()}${family.slice(1)} files`;
|
||||
}
|
||||
|
||||
if (token.startsWith('.')) {
|
||||
return token.slice(1).toUpperCase();
|
||||
}
|
||||
|
||||
if (token.includes('pdf')) return 'PDF';
|
||||
if (token.includes('zip')) return 'ZIP';
|
||||
if (token.includes('json')) return 'JSON';
|
||||
if (token.includes('msword')) return 'DOC';
|
||||
if (token.includes('wordprocessingml')) return 'DOCX';
|
||||
if (token.includes('excel')) return 'XLS';
|
||||
if (token.includes('presentation')) return 'PPT';
|
||||
|
||||
const segments = token.split('/');
|
||||
const lastSegment = segments.pop() ?? token;
|
||||
return lastSegment.toUpperCase();
|
||||
}
|
||||
|
||||
private attachLifecycleListeners(): void {
|
||||
this.rebindInteractiveElements();
|
||||
}
|
||||
|
||||
public firstUpdated(changedProperties: Map<string, unknown>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.attachLifecycleListeners();
|
||||
}
|
||||
|
||||
public updated(changedProperties: Map<string, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('value')) {
|
||||
void this.validate();
|
||||
}
|
||||
this.rebindInteractiveElements();
|
||||
}
|
||||
|
||||
public async disconnectedCallback(): Promise<void> {
|
||||
this.detachDropListeners();
|
||||
this.value.forEach((file) => this.releasePreview(file));
|
||||
this.previewUrlMap = new WeakMap();
|
||||
await super.disconnectedCallback();
|
||||
}
|
||||
|
||||
public async openFileSelector() {
|
||||
if (this.disabled || this.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
// Ensure we have the latest reference to the file input
|
||||
const inputFile = this.shadowRoot?.querySelector('.file-input') as HTMLInputElement | null;
|
||||
|
||||
if (!inputFile) {
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
setTimeout(() => {
|
||||
if (!inputFile.files || inputFile.files.length === 0) {
|
||||
this.isLoading = false;
|
||||
}
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
|
||||
// Click the input to open file selector
|
||||
inputFile.click();
|
||||
}
|
||||
|
||||
public removeFile(file: File) {
|
||||
const index = this.value.indexOf(file);
|
||||
if (index > -1) {
|
||||
this.releasePreview(file);
|
||||
this.value.splice(index, 1);
|
||||
this.requestUpdate('value');
|
||||
void this.validate();
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
public clearAll() {
|
||||
const existingFiles = [...this.value];
|
||||
this.value = [];
|
||||
existingFiles.forEach((file) => this.releasePreview(file));
|
||||
this.requestUpdate('value');
|
||||
void this.validate();
|
||||
this.changeSubject.next(this);
|
||||
this.buttonText = 'Select files';
|
||||
}
|
||||
|
||||
public async updateValue(eventArg: Event) {
|
||||
const target = eventArg.target as HTMLInputElement;
|
||||
this.value = Array.from(target.files ?? []);
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public setValue(value: File[]): void {
|
||||
this.value.forEach((file) => this.releasePreview(file));
|
||||
this.value = value;
|
||||
if (value.length > 0) {
|
||||
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
|
||||
} else {
|
||||
this.buttonText = 'Select files';
|
||||
}
|
||||
this.requestUpdate('value');
|
||||
void this.validate();
|
||||
}
|
||||
|
||||
public getValue(): File[] {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
private async addFiles(files: File[]) {
|
||||
const filesToAdd: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (this.validateFile(file)) {
|
||||
filesToAdd.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToAdd.length === 0) {
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.maxFiles > 0) {
|
||||
const totalFiles = this.value.length + filesToAdd.length;
|
||||
if (totalFiles > this.maxFiles) {
|
||||
const allowedCount = this.maxFiles - this.value.length;
|
||||
if (allowedCount <= 0) {
|
||||
this.validationMessage = `Maximum ${this.maxFiles} files allowed`;
|
||||
this.validationState = 'invalid';
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
filesToAdd.splice(allowedCount);
|
||||
this.validationMessage = `Only ${allowedCount} more file(s) can be added`;
|
||||
this.validationState = 'warn';
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.multiple && filesToAdd.length > 0) {
|
||||
this.value.forEach((file) => this.releasePreview(file));
|
||||
this.value = [filesToAdd[0]];
|
||||
} else {
|
||||
this.value.push(...filesToAdd);
|
||||
}
|
||||
|
||||
this.validationMessage = '';
|
||||
this.validationState = null;
|
||||
this.requestUpdate('value');
|
||||
await this.validate();
|
||||
this.changeSubject.next(this);
|
||||
this.isLoading = false;
|
||||
|
||||
if (this.value.length > 0) {
|
||||
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
|
||||
} else {
|
||||
this.buttonText = 'Select files';
|
||||
}
|
||||
}
|
||||
|
||||
public async validate(): Promise<boolean> {
|
||||
this.validationMessage = '';
|
||||
|
||||
if (this.required && this.value.length === 0) {
|
||||
this.validationState = 'invalid';
|
||||
this.validationMessage = 'Please select at least one file';
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const file of this.value) {
|
||||
if (!this.validateFile(file)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.validationState = this.value.length > 0 ? 'valid' : null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
159
ts_web/elements/dees-input-fileupload/demo.ts
Normal file
159
ts_web/elements/dees-input-fileupload/demo.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { css, cssManager, html } from '@design.estate/dees-element';
|
||||
import './component.js';
|
||||
import '../dees-panel.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
padding: 24px;
|
||||
max-width: 1160px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.demo-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.demo-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.demo-note {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(215 20% 26%)')};
|
||||
background: ${cssManager.bdTheme('hsl(213 100% 97%)', 'hsl(215 20% 12%)')};
|
||||
color: ${cssManager.bdTheme('hsl(215 25% 32%)', 'hsl(215 20% 82%)')};
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.demo-note strong {
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 45%)', 'hsl(213 93% 68%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-shell">
|
||||
<dees-panel
|
||||
.title=${'Modern file uploader'}
|
||||
.subtitle=${'Shadcn-inspired layout with drag & drop, previews and validation'}
|
||||
>
|
||||
<div class="demo-grid demo-grid--two">
|
||||
<div class="demo-stack">
|
||||
<dees-input-fileupload
|
||||
.label=${'Attachments'}
|
||||
.description=${'Upload supporting documents for your request'}
|
||||
.accept=${'image/*,.pdf,.zip'}
|
||||
.maxSize=${10 * 1024 * 1024}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Brand assets'}
|
||||
.description=${'Upload high-resolution imagery (JPG/PNG)'}
|
||||
.accept=${'image/jpeg,image/png'}
|
||||
.multiple=${false}
|
||||
.maxSize=${5 * 1024 * 1024}
|
||||
.buttonText=${'Select cover image'}
|
||||
></dees-input-fileupload>
|
||||
</div>
|
||||
|
||||
<div class="demo-stack">
|
||||
<dees-input-fileupload
|
||||
.label=${'Audio uploads'}
|
||||
.description=${'Share podcast drafts (MP3/WAV, max 25MB each)'}
|
||||
.accept=${'audio/*'}
|
||||
.maxSize=${25 * 1024 * 1024}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Disabled example'}
|
||||
.description=${'Uploader is disabled while moderation is pending'}
|
||||
.disabled=${true}
|
||||
></dees-input-fileupload>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel
|
||||
.title=${'Form integration'}
|
||||
.subtitle=${'Combine file uploads with the rest of the DEES form ecosystem'}
|
||||
>
|
||||
<div class="demo-grid">
|
||||
<dees-form>
|
||||
<div class="demo-stack">
|
||||
<dees-input-text
|
||||
.label=${'Project name'}
|
||||
.description=${'How should we refer to this project internally?'}
|
||||
.required=${true}
|
||||
.key=${'projectName'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Contact email'}
|
||||
.inputType=${'email'}
|
||||
.required=${true}
|
||||
.key=${'contactEmail'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Statement of work'}
|
||||
.description=${'Upload a signed statement of work (PDF, max 15MB)'}
|
||||
.required=${true}
|
||||
.accept=${'application/pdf'}
|
||||
.maxSize=${15 * 1024 * 1024}
|
||||
.multiple=${false}
|
||||
.key=${'sow'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Creative references'}
|
||||
.description=${'Optional. Upload up to five visual references'}
|
||||
.accept=${'image/*'}
|
||||
.maxFiles=${5}
|
||||
.maxSize=${8 * 1024 * 1024}
|
||||
.key=${'references'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Notes'}
|
||||
.description=${'Add optional context for reviewers'}
|
||||
.inputType=${'textarea'}
|
||||
.key=${'notes'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-form-submit .text=${'Submit briefing'}></dees-form-submit>
|
||||
</div>
|
||||
</dees-form>
|
||||
|
||||
<div class="demo-note">
|
||||
<strong>Good to know:</strong>
|
||||
<ul>
|
||||
<li>Drag & drop highlights the dropzone and supports keyboard activation.</li>
|
||||
<li>Accepted file types are summarised automatically from the <code>accept</code> attribute.</li>
|
||||
<li>Image uploads show live previews generated via <code>URL.createObjectURL</code>.</li>
|
||||
<li>File size and file-count limits surface inline validation messages.</li>
|
||||
<li>The component stays compatible with <code>dees-form</code> value accessors.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
2
ts_web/elements/dees-input-fileupload/index.ts
Normal file
2
ts_web/elements/dees-input-fileupload/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './component.js';
|
||||
export { fileuploadStyles } from './styles.js';
|
||||
313
ts_web/elements/dees-input-fileupload/styles.ts
Normal file
313
ts_web/elements/dees-input-fileupload/styles.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base.js';
|
||||
|
||||
export const fileuploadStyles = [
|
||||
cssManager.defaultStyles,
|
||||
...DeesInputBase.baseStyles,
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1.5px dashed ${cssManager.bdTheme('hsl(215 16% 80%)', 'hsl(217 20% 25%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')};
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dropzone:focus-visible {
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')},
|
||||
0 0 0 4px ${cssManager.bdTheme('hsl(217 91% 60% / 0.5)', 'hsl(213 93% 68% / 0.4)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
}
|
||||
|
||||
.dropzone--active {
|
||||
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
box-shadow: 0 12px 32px ${cssManager.bdTheme('rgba(15, 23, 42, 0.12)', 'rgba(0, 0, 0, 0.35)')};
|
||||
background: ${cssManager.bdTheme('hsl(217 91% 60% / 0.06)', 'hsl(213 93% 68% / 0.12)')};
|
||||
}
|
||||
|
||||
.dropzone--has-files {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 99%)', 'hsl(215 20% 11%)')};
|
||||
}
|
||||
|
||||
.dropzone--disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dropzone__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dropzone__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
background: ${cssManager.bdTheme('hsl(217 91% 60% / 0.12)', 'hsl(213 93% 68% / 0.12)')};
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropzone__icon dees-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.dropzone__loader {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid ${cssManager.bdTheme('rgba(15, 23, 42, 0.15)', 'rgba(255, 255, 255, 0.15)')};
|
||||
border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
animation: loader-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.dropzone__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dropzone__headline {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')};
|
||||
}
|
||||
|
||||
.dropzone__subline {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
|
||||
}
|
||||
|
||||
.dropzone__browse {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dropzone__browse:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dropzone__browse:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dropzone__meta {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 50%)', 'hsl(215 16% 72%)')};
|
||||
}
|
||||
|
||||
.dropzone__meta span {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: ${cssManager.bdTheme('hsl(217 91% 95%)', 'hsl(213 93% 18%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(213 93% 24%)')};
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(213 93% 24%)')};
|
||||
}
|
||||
|
||||
.file-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 68%)')};
|
||||
}
|
||||
|
||||
.file-list__clear {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.file-list__clear:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.file-list__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.5)', 'hsl(215 20% 16% / 0.5)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(213 27% 92%)', 'hsl(217 25% 26%)')};
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.8)', 'hsl(215 20% 16% / 0.8)')};
|
||||
}
|
||||
|
||||
.file-thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 32% 18%)')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-thumb dees-icon {
|
||||
font-size: 18px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 70%)')};
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.thumb-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.file-type {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 32% 28%)')};
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease, transform 0.15s ease, color 0.15s ease;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 52%)', 'hsl(215 16% 68%)')};
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 72% 50% / 0.08)', 'hsl(0 62% 32% / 0.15)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 72% 46%)', 'hsl(0 70% 70%)')};
|
||||
}
|
||||
|
||||
.remove-button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.remove-button dees-icon {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('hsl(0 72% 40%)', 'hsl(0 70% 68%)')};
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@keyframes loader-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
275
ts_web/elements/dees-input-list.demo.ts
Normal file
275
ts_web/elements/dees-input-list.demo.ts
Normal 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>
|
||||
`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user