Compare commits
199 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 | |||
| 1dcaccdb6d | |||
| 35eb410051 | |||
| 10c43ecd59 | |||
| 9df4a09414 | |||
| 7cbc941407 | |||
| b31f306106 | |||
| 1dbbac450c | |||
| b5a2bd7436 | |||
| 931a760ee1 | |||
| 27414e0284 | |||
| d63bc762d0 | |||
| 505e40a57f | |||
| d1ea10d8c6 | |||
| 1038759d8b | |||
| ab9b545c9a | |||
| e1329ecd7a | |||
| 167df241b7 | |||
| b41e9f31e7 | |||
| 02f25aa02e | |||
| 312fc4ba90 | |||
| 56d7b44b01 | |||
| f72c9fad3a | |||
| d48fd667a2 | |||
| 979877b3b0 | |||
| 342bd7d7c2 | |||
| 4d42911198 | |||
| 3ea7186d6c | |||
| 09e35d0245 | |||
| 4a26307e1b | |||
| 113c013ea9 | |||
| 0571d5bf4b | |||
| 5f86fdba72 | |||
| 474385a939 | |||
| 71d64fccb8 | |||
| e9541da8ff | |||
| 68b4e9ec8e | |||
| 856d354b5a | |||
| 89a4a15e78 | |||
| fca3638f7f | |||
| 90fc8bed35 | |||
| bd223f77d0 | |||
| 1041814823 | |||
| 366544befc | |||
| 3b93bd63a7 | |||
| ca525ce7e3 | |||
| 83f153f654 | |||
| 75637c7793 | |||
| e0a125c9bd | |||
| 4b2178cedd | |||
| 08a4c361fa | |||
| 35a648d450 | |||
| 1c76ade150 | |||
| 8b02c5aea3 | |||
| c82c407350 | |||
| 169f74aa2e | |||
| e4a042907a | |||
| 7ce282c500 | |||
| 302777feff | |||
| cdcd4f79c8 | |||
| f2e6342a61 | |||
| 0f02e7d00f | |||
| a1079cbbdd | |||
| 58af08cb0d | |||
| 6626726029 | |||
| f3afbb2e48 | |||
| fbd52ee9a5 | |||
| 3284d91c2a | |||
| 22e6b74c4f | |||
| 4de835474b | |||
| 024d8af40d | |||
| 808b74fa17 | |||
| 202881ef1a | |||
| 7de3d451ad |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,7 +3,6 @@
|
|||||||
# artifacts
|
# artifacts
|
||||||
coverage/
|
coverage/
|
||||||
public/
|
public/
|
||||||
pages/
|
|
||||||
|
|
||||||
# installs
|
# installs
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
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"
|
||||||
244
changelog.md
244
changelog.md
@@ -1,5 +1,247 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- Bump @design.estate/dees-wcctools from ^1.0.98 to ^1.0.101
|
||||||
|
- Bump @tiptap packages from 2.22.3 to 2.23.0
|
||||||
|
- Bump lucide from ^0.522.0 to ^0.523.0
|
||||||
|
- Bump @git.zone/tsbundle from ^2.4.0 to ^2.5.1 and tswatch from ^2.0.37 to ^2.1.2
|
||||||
|
- Add 'pointer-events: none' to dees-windowlayer CSS to improve overlay behavior
|
||||||
|
|
||||||
|
## 2025-06-22 - 1.9.0 - feat(form-inputs)
|
||||||
|
Improve form input consistency and auto spacing across inputs and buttons
|
||||||
|
|
||||||
|
- Add an 'insideForm' property to dees-button for auto-detection and proper margin adjustment in forms.
|
||||||
|
- Update dees-input-radio to include a 'name' property so that radio buttons in the same group are mutually exclusive.
|
||||||
|
- Enhance dees-form to group radio inputs properly when collecting form data.
|
||||||
|
- Revise readme.hints.md and readme.plan.md to document changes and provide guidance for dees-input-radio.
|
||||||
|
- Update demos for dees-button and dees-form to showcase correct spacing in vertical and horizontal layouts.
|
||||||
|
|
||||||
|
## 2025-06-20 - 1.8.20 - fix(deps)
|
||||||
|
Update dependency versions: bump @design.estate/dees-domtools from ^2.1.1 to ^2.3.3, @design.estate/dees-element from ^2.0.42 to ^2.0.44, lucide from ^0.515.0 to ^0.518.0, and @git.zone/tsbundle from ^2.0.15 to ^2.4.0
|
||||||
|
|
||||||
|
- Upgrade @design.estate/dees-domtools from ^2.1.1 to ^2.3.3
|
||||||
|
- Upgrade @design.estate/dees-element from ^2.0.42 to ^2.0.44
|
||||||
|
- Upgrade lucide from ^0.515.0 to ^0.518.0
|
||||||
|
- Upgrade @git.zone/tsbundle from ^2.0.15 to ^2.4.0
|
||||||
|
|
||||||
## 2025-06-10 - 1.8.1 - fix(dees-statsgrid)
|
## 2025-06-10 - 1.8.1 - fix(dees-statsgrid)
|
||||||
Adjust stats grid styling for better alignment and improved visualizations in gauge and trend tiles.
|
Adjust stats grid styling for better alignment and improved visualizations in gauge and trend tiles.
|
||||||
|
|
||||||
@@ -26,7 +268,7 @@ Add dees-searchbar component with live search and filter demo
|
|||||||
## 2025-04-22 - 1.6.0 - feat(documentation/dees-heading)
|
## 2025-04-22 - 1.6.0 - feat(documentation/dees-heading)
|
||||||
Add codex documentation overview and dees-heading component demo
|
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
|
- 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
|
- 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.
|
|
||||||
46
package.json
46
package.json
@@ -1,46 +1,54 @@
|
|||||||
{
|
{
|
||||||
"name": "@design.estate/dees-catalog",
|
"name": "@design.estate/dees-catalog",
|
||||||
"version": "1.8.18",
|
"version": "1.12.5",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||||
"main": "dist_ts_web/index.js",
|
"main": "dist_ts_web/index.js",
|
||||||
"typings": "dist_ts_web/index.d.ts",
|
"typings": "dist_ts_web/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "tstest test/ --web",
|
"test": "tstest test/ --web --verbose --timeout 30 --logfile",
|
||||||
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
|
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production --bundler esbuild",
|
||||||
"watch": "tswatch element",
|
"watch": "tswatch element",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc",
|
||||||
|
"postinstall": "node scripts/update-monaco-version.cjs"
|
||||||
},
|
},
|
||||||
"author": "Lossless GmbH",
|
"author": "Lossless GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@design.estate/dees-domtools": "^2.1.1",
|
"@design.estate/dees-domtools": "^2.3.3",
|
||||||
"@design.estate/dees-element": "^2.0.42",
|
"@design.estate/dees-element": "^2.1.2",
|
||||||
"@design.estate/dees-wcctools": "^1.0.98",
|
"@design.estate/dees-wcctools": "^1.2.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
"@fortawesome/fontawesome-svg-core": "^7.0.1",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
"@fortawesome/free-brands-svg-icons": "^7.0.1",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
"@fortawesome/free-regular-svg-icons": "^7.0.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
"@fortawesome/free-solid-svg-icons": "^7.0.1",
|
||||||
"@push.rocks/smarti18n": "^1.0.4",
|
"@push.rocks/smarti18n": "^1.0.4",
|
||||||
"@push.rocks/smartpromise": "^4.2.0",
|
"@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",
|
||||||
|
"@tiptap/extension-typography": "^2.23.0",
|
||||||
|
"@tiptap/extension-underline": "^2.23.0",
|
||||||
|
"@tiptap/starter-kit": "^2.23.0",
|
||||||
"@tsclass/tsclass": "^9.2.0",
|
"@tsclass/tsclass": "^9.2.0",
|
||||||
"@webcontainer/api": "1.2.0",
|
"@webcontainer/api": "1.2.0",
|
||||||
"apexcharts": "^4.7.0",
|
"apexcharts": "^5.3.5",
|
||||||
"highlight.js": "11.11.1",
|
"highlight.js": "11.11.1",
|
||||||
"ibantools": "^4.5.1",
|
"ibantools": "^4.5.1",
|
||||||
"lucide": "^0.515.0",
|
"lit": "^3.3.1",
|
||||||
"monaco-editor": "^0.52.2",
|
"lucide": "^0.544.0",
|
||||||
|
"monaco-editor": "0.52.2",
|
||||||
"pdfjs-dist": "^4.10.38",
|
"pdfjs-dist": "^4.10.38",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
"xterm-addon-fit": "^0.8.0"
|
"xterm-addon-fit": "^0.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.6.4",
|
"@git.zone/tsbuild": "^2.6.8",
|
||||||
"@git.zone/tsbundle": "^2.0.15",
|
"@git.zone/tsbundle": "^2.5.1",
|
||||||
"@git.zone/tstest": "^2.3.1",
|
"@git.zone/tstest": "^2.3.8",
|
||||||
"@git.zone/tswatch": "^2.0.37",
|
"@git.zone/tswatch": "^2.2.1",
|
||||||
"@push.rocks/projectinfo": "^5.0.2",
|
"@push.rocks/projectinfo": "^5.0.2",
|
||||||
"@push.rocks/tapbundle": "^6.0.3",
|
"@push.rocks/tapbundle": "^6.0.3",
|
||||||
"@types/node": "^22.0.0"
|
"@types/node": "^22.0.0"
|
||||||
|
|||||||
4637
pnpm-lock.yaml
generated
4637
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
|
|
||||||
550
readme.hints.md
550
readme.hints.md
@@ -1,5 +1,5 @@
|
|||||||
!!! Please pay attention to the following points when writing the readme: !!!
|
!!! Please pay attention to the following points when writing the readme: !!!
|
||||||
* Give a short rundown of components and a few points abputspecific features on each.
|
* Give a short rundown of components and a few points abput specific features on each.
|
||||||
* Try to list all components in a summary.
|
* Try to list all components in a summary.
|
||||||
* Then list all components with a short description.
|
* Then list all components with a short description.
|
||||||
|
|
||||||
@@ -58,4 +58,550 @@
|
|||||||
- View mode selectors
|
- View mode selectors
|
||||||
- Action grouping
|
- Action grouping
|
||||||
- Navigation options
|
- Navigation options
|
||||||
- Filter controls
|
- Filter controls
|
||||||
|
|
||||||
|
## Form Components
|
||||||
|
|
||||||
|
### dees-input-radio
|
||||||
|
- Radio button component with proper group behavior
|
||||||
|
- Properties:
|
||||||
|
- `name`: Group name for mutually exclusive selection
|
||||||
|
- `key`: Unique identifier for the radio option
|
||||||
|
- `value`: Boolean indicating selection state
|
||||||
|
- `label`: Display label
|
||||||
|
- Features:
|
||||||
|
- Automatic group management (radios with same name are mutually exclusive)
|
||||||
|
- Cannot be deselected by clicking (proper radio behavior)
|
||||||
|
- Form integration: Radio groups are collected by name, value is the selected radio's key
|
||||||
|
- Works both inside and outside forms
|
||||||
|
- Supports disabled state
|
||||||
|
- Fixed: Radio buttons now properly deselect others in the group on first click
|
||||||
|
- Note: When using in forms, set both `name` (for grouping) and `key` (for the value)
|
||||||
|
|
||||||
|
## WYSIWYG Editor Architecture
|
||||||
|
|
||||||
|
### Recent Refactoring (2025-06-24)
|
||||||
|
|
||||||
|
The WYSIWYG editor has been refactored to improve maintainability and separation of concerns:
|
||||||
|
|
||||||
|
#### New Handler Classes
|
||||||
|
|
||||||
|
1. **WysiwygBlockOperations** (`wysiwyg.blockoperations.ts`)
|
||||||
|
- Manages all block-related operations
|
||||||
|
- Methods: createBlock, insertBlockAfter, removeBlock, findBlock, focusBlock, etc.
|
||||||
|
- Centralized block manipulation logic
|
||||||
|
|
||||||
|
2. **WysiwygInputHandler** (`wysiwyg.inputhandler.ts`)
|
||||||
|
- Handles all input events for blocks
|
||||||
|
- Manages block content updates based on type
|
||||||
|
- Detects block type transformations
|
||||||
|
- Handles slash commands
|
||||||
|
- Manages auto-save with debouncing
|
||||||
|
|
||||||
|
3. **WysiwygKeyboardHandler** (`wysiwyg.keyboardhandler.ts`)
|
||||||
|
- Handles all keyboard events
|
||||||
|
- Manages formatting shortcuts (Cmd/Ctrl + B/I/U/K)
|
||||||
|
- Handles special keys: Tab, Enter, Backspace
|
||||||
|
- Manages slash menu navigation
|
||||||
|
|
||||||
|
4. **WysiwygDragDropHandler** (`wysiwyg.dragdrophandler.ts`)
|
||||||
|
- Manages drag and drop operations
|
||||||
|
- Tracks drag state
|
||||||
|
- Handles visual feedback during drag
|
||||||
|
- Manages block reordering
|
||||||
|
|
||||||
|
5. **WysiwygModalManager** (`wysiwyg.modalmanager.ts`)
|
||||||
|
- Static methods for showing modals
|
||||||
|
- Language selection for code blocks
|
||||||
|
- Block settings modal
|
||||||
|
- Reusable modal patterns
|
||||||
|
|
||||||
|
#### Main Component Updates
|
||||||
|
|
||||||
|
The main `DeesInputWysiwyg` component now:
|
||||||
|
- Instantiates handler classes in `connectedCallback`
|
||||||
|
- Delegates complex operations to appropriate handlers
|
||||||
|
- Maintains cleaner, more focused code
|
||||||
|
- Better separation of concerns
|
||||||
|
|
||||||
|
#### Benefits
|
||||||
|
- Reduced main component size from 1100+ lines
|
||||||
|
- Each handler class is focused on a single responsibility
|
||||||
|
- Easier to test individual components
|
||||||
|
- Better code organization
|
||||||
|
- Improved maintainability
|
||||||
|
|
||||||
|
#### Fixed Issues
|
||||||
|
- Enter key no longer duplicates content in new blocks
|
||||||
|
- Removed problematic `setBlockContents()` method
|
||||||
|
- Content is now managed directly through DOM properties
|
||||||
|
- Better timing for block creation and focus
|
||||||
|
- Slash menu no longer disappears immediately on first "/" press
|
||||||
|
- Focus is properly maintained when slash menu opens
|
||||||
|
- Removed duplicate event handling methods from main component
|
||||||
|
- Simplified focus management throughout the editor
|
||||||
|
|
||||||
|
#### Additional Refactoring (2025-06-24 - Part 2)
|
||||||
|
- **Removed duplicate code**: handleBlockInput and handleBlockKeyDown methods removed from main component
|
||||||
|
- **Simplified focus management**: Removed complex lifecycle methods and timers
|
||||||
|
- **Fixed slash menu behavior**: Changed to click events and proper event prevention
|
||||||
|
- **dees-wysiwyg-block component**: Now uses static HTML rendering for better content control
|
||||||
|
- **Improved formatting preservation**: HTML formatting (bold, italic, etc.) properly preserved in all block types
|
||||||
|
|
||||||
|
#### Notes
|
||||||
|
- All input handling now goes through WysiwygInputHandler
|
||||||
|
- All keyboard handling goes through WysiwygKeyboardHandler
|
||||||
|
- The slash menu uses click events instead of mousedown for better UX
|
||||||
|
- Focus is maintained using requestAnimationFrame for better timing
|
||||||
|
- The refactoring maintains all existing functionality with improved reliability
|
||||||
|
|
||||||
|
### Global Menu Architecture (2025-06-24 - Part 3)
|
||||||
|
|
||||||
|
The slash menu and formatting menu have been refactored to render globally instead of inside the wysiwyg component. This fixes focus loss issues that were occurring when the menus were re-rendered with the component.
|
||||||
|
|
||||||
|
#### Key Components:
|
||||||
|
|
||||||
|
1. **DeesSlashMenu** (`dees-slash-menu.ts`)
|
||||||
|
- Singleton component that renders globally in the document body
|
||||||
|
- Accessed via `DeesSlashMenu.getInstance()`
|
||||||
|
- Manages its own visibility, position, and filtering
|
||||||
|
- Emits callbacks when items are selected
|
||||||
|
|
||||||
|
2. **DeesFormattingMenu** (`dees-formatting-menu.ts`)
|
||||||
|
- Singleton component that renders globally in the document body
|
||||||
|
- Accessed via `DeesFormattingMenu.getInstance()`
|
||||||
|
- Shows when text is selected
|
||||||
|
- Applies formatting commands via callback
|
||||||
|
|
||||||
|
3. **Integration in DeesInputWysiwyg**
|
||||||
|
- Stores singleton instances: `private slashMenu = DeesSlashMenu.getInstance()`
|
||||||
|
- Shows menus with absolute positioning
|
||||||
|
- Menus handle their own rendering and state management
|
||||||
|
|
||||||
|
#### Benefits:
|
||||||
|
- No focus loss when menus appear/disappear
|
||||||
|
- Better performance (menus don't re-render with component)
|
||||||
|
- Cleaner separation of concerns
|
||||||
|
- Menus persist across component updates
|
||||||
|
|
||||||
|
#### Usage:
|
||||||
|
```typescript
|
||||||
|
// Show slash menu
|
||||||
|
this.slashMenu.show(
|
||||||
|
{ x: cursorX, y: cursorY },
|
||||||
|
(type: string) => this.insertBlock(type)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show formatting menu
|
||||||
|
this.formattingMenu.show(
|
||||||
|
{ x: selectionX, y: selectionY },
|
||||||
|
(command: string) => this.applyFormat(command)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Previous Issues Fixed:
|
||||||
|
- Slash menu was disappearing immediately on first "/" press
|
||||||
|
- Focus was lost when menus appeared
|
||||||
|
- Text selection was not working properly
|
||||||
|
- Cursor position was lost after menu interactions
|
||||||
|
|
||||||
|
### Arrow Key Navigation (2025-06-24 - Part 4)
|
||||||
|
|
||||||
|
Enhanced arrow key handling for seamless navigation between blocks:
|
||||||
|
|
||||||
|
#### Features:
|
||||||
|
1. **ArrowUp at block start**: Automatically navigates to the end of the previous block
|
||||||
|
2. **ArrowDown at block end**: Automatically navigates to the beginning of the next block
|
||||||
|
3. **Smart detection**: Checks actual cursor position within the block content
|
||||||
|
4. **Slash menu integration**: When slash menu is open, arrow keys navigate menu items instead
|
||||||
|
5. **No focus loss**: Navigation maintains focus throughout
|
||||||
|
|
||||||
|
#### Implementation:
|
||||||
|
- Added `handleArrowUp()` and `handleArrowDown()` methods to `WysiwygKeyboardHandler`
|
||||||
|
- Smart cursor position detection for different block types (text, lists, etc.)
|
||||||
|
- Helper method `getLastTextNode()` for finding the last text position in complex HTML
|
||||||
|
- Prevents default behavior only when navigating between blocks
|
||||||
|
- Skips divider blocks during navigation
|
||||||
|
|
||||||
|
### Focus Management Improvements (2025-06-24 - Part 5)
|
||||||
|
|
||||||
|
Enhanced focus management to prevent focus loss during various operations:
|
||||||
|
|
||||||
|
#### Key Improvements:
|
||||||
|
|
||||||
|
1. **Formatting Without execCommand**:
|
||||||
|
- Replaced deprecated `document.execCommand` with modern DOM manipulation
|
||||||
|
- Proper selection restoration after formatting
|
||||||
|
- Async formatting operations to maintain focus
|
||||||
|
|
||||||
|
2. **Link Dialog**:
|
||||||
|
- Replaced `prompt()` with custom modal dialog
|
||||||
|
- Maintains focus context during async operations
|
||||||
|
- Auto-focuses input field in modal
|
||||||
|
|
||||||
|
3. **Robust Focus Methods**:
|
||||||
|
- Double `requestAnimationFrame` for DOM update timing
|
||||||
|
- Fallback focus attempts with microtasks
|
||||||
|
- Contenteditable attribute verification
|
||||||
|
|
||||||
|
4. **Cursor Positioning**:
|
||||||
|
- Enhanced `setCursorToStart/End` with edge case handling
|
||||||
|
- Zero-width space insertion for empty elements
|
||||||
|
- Recursive node traversal for complex HTML structures
|
||||||
|
|
||||||
|
5. **Async Keyboard Shortcuts**:
|
||||||
|
- Formatting shortcuts use Promise resolution
|
||||||
|
- Prevents focus loss during rapid keyboard input
|
||||||
|
|
||||||
|
#### Implementation Details:
|
||||||
|
- `focusWithCursor()` method now handles empty blocks and complex HTML
|
||||||
|
- `applyFormat()` is async and properly restores selection
|
||||||
|
- Link creation no longer uses blocking `prompt()` dialog
|
||||||
|
- All focus operations use proper timing with RAF and microtasks
|
||||||
|
|
||||||
|
### Focus Loss Prevention for Menus (2025-06-24 - Part 6)
|
||||||
|
|
||||||
|
Fixed focus loss issues when slash menu and formatting menu appear:
|
||||||
|
|
||||||
|
#### Key Fixes:
|
||||||
|
|
||||||
|
1. **Timeout Reduction**:
|
||||||
|
- Replaced 50ms setTimeout with requestAnimationFrame
|
||||||
|
- Immediate focus attempt before falling back to RAF
|
||||||
|
- Reduced delay when inserting blocks
|
||||||
|
|
||||||
|
2. **Menu Focus Prevention**:
|
||||||
|
- Added `tabindex="-1"` to prevent menus from taking focus
|
||||||
|
- Added focus event prevention on menus
|
||||||
|
- Menus now use mousedown prevention consistently
|
||||||
|
|
||||||
|
3. **Blur Event Handling**:
|
||||||
|
- Skip value updates when slash menu is visible
|
||||||
|
- Prevent auto-save during slash menu interaction
|
||||||
|
- Maintain focus after menu appears with RAF
|
||||||
|
|
||||||
|
4. **Block Focus Optimization**:
|
||||||
|
- Try immediate focus if block element exists
|
||||||
|
- Fall back to RAF only when necessary
|
||||||
|
- Consistent focus handling across all block types
|
||||||
|
|
||||||
|
#### Implementation:
|
||||||
|
- `handleBlockBlur()` checks if slash menu is visible before updating
|
||||||
|
- `scheduleAutoSave()` skips saving when slash menu is open
|
||||||
|
- Slash menu show adds RAF to restore focus if lost
|
||||||
|
- Reduced timing delays throughout the focus chain
|
||||||
|
|
||||||
|
### Slash Command Cleanup (2025-06-24 - Part 7)
|
||||||
|
|
||||||
|
Fixed the issue where "/" remained in the editor after selecting a block type:
|
||||||
|
|
||||||
|
#### The Fix:
|
||||||
|
|
||||||
|
1. **In `insertBlock()`**:
|
||||||
|
- Clear slash command before transforming block type
|
||||||
|
- Use regex `/^\/[^\s]*\s*/` to match slash + filter text
|
||||||
|
- Trim the result to ensure clean content
|
||||||
|
- Set content to empty for transformed blocks
|
||||||
|
|
||||||
|
2. **Improved Content Handling**:
|
||||||
|
- Wait for `updateComplete` before focusing
|
||||||
|
- Ensure lists start with empty content
|
||||||
|
- Consistent cleanup in both `insertBlock` and `closeSlashMenu`
|
||||||
|
|
||||||
|
3. **Edge Cases**:
|
||||||
|
- Handle filtered commands (e.g., "/hea" for heading)
|
||||||
|
- Clear content even with partial matches
|
||||||
|
- Proper content reset for all block types
|
||||||
|
|
||||||
|
Now when selecting a block type from the slash menu, the "/" and any filter text is properly removed before the block transformation occurs.
|
||||||
|
|
||||||
|
### Enhanced Enter Key and Block Settings (2025-06-24 - Part 8)
|
||||||
|
|
||||||
|
Added two major improvements to the wysiwyg editor:
|
||||||
|
|
||||||
|
#### 1. Smart Enter Key Behavior:
|
||||||
|
|
||||||
|
When pressing Enter, content after the cursor is now moved to the next block:
|
||||||
|
|
||||||
|
- **Content Splitting**: Uses Range API to extract content after cursor
|
||||||
|
- **HTML Preservation**: Maintains formatting when splitting blocks
|
||||||
|
- **Clean Split**: Current block keeps content before cursor, new block gets content after
|
||||||
|
- **Empty Block**: If cursor is at end, creates empty new block
|
||||||
|
|
||||||
|
Implementation in `WysiwygKeyboardHandler.handleEnter()`:
|
||||||
|
```typescript
|
||||||
|
// Clone the range to extract content after cursor
|
||||||
|
const afterRange = range.cloneRange();
|
||||||
|
afterRange.selectNodeContents(target);
|
||||||
|
afterRange.setStart(range.endContainer, range.endOffset);
|
||||||
|
|
||||||
|
// Extract content after cursor
|
||||||
|
const afterContent = afterRange.extractContents();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Block Type Changing via Settings Menu:
|
||||||
|
|
||||||
|
The block settings menu (three dots) now includes block type selection:
|
||||||
|
|
||||||
|
- **Type Selector Grid**: Shows all available block types with icons
|
||||||
|
- **Smart Metadata Handling**:
|
||||||
|
- Clears code language when changing from code block
|
||||||
|
- Clears list type when changing from list
|
||||||
|
- Prompts for language when changing to code block
|
||||||
|
- **Visual Feedback**: Currently selected type is highlighted
|
||||||
|
- **Instant Update**: Block transforms immediately on selection
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Works for all block types (not just code blocks)
|
||||||
|
- Preserves content during type transformation
|
||||||
|
- Handles special cases like code block language selection
|
||||||
|
- Modal closes automatically after selection
|
||||||
|
|
||||||
|
### Complete WYSIWYG Refactoring (2025-06-24 - Part 9)
|
||||||
|
|
||||||
|
Major architectural improvements to fix Enter key behavior and left arrow focus loss:
|
||||||
|
|
||||||
|
#### 1. Async Operation Architecture:
|
||||||
|
- All focus operations are now async with proper Promise handling
|
||||||
|
- `insertBlockAfter()` waits for component updates before focusing
|
||||||
|
- `focusBlock()` ensures DOM is ready with `updateComplete`
|
||||||
|
- Eliminated arbitrary timeouts in favor of proper async/await
|
||||||
|
|
||||||
|
#### 2. Enter Key Split Content Fix:
|
||||||
|
- Added `getSplitContent()` method to block component
|
||||||
|
- Properly extracts content before/after cursor using Range API
|
||||||
|
- Updates current block and creates new block atomically
|
||||||
|
- Content after cursor correctly moves to new block
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In block component
|
||||||
|
public getSplitContent(): { before: string; after: string } | null {
|
||||||
|
const beforeRange = range.cloneRange();
|
||||||
|
beforeRange.selectNodeContents(this.blockElement);
|
||||||
|
beforeRange.setEnd(range.startContainer, range.startOffset);
|
||||||
|
// ... extract and return split content
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Arrow Key Navigation:
|
||||||
|
- Added ArrowLeft/ArrowRight handlers for block boundaries
|
||||||
|
- Prevents focus loss when navigating between blocks
|
||||||
|
- Only intercepts at block boundaries, normal navigation otherwise
|
||||||
|
- All arrow key operations are async for proper timing
|
||||||
|
|
||||||
|
#### 4. Interface Architecture:
|
||||||
|
Created `wysiwyg.interfaces.ts` with proper typing:
|
||||||
|
- `IWysiwygComponent` - Main component contract
|
||||||
|
- `IBlockOperations` - Block operation methods
|
||||||
|
- `IWysiwygBlockComponent` - Block component interface
|
||||||
|
- `IBlockEventHandlers` - Event handler signatures
|
||||||
|
|
||||||
|
#### 5. Focus Management Improvements:
|
||||||
|
- Eliminated double RAF in favor of single async flow
|
||||||
|
- Focus operations wait for DOM updates via `updateComplete`
|
||||||
|
- Proper cursor positioning after all operations
|
||||||
|
- No more focus loss during navigation
|
||||||
|
|
||||||
|
#### Key Changes:
|
||||||
|
1. Keyboard handler methods are now async
|
||||||
|
2. Block operations return Promises
|
||||||
|
3. Enter key properly splits content at cursor
|
||||||
|
4. Arrow keys handle block navigation without focus loss
|
||||||
|
5. All timing is handled via proper async/await patterns
|
||||||
|
|
||||||
|
The refactoring eliminates race conditions and timing issues that were causing focus loss and content duplication problems.
|
||||||
|
|
||||||
|
### Programmatic Rendering Solution (2025-06-24 - Part 10)
|
||||||
|
|
||||||
|
Fixed persistent focus loss issue by implementing fully programmatic rendering:
|
||||||
|
|
||||||
|
#### The Problem:
|
||||||
|
- User would click in a block, type text, then press arrow keys and lose focus
|
||||||
|
- Root cause: Lit was re-rendering components when block content was mutated
|
||||||
|
- Even with shouldUpdate() preventing re-renders, parent re-evaluation caused focus loss
|
||||||
|
|
||||||
|
#### The Solution:
|
||||||
|
|
||||||
|
1. **Static Parent Rendering**:
|
||||||
|
- Parent component renders only once with empty editor content div
|
||||||
|
- All blocks are created and managed programmatically via DOM manipulation
|
||||||
|
- No Lit re-renders triggered by state changes
|
||||||
|
|
||||||
|
2. **Manual Block Management**:
|
||||||
|
- `renderBlocksProgrammatically()` creates all block elements manually
|
||||||
|
- `createBlockElement()` builds block wrapper with all event handlers
|
||||||
|
- `updateBlockElement()` replaces individual blocks when needed
|
||||||
|
- No reactive properties trigger parent re-renders
|
||||||
|
|
||||||
|
3. **Content Update Strategy**:
|
||||||
|
- During typing, content is NOT immediately synced to data model
|
||||||
|
- Auto-save delayed to 2 seconds to avoid interference
|
||||||
|
- Content synced from DOM only on blur or before save
|
||||||
|
- `syncAllBlockContent()` reads from DOM when needed
|
||||||
|
|
||||||
|
4. **Focus Preservation**:
|
||||||
|
- Block components prevent re-renders with `shouldUpdate()`
|
||||||
|
- Parent never re-renders after initial load
|
||||||
|
- Focus remains stable during all editing operations
|
||||||
|
- Arrow key navigation works without focus loss
|
||||||
|
|
||||||
|
5. **Implementation Details**:
|
||||||
|
```typescript
|
||||||
|
// Parent render method - static after first render
|
||||||
|
render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="editor-content" id="editor-content">
|
||||||
|
<!-- Blocks rendered programmatically -->
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All block operations use DOM manipulation
|
||||||
|
private renderBlocksProgrammatically() {
|
||||||
|
this.editorContentRef.innerHTML = '';
|
||||||
|
this.blocks.forEach(block => {
|
||||||
|
const blockWrapper = this.createBlockElement(block);
|
||||||
|
this.editorContentRef.appendChild(blockWrapper);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This approach completely eliminates focus loss by taking full control of the DOM and preventing any framework-induced re-renders during editing.
|
||||||
|
|
||||||
|
### Code Refactoring and Cleanup (2025-06-24 - Part 11)
|
||||||
|
|
||||||
|
Completed comprehensive refactoring to ensure clean, maintainable code with separated concerns:
|
||||||
|
|
||||||
|
#### Refactoring Changes:
|
||||||
|
|
||||||
|
1. **Drag and Drop Handler Cleanup**:
|
||||||
|
- Removed all `requestUpdate()` calls from drag handler
|
||||||
|
- Handler now only updates internal state
|
||||||
|
- Parent component handles DOM updates programmatically
|
||||||
|
- Simplified drag state management
|
||||||
|
|
||||||
|
2. **Unused Code Removal**:
|
||||||
|
- Removed duplicate `showBlockSettingsModal` method (using WysiwygModalManager)
|
||||||
|
- Removed duplicate `showLanguageSelectionModal` method
|
||||||
|
- Removed unused `renderBlock` method
|
||||||
|
- Cleaned up unused imports (WysiwygBlocks, ISlashMenuItem)
|
||||||
|
|
||||||
|
3. **Import Cleanup**:
|
||||||
|
- Removed unused type imports
|
||||||
|
- Organized imports logically
|
||||||
|
- Kept only necessary dependencies
|
||||||
|
|
||||||
|
4. **Separated Concerns**:
|
||||||
|
- Modal management in WysiwygModalManager
|
||||||
|
- Block operations in WysiwygBlockOperations
|
||||||
|
- Input handling in WysiwygInputHandler
|
||||||
|
- Keyboard handling in WysiwygKeyboardHandler
|
||||||
|
- Drag/drop in WysiwygDragDropHandler
|
||||||
|
- Each class has a single responsibility
|
||||||
|
|
||||||
|
5. **Programmatic DOM Management**:
|
||||||
|
- All DOM updates happen through explicit methods
|
||||||
|
- No reactive re-renders during user interaction
|
||||||
|
- Manual class management for drag states
|
||||||
|
- Direct DOM manipulation for performance
|
||||||
|
|
||||||
|
6. **Test Files Created**:
|
||||||
|
- `test-focus-fix.html` - Verifies focus management
|
||||||
|
- `test-drag-drop.html` - Tests drag and drop functionality
|
||||||
|
- `test-comprehensive.html` - Tests all features together
|
||||||
|
|
||||||
|
The refactoring follows the principles in instructions.md:
|
||||||
|
- Uses static templates with manual DOM operations
|
||||||
|
- Maintains separated concerns in different classes
|
||||||
|
- Results in clean, concise, and manageable code
|
||||||
|
|
||||||
|
## Z-Index Management System (2025-12-24)
|
||||||
|
|
||||||
|
A comprehensive z-index management system has been implemented to fix overlay stacking conflicts:
|
||||||
|
|
||||||
|
### The Problem:
|
||||||
|
- Modals were hiding dropdown overlays
|
||||||
|
- Context menus appeared behind modals
|
||||||
|
- Inconsistent z-index values across components
|
||||||
|
- No clear hierarchy for overlay stacking
|
||||||
|
|
||||||
|
### The Solution:
|
||||||
|
|
||||||
|
#### 1. Central Z-Index Constants (`00zindex.ts`):
|
||||||
|
Created a centralized file defining all z-index layers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const zIndexLayers = {
|
||||||
|
// Base layer: Regular content
|
||||||
|
base: {
|
||||||
|
content: 'auto',
|
||||||
|
inputElements: 1,
|
||||||
|
},
|
||||||
|
// Fixed UI elements
|
||||||
|
fixed: {
|
||||||
|
appBar: 10,
|
||||||
|
sideMenu: 10,
|
||||||
|
mobileNav: 250,
|
||||||
|
},
|
||||||
|
// Overlay backdrops
|
||||||
|
backdrop: {
|
||||||
|
dropdown: 1999,
|
||||||
|
modal: 2999,
|
||||||
|
contextMenu: 3999,
|
||||||
|
},
|
||||||
|
// Interactive overlays
|
||||||
|
overlay: {
|
||||||
|
dropdown: 2000, // Dropdowns and select menus
|
||||||
|
modal: 3000, // Modal dialogs
|
||||||
|
contextMenu: 4000, // Context menus and tooltips
|
||||||
|
toast: 5000, // Toast notifications
|
||||||
|
},
|
||||||
|
// Special cases
|
||||||
|
modalDropdown: 3500, // Dropdowns inside modals
|
||||||
|
wysiwygMenus: 4500, // Editor formatting menus
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Updated Components:
|
||||||
|
- **dees-modal**: Changed from 2000 to 3000
|
||||||
|
- **dees-windowlayer**: Changed from 200-201 to 1999-2000 (used by dropdowns)
|
||||||
|
- **dees-contextmenu**: Changed from 10000 to 4000
|
||||||
|
- **dees-toast**: Changed from 10000 to 5000
|
||||||
|
- **wysiwyg menus**: Changed from 10000 to 4500
|
||||||
|
- **dees-appui-profiledropdown**: Uses new dropdown z-index (2000)
|
||||||
|
|
||||||
|
#### 3. Stacking Order (bottom to top):
|
||||||
|
1. Regular page content (auto)
|
||||||
|
2. Fixed navigation elements (10-250)
|
||||||
|
3. Dropdown backdrop (1999)
|
||||||
|
4. Dropdown content (2000)
|
||||||
|
5. Modal backdrop (2999)
|
||||||
|
6. Modal content (3000)
|
||||||
|
7. Context menu (4000)
|
||||||
|
8. WYSIWYG menus (4500)
|
||||||
|
9. Toast notifications (5000)
|
||||||
|
|
||||||
|
#### 4. Key Benefits:
|
||||||
|
- Dropdowns now appear above modals
|
||||||
|
- Context menus appear above dropdowns and modals
|
||||||
|
- Toast notifications always appear on top
|
||||||
|
- Consistent and predictable stacking behavior
|
||||||
|
- Easy to adjust hierarchy by modifying central constants
|
||||||
|
|
||||||
|
#### 5. Testing:
|
||||||
|
Created `test-zindex.demo.ts` to verify stacking behavior with:
|
||||||
|
- Modal containing dropdown
|
||||||
|
- Context menu on modal
|
||||||
|
- Toast notifications
|
||||||
|
- Complex overlay combinations
|
||||||
|
|
||||||
|
### Usage:
|
||||||
|
Import and use the z-index constants in any component:
|
||||||
|
```typescript
|
||||||
|
import { zIndexLayers } from './00zindex.js';
|
||||||
|
|
||||||
|
// In styles
|
||||||
|
z-index: ${zIndexLayers.overlay.modal};
|
||||||
|
```
|
||||||
|
|
||||||
|
This system ensures proper stacking order for all overlay components and prevents z-index conflicts.
|
||||||
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.
|
||||||
213
readme.plan.md
213
readme.plan.md
@@ -1,213 +0,0 @@
|
|||||||
# Input Component Unification Plan
|
|
||||||
|
|
||||||
Command to reread guidelines: `cat /home/philkunz/.claude/CLAUDE.md`
|
|
||||||
|
|
||||||
## Problem Summary
|
|
||||||
|
|
||||||
The dees-input components have inconsistent margin behavior causing vertical alignment issues in horizontal flexbox layouts:
|
|
||||||
|
|
||||||
- **dees-input-text**: 8px top, 24px bottom margin
|
|
||||||
- **dees-input-dropdown**: 0px top, 24px bottom margin
|
|
||||||
- **dees-input-checkbox/radio**: 20px top, 20px bottom margin
|
|
||||||
- Different components use different label implementations (some use dees-label, others have built-in labels)
|
|
||||||
|
|
||||||
## Proposed Solution
|
|
||||||
|
|
||||||
### 1. Standardize Margin System
|
|
||||||
|
|
||||||
Create a unified margin approach for all input components:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Default vertical stacking mode (for forms) */
|
|
||||||
:host {
|
|
||||||
margin: 0;
|
|
||||||
margin-bottom: 16px; /* Reduced from 24px for better density */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Last child in container should have no bottom margin */
|
|
||||||
:host(:last-child) {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Horizontal layout mode - activated by parent context or attribute */
|
|
||||||
:host([horizontal-layout]) {
|
|
||||||
margin: 0;
|
|
||||||
margin-right: 16px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host([horizontal-layout]:last-child) {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Unified Label Architecture
|
|
||||||
|
|
||||||
All input components should use the `dees-label` component for consistency:
|
|
||||||
|
|
||||||
- Move label rendering from built-in implementations to `dees-label` usage
|
|
||||||
- Add a `labelPosition` property to all inputs: `'top' | 'left' | 'right' | 'none'`
|
|
||||||
- Default to 'top' for text/dropdown, 'right' for checkbox/radio
|
|
||||||
|
|
||||||
### 3. Layout Mode Support
|
|
||||||
|
|
||||||
Add a `layoutMode` property to all input components:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
@property({ type: String })
|
|
||||||
public layoutMode: 'vertical' | 'horizontal' | 'auto' = 'auto';
|
|
||||||
```
|
|
||||||
|
|
||||||
- `vertical`: Traditional form layout (label on top)
|
|
||||||
- `horizontal`: Inline layout (label position configurable)
|
|
||||||
- `auto`: Detect from parent context
|
|
||||||
|
|
||||||
### 4. Implementation Steps
|
|
||||||
|
|
||||||
1. **Create base input class** (`DeesInputBase`):
|
|
||||||
- Common margin styles
|
|
||||||
- Layout mode detection
|
|
||||||
- Label position handling
|
|
||||||
- Shared properties (key, required, disabled, value)
|
|
||||||
|
|
||||||
2. **Update dees-input-text**:
|
|
||||||
- Extend from DeesInputBase
|
|
||||||
- Remove hardcoded margins
|
|
||||||
- Keep using dees-label component
|
|
||||||
|
|
||||||
3. **Update dees-input-dropdown**:
|
|
||||||
- Extend from DeesInputBase
|
|
||||||
- Remove hardcoded margins
|
|
||||||
- Switch from built-in label to dees-label
|
|
||||||
|
|
||||||
4. **Update dees-input-checkbox**:
|
|
||||||
- Extend from DeesInputBase
|
|
||||||
- Remove hardcoded margins
|
|
||||||
- Add support for label position (keep default as 'right')
|
|
||||||
- Switch to dees-label component
|
|
||||||
|
|
||||||
5. **Update dees-input-radio**:
|
|
||||||
- Same as checkbox
|
|
||||||
|
|
||||||
6. **Update dees-form**:
|
|
||||||
- Add property to control child input layout mode
|
|
||||||
- Ensure proper spacing context
|
|
||||||
|
|
||||||
### 5. CSS Variable System
|
|
||||||
|
|
||||||
Introduce CSS variables for consistent spacing:
|
|
||||||
|
|
||||||
```css
|
|
||||||
:host {
|
|
||||||
--dees-input-spacing-unit: 8px;
|
|
||||||
--dees-input-vertical-gap: calc(var(--dees-input-spacing-unit) * 2); /* 16px */
|
|
||||||
--dees-input-horizontal-gap: calc(var(--dees-input-spacing-unit) * 2); /* 16px */
|
|
||||||
--dees-input-label-gap: var(--dees-input-spacing-unit); /* 8px */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Backward Compatibility
|
|
||||||
|
|
||||||
- Keep existing properties and methods
|
|
||||||
- Add deprecation notices for properties that will be removed
|
|
||||||
- Provide migration guide in documentation
|
|
||||||
|
|
||||||
### 7. Testing Requirements
|
|
||||||
|
|
||||||
- Test all inputs in vertical form layouts
|
|
||||||
- Test all inputs in horizontal flexbox containers
|
|
||||||
- Test mixed input types in same container
|
|
||||||
- Test with and without labels
|
|
||||||
- Test theme switching (light/dark)
|
|
||||||
- Test responsive behavior
|
|
||||||
|
|
||||||
## Expected Outcome
|
|
||||||
|
|
||||||
- All input components will align properly in horizontal layouts
|
|
||||||
- Consistent spacing in vertical forms
|
|
||||||
- Unified label handling across all inputs
|
|
||||||
- Better developer experience with predictable behavior
|
|
||||||
- Maintained backward compatibility
|
|
||||||
|
|
||||||
## Timeline
|
|
||||||
|
|
||||||
1. Phase 1: Create DeesInputBase class and update dees-input-text ✅
|
|
||||||
2. Phase 2: Update remaining input components ✅
|
|
||||||
3. Phase 3: Update documentation and examples ✅
|
|
||||||
4. Phase 4: Testing and refinement ✅
|
|
||||||
|
|
||||||
## Implementation Status
|
|
||||||
|
|
||||||
### Completed:
|
|
||||||
|
|
||||||
1. **Created DeesInputBase class** (`dees-input-base.ts`):
|
|
||||||
- Generic base class with unified margin system
|
|
||||||
- Layout mode support (vertical/horizontal/auto)
|
|
||||||
- Label position control
|
|
||||||
- Common properties and methods
|
|
||||||
- CSS variables for consistent spacing
|
|
||||||
|
|
||||||
2. **Updated all input components**:
|
|
||||||
- `dees-input-text`: Now extends DeesInputBase, margins removed
|
|
||||||
- `dees-input-dropdown`: Now extends DeesInputBase, uses dees-label
|
|
||||||
- `dees-input-checkbox`: Now extends DeesInputBase, uses dees-label (default label position: right)
|
|
||||||
- `dees-input-radio`: Now extends DeesInputBase, uses dees-label (default label position: right)
|
|
||||||
- `dees-input-phone`: Now extends DeesInputBase with phone formatting functionality
|
|
||||||
- `dees-input-iban`: Now extends DeesInputBase with IBAN validation
|
|
||||||
- `dees-input-quantityselector`: Now extends DeesInputBase
|
|
||||||
- `dees-input-multitoggle`: Now extends DeesInputBase with value property for forms
|
|
||||||
- `dees-input-typelist`: Now extends DeesInputBase
|
|
||||||
- `dees-input-fileupload`: Now extends DeesInputBase, uses dees-label
|
|
||||||
|
|
||||||
3. **Updated dees-form**:
|
|
||||||
- Added `horizontal-layout` property
|
|
||||||
- Auto-detection of layout mode for child inputs
|
|
||||||
- Added dropdown to form input types
|
|
||||||
|
|
||||||
4. **Fixed TypeScript errors**:
|
|
||||||
- Added value property to dropdown for form compatibility
|
|
||||||
- Fixed changeSubject typing
|
|
||||||
- Updated form value type to include dropdown options
|
|
||||||
- Fixed firstUpdated method signatures (phone, iban, fileupload)
|
|
||||||
- Fixed CSS-in-JS errors in quantityselector (removed dynamic references)
|
|
||||||
- Added value property to multitoggle for form compatibility
|
|
||||||
- Removed duplicate properties in fileupload (label, key, disabled, required)
|
|
||||||
|
|
||||||
### Result:
|
|
||||||
|
|
||||||
All input components now have:
|
|
||||||
- Unified 16px bottom margin in vertical layouts
|
|
||||||
- 16px right margin in horizontal layouts
|
|
||||||
- No margin on last child
|
|
||||||
- Consistent label handling via dees-label
|
|
||||||
- Flexible layout modes
|
|
||||||
- Better alignment in flexbox containers
|
|
||||||
|
|
||||||
## Demo Improvements
|
|
||||||
|
|
||||||
### Created external demo files:
|
|
||||||
1. **dees-input-text.demo.ts**: Comprehensive demos showing:
|
|
||||||
- Basic text inputs with descriptions
|
|
||||||
- Horizontal layout examples
|
|
||||||
- Label position variations
|
|
||||||
- Validation states
|
|
||||||
- Password input features
|
|
||||||
|
|
||||||
2. **dees-input-checkbox.demo.ts**: Interactive demos featuring:
|
|
||||||
- Basic checkbox usage
|
|
||||||
- Horizontal layout groups
|
|
||||||
- Feature selection with batch operations
|
|
||||||
- Real-world examples
|
|
||||||
|
|
||||||
3. **dees-input-radio.demo.ts**: Radio button demos including:
|
|
||||||
- Radio groups with proper behavior
|
|
||||||
- Horizontal yes/no questions
|
|
||||||
- Survey-style layouts
|
|
||||||
- Settings examples
|
|
||||||
|
|
||||||
### Updated existing demos:
|
|
||||||
1. **dees-input-dropdown.demo.ts**: Enhanced with dees-demowrapper and comprehensive examples
|
|
||||||
2. **dees-form.demo.ts**: Added horizontal form layout examples and advanced form features
|
|
||||||
3. **dees-simple-appdash.demo.ts**: Enhanced settings view with horizontal forms and radio groups
|
|
||||||
|
|
||||||
All demos now use the `dees-demowrapper` component for consistency and include proper styling for light/dark themes.
|
|
||||||
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.
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
|
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
import * as deesCatalog from '../ts_web';
|
import * as deesCatalog from '../ts_web/index.js';
|
||||||
|
|
||||||
tap.test('should create a working button', async () => {
|
tap.test('should create a working button', async () => {
|
||||||
const button: deesCatalog.DeesButton = await webhelpers.fixture(
|
const button: deesCatalog.DeesButton = await webhelpers.fixture(
|
||||||
@@ -9,4 +9,4 @@ tap.test('should create a working button', async () => {
|
|||||||
expect(button).toBeInstanceOf(deesCatalog.DeesButton);
|
expect(button).toBeInstanceOf(deesCatalog.DeesButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.start();
|
export default tap.start();
|
||||||
|
|||||||
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();
|
||||||
175
test/test.shadow-dom-containment.browser.ts
Normal file
175
test/test.shadow-dom-containment.browser.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { expect, tap, webhelpers } from '@push.rocks/tapbundle';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
import { WysiwygSelection } from '../ts_web/elements/wysiwyg/wysiwyg.selection.js';
|
||||||
|
|
||||||
|
tap.test('Shadow DOM containment should work correctly', async () => {
|
||||||
|
console.log('=== Testing Shadow DOM Containment ===');
|
||||||
|
|
||||||
|
// Create a WYSIWYG block component
|
||||||
|
const block = await webhelpers.fixture<DeesWysiwygBlock>(
|
||||||
|
'<dees-wysiwyg-block></dees-wysiwyg-block>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set the block data
|
||||||
|
block.block = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Hello world test content'
|
||||||
|
};
|
||||||
|
|
||||||
|
block.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
await block.updateComplete;
|
||||||
|
|
||||||
|
// Get the paragraph element inside Shadow DOM
|
||||||
|
const container = block.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
expect(paragraphBlock).toBeTruthy();
|
||||||
|
console.log('Found paragraph block:', paragraphBlock);
|
||||||
|
console.log('Paragraph text content:', paragraphBlock.textContent);
|
||||||
|
|
||||||
|
// Focus the paragraph
|
||||||
|
paragraphBlock.focus();
|
||||||
|
|
||||||
|
// Manually set cursor position
|
||||||
|
const textNode = paragraphBlock.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
|
||||||
|
// Set cursor at position 11 (after "Hello world")
|
||||||
|
range.setStart(textNode, 11);
|
||||||
|
range.setEnd(textNode, 11);
|
||||||
|
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
|
||||||
|
console.log('Set cursor at position 11');
|
||||||
|
|
||||||
|
// Test the containment check
|
||||||
|
console.log('\n--- Testing containment ---');
|
||||||
|
const currentSelection = window.getSelection();
|
||||||
|
if (currentSelection && currentSelection.rangeCount > 0) {
|
||||||
|
const selRange = currentSelection.getRangeAt(0);
|
||||||
|
console.log('Selection range:', {
|
||||||
|
startContainer: selRange.startContainer,
|
||||||
|
startOffset: selRange.startOffset,
|
||||||
|
containerText: selRange.startContainer.textContent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test regular contains (should fail across Shadow DOM)
|
||||||
|
const regularContains = paragraphBlock.contains(selRange.startContainer);
|
||||||
|
console.log('Regular contains:', regularContains);
|
||||||
|
|
||||||
|
// Test Shadow DOM-aware contains
|
||||||
|
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selRange.startContainer);
|
||||||
|
console.log('Shadow DOM contains:', shadowDOMContains);
|
||||||
|
|
||||||
|
// Since we're setting selection within the same shadow DOM, both should be true
|
||||||
|
expect(regularContains).toBeTrue();
|
||||||
|
expect(shadowDOMContains).toBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test getSplitContent
|
||||||
|
console.log('\n--- Testing getSplitContent ---');
|
||||||
|
const splitResult = block.getSplitContent();
|
||||||
|
console.log('Split result:', splitResult);
|
||||||
|
|
||||||
|
expect(splitResult).toBeTruthy();
|
||||||
|
if (splitResult) {
|
||||||
|
console.log('Before:', JSON.stringify(splitResult.before));
|
||||||
|
console.log('After:', JSON.stringify(splitResult.after));
|
||||||
|
|
||||||
|
// Expected split at position 11
|
||||||
|
expect(splitResult.before).toEqual('Hello world');
|
||||||
|
expect(splitResult.after).toEqual(' test content');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Shadow DOM containment across different shadow roots', async () => {
|
||||||
|
console.log('=== Testing Cross Shadow Root Containment ===');
|
||||||
|
|
||||||
|
// Create parent component with WYSIWYG editor
|
||||||
|
const parentDiv = document.createElement('div');
|
||||||
|
parentDiv.innerHTML = `
|
||||||
|
<dees-input-wysiwyg>
|
||||||
|
<dees-wysiwyg-block></dees-wysiwyg-block>
|
||||||
|
</dees-input-wysiwyg>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(parentDiv);
|
||||||
|
|
||||||
|
// Wait for components to be ready
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const wysiwygInput = parentDiv.querySelector('dees-input-wysiwyg') as any;
|
||||||
|
const blockElement = wysiwygInput?.shadowRoot?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
|
||||||
|
if (blockElement) {
|
||||||
|
// Set block data
|
||||||
|
blockElement.block = {
|
||||||
|
id: 'test-2',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Cross shadow DOM test'
|
||||||
|
};
|
||||||
|
|
||||||
|
blockElement.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
await blockElement.updateComplete;
|
||||||
|
|
||||||
|
// Get the paragraph inside the nested shadow DOM
|
||||||
|
const container = blockElement.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
if (paragraphBlock) {
|
||||||
|
console.log('Found nested paragraph block');
|
||||||
|
|
||||||
|
// Focus and set selection
|
||||||
|
paragraphBlock.focus();
|
||||||
|
const textNode = paragraphBlock.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(textNode, 5);
|
||||||
|
range.setEnd(textNode, 5);
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
|
||||||
|
// Test containment from parent's perspective
|
||||||
|
const selRange = selection?.getRangeAt(0);
|
||||||
|
if (selRange) {
|
||||||
|
// This should fail because it crosses shadow DOM boundary
|
||||||
|
const regularContains = wysiwygInput.contains(selRange.startContainer);
|
||||||
|
console.log('Parent regular contains:', regularContains);
|
||||||
|
expect(regularContains).toBeFalse();
|
||||||
|
|
||||||
|
// This should work with our Shadow DOM-aware method
|
||||||
|
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(wysiwygInput, selRange.startContainer);
|
||||||
|
console.log('Parent shadow DOM contains:', shadowDOMContains);
|
||||||
|
expect(shadowDOMContains).toBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
document.body.removeChild(parentDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
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();
|
||||||
9
test/test.wysiwyg-basic.browser.ts
Normal file
9
test/test.wysiwyg-basic.browser.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||||
|
|
||||||
|
tap.test('should create wysiwyg editor', async () => {
|
||||||
|
const editor = new DeesInputWysiwyg();
|
||||||
|
expect(editor).toBeInstanceOf(DeesInputWysiwyg);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
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();
|
||||||
69
test/test.wysiwyg-blocks-debug.browser.ts
Normal file
69
test/test.wysiwyg-blocks-debug.browser.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
|
||||||
|
|
||||||
|
import * as deesCatalog from '../ts_web/index.js';
|
||||||
|
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
|
||||||
|
// Import block registration to ensure handlers are registered
|
||||||
|
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
|
||||||
|
|
||||||
|
tap.test('Debug: should create empty wysiwyg block component', async () => {
|
||||||
|
try {
|
||||||
|
console.log('Creating DeesWysiwygBlock...');
|
||||||
|
const block: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
console.log('Block created:', block);
|
||||||
|
expect(block).toBeDefined();
|
||||||
|
expect(block).toBeInstanceOf(DeesWysiwygBlock);
|
||||||
|
console.log('Initial block property:', block.block);
|
||||||
|
console.log('Initial handlers property:', block.handlers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating block:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Debug: should set properties step by step', async () => {
|
||||||
|
try {
|
||||||
|
console.log('Step 1: Creating component...');
|
||||||
|
const block: DeesWysiwygBlock = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
expect(block).toBeDefined();
|
||||||
|
|
||||||
|
console.log('Step 2: Setting handlers...');
|
||||||
|
block.handlers = {
|
||||||
|
onInput: () => console.log('onInput'),
|
||||||
|
onKeyDown: () => console.log('onKeyDown'),
|
||||||
|
onFocus: () => console.log('onFocus'),
|
||||||
|
onBlur: () => console.log('onBlur'),
|
||||||
|
onCompositionStart: () => console.log('onCompositionStart'),
|
||||||
|
onCompositionEnd: () => console.log('onCompositionEnd')
|
||||||
|
};
|
||||||
|
console.log('Handlers set:', block.handlers);
|
||||||
|
|
||||||
|
console.log('Step 3: Setting block data...');
|
||||||
|
block.block = {
|
||||||
|
id: 'test-block',
|
||||||
|
type: 'divider',
|
||||||
|
content: ' '
|
||||||
|
};
|
||||||
|
console.log('Block set:', block.block);
|
||||||
|
|
||||||
|
console.log('Step 4: Appending to body...');
|
||||||
|
document.body.appendChild(block);
|
||||||
|
|
||||||
|
console.log('Step 5: Waiting for update...');
|
||||||
|
await block.updateComplete;
|
||||||
|
console.log('Update complete');
|
||||||
|
|
||||||
|
console.log('Step 6: Checking shadowRoot...');
|
||||||
|
expect(block.shadowRoot).toBeDefined();
|
||||||
|
console.log('ShadowRoot exists');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in step-by-step test:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
205
test/test.wysiwyg-blocks.browser.ts
Normal file
205
test/test.wysiwyg-blocks.browser.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
|
||||||
|
|
||||||
|
import * as deesCatalog from '../ts_web/index.js';
|
||||||
|
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
|
||||||
|
// Import block registration to ensure handlers are registered
|
||||||
|
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
|
||||||
|
|
||||||
|
tap.test('BlockRegistry should have registered handlers', async () => {
|
||||||
|
// Test divider handler
|
||||||
|
const dividerHandler = BlockRegistry.getHandler('divider');
|
||||||
|
expect(dividerHandler).toBeDefined();
|
||||||
|
expect(dividerHandler?.type).toEqual('divider');
|
||||||
|
|
||||||
|
// Test paragraph handler
|
||||||
|
const paragraphHandler = BlockRegistry.getHandler('paragraph');
|
||||||
|
expect(paragraphHandler).toBeDefined();
|
||||||
|
expect(paragraphHandler?.type).toEqual('paragraph');
|
||||||
|
|
||||||
|
// Test heading handlers
|
||||||
|
const heading1Handler = BlockRegistry.getHandler('heading-1');
|
||||||
|
expect(heading1Handler).toBeDefined();
|
||||||
|
expect(heading1Handler?.type).toEqual('heading-1');
|
||||||
|
|
||||||
|
const heading2Handler = BlockRegistry.getHandler('heading-2');
|
||||||
|
expect(heading2Handler).toBeDefined();
|
||||||
|
expect(heading2Handler?.type).toEqual('heading-2');
|
||||||
|
|
||||||
|
const heading3Handler = BlockRegistry.getHandler('heading-3');
|
||||||
|
expect(heading3Handler).toBeDefined();
|
||||||
|
expect(heading3Handler?.type).toEqual('heading-3');
|
||||||
|
|
||||||
|
// Test that getAllTypes returns all registered types
|
||||||
|
const allTypes = BlockRegistry.getAllTypes();
|
||||||
|
expect(allTypes).toContain('divider');
|
||||||
|
expect(allTypes).toContain('paragraph');
|
||||||
|
expect(allTypes).toContain('heading-1');
|
||||||
|
expect(allTypes).toContain('heading-2');
|
||||||
|
expect(allTypes).toContain('heading-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should render divider block using handler', async () => {
|
||||||
|
const dividerBlock: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set required handlers
|
||||||
|
dividerBlock.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set a divider block
|
||||||
|
dividerBlock.block = {
|
||||||
|
id: 'test-divider',
|
||||||
|
type: 'divider',
|
||||||
|
content: ' '
|
||||||
|
};
|
||||||
|
|
||||||
|
await dividerBlock.updateComplete;
|
||||||
|
|
||||||
|
// Check that the divider is rendered
|
||||||
|
const dividerElement = dividerBlock.shadowRoot?.querySelector('.block.divider');
|
||||||
|
expect(dividerElement).toBeDefined();
|
||||||
|
expect(dividerElement?.getAttribute('tabindex')).toEqual('0');
|
||||||
|
|
||||||
|
// Check for the divider icon
|
||||||
|
const icon = dividerBlock.shadowRoot?.querySelector('.divider-icon');
|
||||||
|
expect(icon).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should render paragraph block using handler', async () => {
|
||||||
|
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set required handlers
|
||||||
|
paragraphBlock.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {},
|
||||||
|
onMouseUp: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set a paragraph block
|
||||||
|
paragraphBlock.block = {
|
||||||
|
id: 'test-paragraph',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Test paragraph content'
|
||||||
|
};
|
||||||
|
|
||||||
|
await paragraphBlock.updateComplete;
|
||||||
|
|
||||||
|
// Check that the paragraph is rendered
|
||||||
|
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
|
||||||
|
expect(paragraphElement).toBeDefined();
|
||||||
|
expect(paragraphElement?.getAttribute('contenteditable')).toEqual('true');
|
||||||
|
expect(paragraphElement?.textContent).toEqual('Test paragraph content');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should render heading blocks using handler', async () => {
|
||||||
|
// Test heading-1
|
||||||
|
const heading1Block: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set required handlers
|
||||||
|
heading1Block.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {},
|
||||||
|
onMouseUp: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
heading1Block.block = {
|
||||||
|
id: 'test-h1',
|
||||||
|
type: 'heading-1',
|
||||||
|
content: 'Heading 1 Test'
|
||||||
|
};
|
||||||
|
|
||||||
|
await heading1Block.updateComplete;
|
||||||
|
|
||||||
|
const h1Element = heading1Block.shadowRoot?.querySelector('.block.heading-1');
|
||||||
|
expect(h1Element).toBeDefined();
|
||||||
|
expect(h1Element?.textContent).toEqual('Heading 1 Test');
|
||||||
|
|
||||||
|
// Test heading-2
|
||||||
|
const heading2Block: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set required handlers
|
||||||
|
heading2Block.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {},
|
||||||
|
onMouseUp: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
heading2Block.block = {
|
||||||
|
id: 'test-h2',
|
||||||
|
type: 'heading-2',
|
||||||
|
content: 'Heading 2 Test'
|
||||||
|
};
|
||||||
|
|
||||||
|
await heading2Block.updateComplete;
|
||||||
|
|
||||||
|
const h2Element = heading2Block.shadowRoot?.querySelector('.block.heading-2');
|
||||||
|
expect(h2Element).toBeDefined();
|
||||||
|
expect(h2Element?.textContent).toEqual('Heading 2 Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('paragraph block handler methods should work', async () => {
|
||||||
|
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set required handlers
|
||||||
|
paragraphBlock.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {},
|
||||||
|
onMouseUp: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
paragraphBlock.block = {
|
||||||
|
id: 'test-methods',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Initial content'
|
||||||
|
};
|
||||||
|
|
||||||
|
await paragraphBlock.updateComplete;
|
||||||
|
|
||||||
|
// Test getContent
|
||||||
|
const content = paragraphBlock.getContent();
|
||||||
|
expect(content).toEqual('Initial content');
|
||||||
|
|
||||||
|
// Test setContent
|
||||||
|
paragraphBlock.setContent('Updated content');
|
||||||
|
await paragraphBlock.updateComplete;
|
||||||
|
expect(paragraphBlock.getContent()).toEqual('Updated content');
|
||||||
|
|
||||||
|
// Test that the DOM is updated
|
||||||
|
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
|
||||||
|
expect(paragraphElement?.textContent).toEqual('Updated content');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
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();
|
||||||
341
test/test.wysiwyg-keyboard.browser.ts
Normal file
341
test/test.wysiwyg-keyboard.browser.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
|
||||||
|
tap.test('Keyboard: Arrow navigation between blocks', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import multiple blocks
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'block-1', type: 'paragraph', content: 'First paragraph' },
|
||||||
|
{ id: 'block-2', type: 'paragraph', content: 'Second paragraph' },
|
||||||
|
{ id: 'block-3', type: 'paragraph', content: 'Third paragraph' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus first block at end
|
||||||
|
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
|
||||||
|
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const firstBlockContainer = firstBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const firstParagraph = firstBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus and set cursor at end of first block
|
||||||
|
firstParagraph.focus();
|
||||||
|
const textNode = firstParagraph.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
range.setStart(textNode, textNode.textContent?.length || 0);
|
||||||
|
range.setEnd(textNode, textNode.textContent?.length || 0);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press ArrowRight to move to second block
|
||||||
|
const arrowRightEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'ArrowRight',
|
||||||
|
code: 'ArrowRight',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
firstParagraph.dispatchEvent(arrowRightEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if second block is focused
|
||||||
|
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
|
||||||
|
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Check if the second paragraph has focus
|
||||||
|
const activeElement = secondBlockComponent.shadowRoot?.activeElement;
|
||||||
|
expect(activeElement).toEqual(secondParagraph);
|
||||||
|
|
||||||
|
console.log('Arrow navigation test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Keyboard: Backspace merges blocks', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import two blocks
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'merge-1', type: 'paragraph', content: 'First' },
|
||||||
|
{ id: 'merge-2', type: 'paragraph', content: 'Second' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus second block at beginning
|
||||||
|
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="merge-2"]');
|
||||||
|
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus and set cursor at beginning
|
||||||
|
secondParagraph.focus();
|
||||||
|
const textNode = secondParagraph.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
range.setStart(textNode, 0);
|
||||||
|
range.setEnd(textNode, 0);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press Backspace to merge with previous block
|
||||||
|
const backspaceEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Backspace',
|
||||||
|
code: 'Backspace',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
secondParagraph.dispatchEvent(backspaceEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if blocks were merged
|
||||||
|
expect(editor.blocks.length).toEqual(1);
|
||||||
|
expect(editor.blocks[0].content).toContain('First');
|
||||||
|
expect(editor.blocks[0].content).toContain('Second');
|
||||||
|
|
||||||
|
console.log('Backspace merge test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Keyboard: Delete key on non-editable blocks', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import blocks including a divider
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'para-1', type: 'paragraph', content: 'Before divider' },
|
||||||
|
{ id: 'div-1', type: 'divider', content: '' },
|
||||||
|
{ id: 'para-2', type: 'paragraph', content: 'After divider' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus the divider block
|
||||||
|
const dividerBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="div-1"]');
|
||||||
|
const dividerBlockComponent = dividerBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const dividerBlockContainer = dividerBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const dividerElement = dividerBlockContainer?.querySelector('.block.divider') as HTMLElement;
|
||||||
|
|
||||||
|
// Non-editable blocks need to be focused differently
|
||||||
|
dividerElement?.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press Delete to remove the divider
|
||||||
|
const deleteEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Delete',
|
||||||
|
code: 'Delete',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
dividerElement.dispatchEvent(deleteEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if divider was removed
|
||||||
|
expect(editor.blocks.length).toEqual(2);
|
||||||
|
expect(editor.blocks.find(b => b.type === 'divider')).toBeUndefined();
|
||||||
|
|
||||||
|
console.log('Delete key on non-editable block test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Keyboard: Tab key in code block', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a code block
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'code-1', type: 'code', content: 'function test() {', metadata: { language: 'javascript' } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus code block
|
||||||
|
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
|
||||||
|
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const codeBlockContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const codeElement = codeBlockContainer?.querySelector('.block.code') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus and set cursor at end
|
||||||
|
codeElement.focus();
|
||||||
|
const textNode = codeElement.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
range.setStart(textNode, textNode.textContent?.length || 0);
|
||||||
|
range.setEnd(textNode, textNode.textContent?.length || 0);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press Tab to insert spaces
|
||||||
|
const tabEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Tab',
|
||||||
|
code: 'Tab',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
codeElement.dispatchEvent(tabEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if spaces were inserted
|
||||||
|
const updatedContent = codeElement.textContent || '';
|
||||||
|
expect(updatedContent).toContain(' '); // Tab should insert 2 spaces
|
||||||
|
|
||||||
|
console.log('Tab in code block test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Keyboard: ArrowUp/Down navigation', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import multiple blocks
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'nav-1', type: 'paragraph', content: 'First line' },
|
||||||
|
{ id: 'nav-2', type: 'paragraph', content: 'Second line' },
|
||||||
|
{ id: 'nav-3', type: 'paragraph', content: 'Third line' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus second block
|
||||||
|
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-2"]');
|
||||||
|
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
secondParagraph.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press ArrowUp to move to first block
|
||||||
|
const arrowUpEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'ArrowUp',
|
||||||
|
code: 'ArrowUp',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
secondParagraph.dispatchEvent(arrowUpEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if first block is focused
|
||||||
|
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-1"]');
|
||||||
|
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const firstParagraph = firstBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
expect(firstBlockComponent.shadowRoot?.activeElement).toEqual(firstParagraph);
|
||||||
|
|
||||||
|
// Now press ArrowDown twice to get to third block
|
||||||
|
const arrowDownEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'ArrowDown',
|
||||||
|
code: 'ArrowDown',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
firstParagraph.dispatchEvent(arrowDownEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Second block should be focused, dispatch again
|
||||||
|
const secondActiveElement = secondBlockComponent.shadowRoot?.activeElement;
|
||||||
|
if (secondActiveElement) {
|
||||||
|
secondActiveElement.dispatchEvent(arrowDownEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if third block is focused
|
||||||
|
const thirdBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-3"]');
|
||||||
|
const thirdBlockComponent = thirdBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const thirdParagraph = thirdBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
expect(thirdBlockComponent.shadowRoot?.activeElement).toEqual(thirdParagraph);
|
||||||
|
|
||||||
|
console.log('ArrowUp/Down navigation test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Keyboard: Formatting shortcuts', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a paragraph
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'format-1', type: 'paragraph', content: 'Test formatting' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus and select text
|
||||||
|
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="format-1"]');
|
||||||
|
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const blockContainer = blockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const paragraph = blockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
paragraph.focus();
|
||||||
|
|
||||||
|
// Select "formatting"
|
||||||
|
const textNode = paragraph.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
range.setStart(textNode, 5); // After "Test "
|
||||||
|
range.setEnd(textNode, 15); // After "formatting"
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press Cmd/Ctrl+B for bold
|
||||||
|
const boldEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'b',
|
||||||
|
code: 'KeyB',
|
||||||
|
metaKey: true, // Use metaKey for Mac, ctrlKey for Windows/Linux
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
paragraph.dispatchEvent(boldEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if bold was applied
|
||||||
|
const content = paragraph.innerHTML;
|
||||||
|
expect(content).toContain('<strong>') || expect(content).toContain('<b>');
|
||||||
|
|
||||||
|
console.log('Formatting shortcuts test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
150
test/test.wysiwyg-phase3.browser.ts
Normal file
150
test/test.wysiwyg-phase3.browser.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
|
||||||
|
tap.test('Phase 3: Quote block should render and work correctly', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a quote block
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'quote-1', type: 'quote', content: 'This is a famous quote' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if quote block was rendered
|
||||||
|
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
|
||||||
|
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
expect(quoteBlockComponent).toBeTruthy();
|
||||||
|
|
||||||
|
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
|
||||||
|
expect(quoteElement).toBeTruthy();
|
||||||
|
expect(quoteElement?.textContent).toEqual('This is a famous quote');
|
||||||
|
|
||||||
|
// Check if styles are applied (border-left for quote)
|
||||||
|
const computedStyle = window.getComputedStyle(quoteElement);
|
||||||
|
expect(computedStyle.borderLeftStyle).toEqual('solid');
|
||||||
|
expect(computedStyle.fontStyle).toEqual('italic');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Phase 3: Code block should render and handle tab correctly', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a code block
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'code-1', type: 'code', content: 'const x = 42;', metadata: { language: 'javascript' } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if code block was rendered
|
||||||
|
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
|
||||||
|
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const codeContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const codeElement = codeContainer?.querySelector('.block.code') as HTMLElement;
|
||||||
|
|
||||||
|
expect(codeElement).toBeTruthy();
|
||||||
|
expect(codeElement?.textContent).toEqual('const x = 42;');
|
||||||
|
|
||||||
|
// Check if language label is shown
|
||||||
|
const languageLabel = codeContainer?.querySelector('.code-language');
|
||||||
|
expect(languageLabel?.textContent).toEqual('javascript');
|
||||||
|
|
||||||
|
// Check if monospace font is applied
|
||||||
|
const computedStyle = window.getComputedStyle(codeElement);
|
||||||
|
expect(computedStyle.fontFamily).toContain('monospace');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Phase 3: List block should render correctly', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a list block
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'list-1', type: 'list', content: 'First item\nSecond item\nThird item' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if list block was rendered
|
||||||
|
const listBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="list-1"]');
|
||||||
|
const listBlockComponent = listBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const listContainer = listBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const listElement = listContainer?.querySelector('.block.list') as HTMLElement;
|
||||||
|
|
||||||
|
expect(listElement).toBeTruthy();
|
||||||
|
|
||||||
|
// Check if list items were created
|
||||||
|
const listItems = listElement?.querySelectorAll('li');
|
||||||
|
expect(listItems?.length).toEqual(3);
|
||||||
|
expect(listItems?.[0].textContent).toEqual('First item');
|
||||||
|
expect(listItems?.[1].textContent).toEqual('Second item');
|
||||||
|
expect(listItems?.[2].textContent).toEqual('Third item');
|
||||||
|
|
||||||
|
// Check if it's an unordered list by default
|
||||||
|
const ulElement = listElement?.querySelector('ul');
|
||||||
|
expect(ulElement).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Phase 3: Quote block split should work', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a quote block
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'quote-split', type: 'quote', content: 'To be or not to be' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get the quote block
|
||||||
|
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-split"]');
|
||||||
|
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus and set cursor after "To be"
|
||||||
|
quoteElement.focus();
|
||||||
|
const textNode = quoteElement.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
range.setStart(textNode, 5); // After "To be"
|
||||||
|
range.setEnd(textNode, 5);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press Enter to split
|
||||||
|
const enterEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
code: 'Enter',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
quoteElement.dispatchEvent(enterEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if split happened correctly
|
||||||
|
expect(editor.blocks.length).toEqual(2);
|
||||||
|
expect(editor.blocks[0].content).toEqual('To be');
|
||||||
|
expect(editor.blocks[1].content).toEqual(' or not to be');
|
||||||
|
expect(editor.blocks[1].type).toEqual('paragraph'); // New block should be paragraph
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
105
test/test.wysiwyg-registry.both.ts
Normal file
105
test/test.wysiwyg-registry.both.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
|
||||||
|
import { DividerBlockHandler } from '../ts_web/elements/wysiwyg/blocks/content/divider.block.js';
|
||||||
|
import { ParagraphBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/paragraph.block.js';
|
||||||
|
import { HeadingBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/heading.block.js';
|
||||||
|
|
||||||
|
// Import block registration to ensure handlers are registered
|
||||||
|
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
|
||||||
|
|
||||||
|
tap.test('BlockRegistry should register and retrieve handlers', async () => {
|
||||||
|
// Test divider handler
|
||||||
|
const dividerHandler = BlockRegistry.getHandler('divider');
|
||||||
|
expect(dividerHandler).toBeDefined();
|
||||||
|
expect(dividerHandler).toBeInstanceOf(DividerBlockHandler);
|
||||||
|
expect(dividerHandler?.type).toEqual('divider');
|
||||||
|
|
||||||
|
// Test paragraph handler
|
||||||
|
const paragraphHandler = BlockRegistry.getHandler('paragraph');
|
||||||
|
expect(paragraphHandler).toBeDefined();
|
||||||
|
expect(paragraphHandler).toBeInstanceOf(ParagraphBlockHandler);
|
||||||
|
expect(paragraphHandler?.type).toEqual('paragraph');
|
||||||
|
|
||||||
|
// Test heading handlers
|
||||||
|
const heading1Handler = BlockRegistry.getHandler('heading-1');
|
||||||
|
expect(heading1Handler).toBeDefined();
|
||||||
|
expect(heading1Handler).toBeInstanceOf(HeadingBlockHandler);
|
||||||
|
expect(heading1Handler?.type).toEqual('heading-1');
|
||||||
|
|
||||||
|
const heading2Handler = BlockRegistry.getHandler('heading-2');
|
||||||
|
expect(heading2Handler).toBeDefined();
|
||||||
|
expect(heading2Handler).toBeInstanceOf(HeadingBlockHandler);
|
||||||
|
expect(heading2Handler?.type).toEqual('heading-2');
|
||||||
|
|
||||||
|
const heading3Handler = BlockRegistry.getHandler('heading-3');
|
||||||
|
expect(heading3Handler).toBeDefined();
|
||||||
|
expect(heading3Handler).toBeInstanceOf(HeadingBlockHandler);
|
||||||
|
expect(heading3Handler?.type).toEqual('heading-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Block handlers should render content correctly', async () => {
|
||||||
|
const testBlock = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'paragraph' as const,
|
||||||
|
content: 'Test paragraph content'
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = BlockRegistry.getHandler('paragraph');
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
const rendered = handler.render(testBlock, false);
|
||||||
|
expect(rendered).toContain('contenteditable="true"');
|
||||||
|
expect(rendered).toContain('data-block-type="paragraph"');
|
||||||
|
expect(rendered).toContain('Test paragraph content');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Divider handler should render correctly', async () => {
|
||||||
|
const dividerBlock = {
|
||||||
|
id: 'test-divider',
|
||||||
|
type: 'divider' as const,
|
||||||
|
content: ' '
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = BlockRegistry.getHandler('divider');
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
const rendered = handler.render(dividerBlock, false);
|
||||||
|
expect(rendered).toContain('class="block divider"');
|
||||||
|
expect(rendered).toContain('tabindex="0"');
|
||||||
|
expect(rendered).toContain('divider-icon');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Heading handlers should render with correct levels', async () => {
|
||||||
|
const headingBlock = {
|
||||||
|
id: 'test-h1',
|
||||||
|
type: 'heading-1' as const,
|
||||||
|
content: 'Test Heading'
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = BlockRegistry.getHandler('heading-1');
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
const rendered = handler.render(headingBlock, false);
|
||||||
|
expect(rendered).toContain('class="block heading-1"');
|
||||||
|
expect(rendered).toContain('contenteditable="true"');
|
||||||
|
expect(rendered).toContain('Test Heading');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('getAllTypes should return all registered types', async () => {
|
||||||
|
const allTypes = BlockRegistry.getAllTypes();
|
||||||
|
expect(allTypes).toContain('divider');
|
||||||
|
expect(allTypes).toContain('paragraph');
|
||||||
|
expect(allTypes).toContain('heading-1');
|
||||||
|
expect(allTypes).toContain('heading-2');
|
||||||
|
expect(allTypes).toContain('heading-3');
|
||||||
|
expect(allTypes.length).toBeGreaterThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
156
test/test.wysiwyg-selection-highlight.browser.ts
Normal file
156
test/test.wysiwyg-selection-highlight.browser.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
|
||||||
|
tap.test('Selection highlighting should work consistently for all block types', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import various block types
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'para-1', type: 'paragraph', content: 'This is a paragraph' },
|
||||||
|
{ id: 'heading-1', type: 'heading-1', content: 'This is a heading' },
|
||||||
|
{ id: 'quote-1', type: 'quote', content: 'This is a quote' },
|
||||||
|
{ id: 'code-1', type: 'code', content: 'const x = 42;' },
|
||||||
|
{ id: 'list-1', type: 'list', content: 'Item 1\nItem 2' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Test paragraph highlighting
|
||||||
|
console.log('Testing paragraph highlighting...');
|
||||||
|
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
|
||||||
|
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const paraContainer = paraComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const paraElement = paraContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus paragraph to select it
|
||||||
|
paraElement.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if paragraph has selected class
|
||||||
|
const paraHasSelected = paraElement.classList.contains('selected');
|
||||||
|
console.log('Paragraph has selected class:', paraHasSelected);
|
||||||
|
|
||||||
|
// Check computed styles
|
||||||
|
const paraStyle = window.getComputedStyle(paraElement);
|
||||||
|
console.log('Paragraph background:', paraStyle.background);
|
||||||
|
console.log('Paragraph box-shadow:', paraStyle.boxShadow);
|
||||||
|
|
||||||
|
// Test heading highlighting
|
||||||
|
console.log('\nTesting heading highlighting...');
|
||||||
|
const headingWrapper = editor.shadowRoot?.querySelector('[data-block-id="heading-1"]');
|
||||||
|
const headingComponent = headingWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const headingContainer = headingComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const headingElement = headingContainer?.querySelector('.block.heading-1') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus heading to select it
|
||||||
|
headingElement.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if heading has selected class
|
||||||
|
const headingHasSelected = headingElement.classList.contains('selected');
|
||||||
|
console.log('Heading has selected class:', headingHasSelected);
|
||||||
|
|
||||||
|
// Check computed styles
|
||||||
|
const headingStyle = window.getComputedStyle(headingElement);
|
||||||
|
console.log('Heading background:', headingStyle.background);
|
||||||
|
console.log('Heading box-shadow:', headingStyle.boxShadow);
|
||||||
|
|
||||||
|
// Test quote highlighting
|
||||||
|
console.log('\nTesting quote highlighting...');
|
||||||
|
const quoteWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
|
||||||
|
const quoteComponent = quoteWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const quoteContainer = quoteComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus quote to select it
|
||||||
|
quoteElement.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if quote has selected class
|
||||||
|
const quoteHasSelected = quoteElement.classList.contains('selected');
|
||||||
|
console.log('Quote has selected class:', quoteHasSelected);
|
||||||
|
|
||||||
|
// Test code highlighting
|
||||||
|
console.log('\nTesting code highlighting...');
|
||||||
|
const codeWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
|
||||||
|
const codeComponent = codeWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const codeContainer = codeComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const codeElement = codeContainer?.querySelector('.block.code') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus code to select it
|
||||||
|
codeElement.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if code has selected class
|
||||||
|
const codeHasSelected = codeElement.classList.contains('selected');
|
||||||
|
console.log('Code has selected class:', codeHasSelected);
|
||||||
|
|
||||||
|
// Focus back on paragraph and check if others are deselected
|
||||||
|
console.log('\nFocusing back on paragraph...');
|
||||||
|
paraElement.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check that only paragraph is selected
|
||||||
|
expect(paraElement.classList.contains('selected')).toBeTrue();
|
||||||
|
expect(headingElement.classList.contains('selected')).toBeFalse();
|
||||||
|
expect(quoteElement.classList.contains('selected')).toBeFalse();
|
||||||
|
expect(codeElement.classList.contains('selected')).toBeFalse();
|
||||||
|
|
||||||
|
console.log('Selection highlighting test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Selected class should toggle correctly when clicking between blocks', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import two blocks
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'block-1', type: 'paragraph', content: 'First block' },
|
||||||
|
{ id: 'block-2', type: 'paragraph', content: 'Second block' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get both blocks
|
||||||
|
const block1Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
|
||||||
|
const block1Component = block1Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const block1Container = block1Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const block1Element = block1Container?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
const block2Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
|
||||||
|
const block2Component = block2Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const block2Container = block2Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const block2Element = block2Container?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Initially neither should be selected
|
||||||
|
expect(block1Element.classList.contains('selected')).toBeFalse();
|
||||||
|
expect(block2Element.classList.contains('selected')).toBeFalse();
|
||||||
|
|
||||||
|
// Click on first block
|
||||||
|
block1Element.click();
|
||||||
|
block1Element.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// First block should be selected
|
||||||
|
expect(block1Element.classList.contains('selected')).toBeTrue();
|
||||||
|
expect(block2Element.classList.contains('selected')).toBeFalse();
|
||||||
|
|
||||||
|
// Click on second block
|
||||||
|
block2Element.click();
|
||||||
|
block2Element.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Second block should be selected, first should not
|
||||||
|
expect(block1Element.classList.contains('selected')).toBeFalse();
|
||||||
|
expect(block2Element.classList.contains('selected')).toBeTrue();
|
||||||
|
|
||||||
|
console.log('Toggle test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
62
test/test.wysiwyg-selection-simple.browser.ts
Normal file
62
test/test.wysiwyg-selection-simple.browser.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
|
||||||
|
tap.test('Selection highlighting basic test', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import two blocks
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'para-1', type: 'paragraph', content: 'First paragraph' },
|
||||||
|
{ id: 'head-1', type: 'heading-1', content: 'First heading' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Get paragraph element
|
||||||
|
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
|
||||||
|
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const paraBlock = paraComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Get heading element
|
||||||
|
const headWrapper = editor.shadowRoot?.querySelector('[data-block-id="head-1"]');
|
||||||
|
const headComponent = headWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const headBlock = headComponent?.shadowRoot?.querySelector('.block.heading-1') as HTMLElement;
|
||||||
|
|
||||||
|
console.log('Found elements:', {
|
||||||
|
paraBlock: !!paraBlock,
|
||||||
|
headBlock: !!headBlock
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus paragraph
|
||||||
|
paraBlock.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check classes
|
||||||
|
console.log('Paragraph classes:', paraBlock.className);
|
||||||
|
console.log('Heading classes:', headBlock.className);
|
||||||
|
|
||||||
|
// Check isSelected property
|
||||||
|
console.log('Paragraph component isSelected:', paraComponent.isSelected);
|
||||||
|
console.log('Heading component isSelected:', headComponent.isSelected);
|
||||||
|
|
||||||
|
// Focus heading
|
||||||
|
headBlock.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check classes again
|
||||||
|
console.log('\nAfter focusing heading:');
|
||||||
|
console.log('Paragraph classes:', paraBlock.className);
|
||||||
|
console.log('Heading classes:', headBlock.className);
|
||||||
|
console.log('Paragraph component isSelected:', paraComponent.isSelected);
|
||||||
|
console.log('Heading component isSelected:', headComponent.isSelected);
|
||||||
|
|
||||||
|
// Check that heading is selected
|
||||||
|
expect(headBlock.classList.contains('selected')).toBeTrue();
|
||||||
|
expect(paraBlock.classList.contains('selected')).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
98
test/test.wysiwyg-split.browser.ts
Normal file
98
test/test.wysiwyg-split.browser.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
|
||||||
|
tap.test('should split paragraph content on Enter key', async () => {
|
||||||
|
// Create the wysiwyg editor
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a test paragraph
|
||||||
|
editor.importBlocks([{
|
||||||
|
id: 'test-para-1',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Hello World'
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
|
||||||
|
// Wait for blocks to render
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get the block wrapper and component
|
||||||
|
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-para-1"]');
|
||||||
|
expect(blockWrapper).toBeDefined();
|
||||||
|
|
||||||
|
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
expect(blockComponent).toBeDefined();
|
||||||
|
expect(blockComponent.block.type).toEqual('paragraph');
|
||||||
|
|
||||||
|
// Wait for block to render
|
||||||
|
await blockComponent.updateComplete;
|
||||||
|
|
||||||
|
// Test getSplitContent
|
||||||
|
console.log('Testing getSplitContent...');
|
||||||
|
const splitResult = blockComponent.getSplitContent();
|
||||||
|
console.log('Split result:', splitResult);
|
||||||
|
|
||||||
|
// Since we haven't set cursor position, it might return null or split at start
|
||||||
|
// This is just to test if the method is callable
|
||||||
|
expect(typeof blockComponent.getSplitContent).toEqual('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle Enter key press in paragraph', async () => {
|
||||||
|
// Create the wysiwyg editor
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a test paragraph
|
||||||
|
editor.importBlocks([{
|
||||||
|
id: 'test-enter-1',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'First part|Second part' // | marks where we'll simulate cursor
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check initial state
|
||||||
|
expect(editor.blocks.length).toEqual(1);
|
||||||
|
expect(editor.blocks[0].content).toEqual('First part|Second part');
|
||||||
|
|
||||||
|
// Get the block element
|
||||||
|
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-enter-1"]');
|
||||||
|
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const blockElement = blockComponent.shadowRoot?.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
|
|
||||||
|
expect(blockElement).toBeDefined();
|
||||||
|
|
||||||
|
// Set content without the | marker
|
||||||
|
blockElement.textContent = 'First partSecond part';
|
||||||
|
|
||||||
|
// Focus the block
|
||||||
|
blockElement.focus();
|
||||||
|
|
||||||
|
// Create and dispatch Enter key event
|
||||||
|
const enterEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
code: 'Enter',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch the event
|
||||||
|
blockElement.dispatchEvent(enterEvent);
|
||||||
|
|
||||||
|
// Wait for processing
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if block was split (this might not work perfectly in test environment)
|
||||||
|
console.log('Blocks after Enter:', editor.blocks.length);
|
||||||
|
console.log('Block contents:', editor.blocks.map(b => b.content));
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@design.estate/dees-catalog',
|
name: '@design.estate/dees-catalog',
|
||||||
version: '1.8.1',
|
version: '1.12.5',
|
||||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
`);
|
||||||
161
ts_web/elements/00zindex.ts
Normal file
161
ts_web/elements/00zindex.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Central z-index management for consistent stacking order
|
||||||
|
* Higher numbers appear on top of lower numbers
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const zIndexLayers = {
|
||||||
|
// Base layer: Regular content
|
||||||
|
base: {
|
||||||
|
content: 'auto',
|
||||||
|
inputElements: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fixed UI elements
|
||||||
|
fixed: {
|
||||||
|
appBar: 10,
|
||||||
|
sideMenu: 10,
|
||||||
|
mobileNav: 250,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Overlay backdrops (semi-transparent backgrounds)
|
||||||
|
backdrop: {
|
||||||
|
dropdown: 1999, // Below modals but above fixed elements
|
||||||
|
modal: 2999, // Below dropdowns on modals
|
||||||
|
contextMenu: 3999, // Below critical overlays
|
||||||
|
},
|
||||||
|
|
||||||
|
// Interactive overlays
|
||||||
|
overlay: {
|
||||||
|
dropdown: 2000, // Dropdowns and select menus
|
||||||
|
modal: 3000, // Modal dialogs
|
||||||
|
contextMenu: 4000, // Context menus and tooltips
|
||||||
|
toast: 5000, // Toast notifications (highest priority)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Special cases for nested elements
|
||||||
|
modalDropdown: 3500, // Dropdowns inside modals
|
||||||
|
wysiwygMenus: 4500, // Editor formatting menus
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Helper function to get z-index value
|
||||||
|
export function getZIndex(category: keyof typeof zIndexLayers, subcategory?: string): number | string {
|
||||||
|
const categoryObj = zIndexLayers[category];
|
||||||
|
if (typeof categoryObj === 'object' && subcategory) {
|
||||||
|
return categoryObj[subcategory as keyof typeof categoryObj] || 'auto';
|
||||||
|
}
|
||||||
|
return typeof categoryObj === 'number' ? categoryObj : 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Z-index assignments for components
|
||||||
|
export const componentZIndex = {
|
||||||
|
'dees-modal': zIndexLayers.overlay.modal,
|
||||||
|
'dees-windowlayer': zIndexLayers.overlay.dropdown,
|
||||||
|
'dees-contextmenu': zIndexLayers.overlay.contextMenu,
|
||||||
|
'dees-toast': zIndexLayers.overlay.toast,
|
||||||
|
'dees-appui-mainmenu': zIndexLayers.fixed.appBar,
|
||||||
|
'dees-mobilenavigation': zIndexLayers.fixed.mobileNav,
|
||||||
|
'dees-slash-menu': zIndexLayers.wysiwygMenus,
|
||||||
|
'dees-formatting-menu': zIndexLayers.wysiwygMenus,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Z-Index Registry for managing stacked elements
|
||||||
|
* Simple incremental z-index assignment based on creation order
|
||||||
|
*/
|
||||||
|
export class ZIndexRegistry {
|
||||||
|
private static instance: ZIndexRegistry;
|
||||||
|
private activeElements = new Set<HTMLElement>();
|
||||||
|
private elementZIndexMap = new WeakMap<HTMLElement, number>();
|
||||||
|
private currentZIndex = 1000; // Starting z-index
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public static getInstance(): ZIndexRegistry {
|
||||||
|
if (!ZIndexRegistry.instance) {
|
||||||
|
ZIndexRegistry.instance = new ZIndexRegistry();
|
||||||
|
}
|
||||||
|
return ZIndexRegistry.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next available z-index
|
||||||
|
* @returns The next available z-index
|
||||||
|
*/
|
||||||
|
public getNextZIndex(): number {
|
||||||
|
this.currentZIndex += 10;
|
||||||
|
return this.currentZIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an element with the z-index registry
|
||||||
|
* @param element - The HTML element to register
|
||||||
|
* @param zIndex - The z-index assigned to this element
|
||||||
|
*/
|
||||||
|
public register(element: HTMLElement, zIndex: number): void {
|
||||||
|
this.activeElements.add(element);
|
||||||
|
this.elementZIndexMap.set(element, zIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister an element from the z-index registry
|
||||||
|
* @param element - The HTML element to unregister
|
||||||
|
*/
|
||||||
|
public unregister(element: HTMLElement): void {
|
||||||
|
this.activeElements.delete(element);
|
||||||
|
this.elementZIndexMap.delete(element);
|
||||||
|
|
||||||
|
// If no more active elements, reset counter to base
|
||||||
|
if (this.activeElements.size === 0) {
|
||||||
|
this.currentZIndex = 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the z-index for a specific element
|
||||||
|
* @param element - The HTML element
|
||||||
|
* @returns The z-index or undefined if not registered
|
||||||
|
*/
|
||||||
|
public getElementZIndex(element: HTMLElement): number | undefined {
|
||||||
|
return this.elementZIndexMap.get(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of active elements
|
||||||
|
* @returns Number of active elements
|
||||||
|
*/
|
||||||
|
public getActiveCount(): number {
|
||||||
|
return this.activeElements.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current highest z-index
|
||||||
|
* @returns The current z-index value
|
||||||
|
*/
|
||||||
|
public getCurrentZIndex(): number {
|
||||||
|
return this.currentZIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all registrations (useful for testing)
|
||||||
|
*/
|
||||||
|
public clear(): void {
|
||||||
|
this.activeElements.clear();
|
||||||
|
this.elementZIndexMap = new WeakMap();
|
||||||
|
this.currentZIndex = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active elements in z-index order
|
||||||
|
* @returns Array of elements sorted by z-index
|
||||||
|
*/
|
||||||
|
public getActiveElementsInOrder(): HTMLElement[] {
|
||||||
|
return Array.from(this.activeElements).sort((a, b) => {
|
||||||
|
const aZ = this.elementZIndexMap.get(a) || 0;
|
||||||
|
const bZ = this.elementZIndexMap.get(b) || 0;
|
||||||
|
return aZ - bZ;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance for convenience
|
||||||
|
export const zIndexRegistry = ZIndexRegistry.getInstance();
|
||||||
@@ -11,27 +11,46 @@ import {
|
|||||||
|
|
||||||
import * as domtools from '@design.estate/dees-domtools';
|
import * as domtools from '@design.estate/dees-domtools';
|
||||||
import { DeesContextmenu } from './dees-contextmenu.js';
|
import { DeesContextmenu } from './dees-contextmenu.js';
|
||||||
|
import './dees-icon.js';
|
||||||
|
|
||||||
@customElement('dees-appui-activitylog')
|
@customElement('dees-appui-activitylog')
|
||||||
export class DeesAppuiActivitylog extends DeesElement {
|
export class DeesAppuiActivitylog extends DeesElement {
|
||||||
// STATIC
|
// 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
|
// INSTANCE
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 300px;
|
max-width: 320px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: ${cssManager.bdTheme('#f8f8f8', '#111c28')};
|
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||||
font-family: 'Intel One Mono', sans-serif;
|
font-family: 'Geist Mono', monospace;
|
||||||
border-left: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
border-left: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||||
cursor: default;
|
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 {
|
.maincontainer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -44,108 +63,265 @@ export class DeesAppuiActivitylog extends DeesElement {
|
|||||||
.topbar {
|
.topbar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
height: 32px;
|
height: 40px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0px 12px 0px 12px;
|
padding: 0px 16px;
|
||||||
background: ${cssManager.bdTheme('#ffffff', '#0e151f')};
|
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||||
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar .heading {
|
.topbar .heading {
|
||||||
text-align: left;
|
font-weight: 600;
|
||||||
line-height: 24px;
|
|
||||||
padding-top: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: 'Geist Sans', sans-serif;
|
font-family: 'Geist Sans', sans-serif;
|
||||||
color: ${cssManager.bdTheme('#666', '#ccc')};
|
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityContainer {
|
.activityContainer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 32px;
|
top: 40px;
|
||||||
bottom: 40px;
|
bottom: 48px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0px;
|
padding: 12px 0px;
|
||||||
overflow-y: scroll;
|
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 {
|
.streamingIndicator {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding-top: 16px;
|
padding: 16px;
|
||||||
color: ${cssManager.bdTheme('#666', '#888')}
|
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 {
|
.streamingIndicator.bottom {
|
||||||
padding-top: 0px;
|
padding-top: 8px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityentry {
|
.activityentry {
|
||||||
min-height: 30px;
|
min-height: 36px;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
padding: 8px 16px;
|
padding: 10px 16px;
|
||||||
border-bottom: 1px dotted ${cssManager.bdTheme('#00000020', '#ffffff20')};
|
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 {
|
.activityentry:last-of-type {
|
||||||
border-bottom: 1px solid transparent;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityentry:hover {
|
.activityentry:hover {
|
||||||
background: ${cssManager.bdTheme('#00000005', '#00000080')};
|
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp {
|
.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 {
|
.searchbox {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 48px;
|
||||||
background: ${cssManager.bdTheme('#ffffff', '#0e151f')};
|
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||||
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
border-top: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||||
|
padding: 8px;
|
||||||
}
|
}
|
||||||
.searchbox input {
|
|
||||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
.search-wrapper {
|
||||||
background: none;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 32px;
|
||||||
line-height: 32px;
|
}
|
||||||
border: none;
|
|
||||||
padding: 4px 12px;
|
.search-icon {
|
||||||
font-family: 'Intel One Mono', sans-serif;
|
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 {
|
.searchbox input:focus {
|
||||||
outline: none;
|
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 {
|
.bottomShadow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 32px;
|
height: 24px;
|
||||||
bottom: 40px;
|
bottom: 48px;
|
||||||
background: ${cssManager.bdTheme(
|
background: ${cssManager.bdTheme(
|
||||||
'linear-gradient(180deg, #f8f8f800 0%, #ffffff 100%)',
|
'linear-gradient(180deg, transparent 0%, #fafafa 100%)',
|
||||||
'linear-gradient(180deg, #111c2800 0%, #0e151f 100%)'
|
'linear-gradient(180deg, transparent 0%, #0a0a0a 100%)'
|
||||||
)};
|
)};
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topShadow {
|
.topShadow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 32px;
|
height: 24px;
|
||||||
top: 32px;
|
top: 40px;
|
||||||
background: ${cssManager.bdTheme(
|
background: ${cssManager.bdTheme(
|
||||||
'linear-gradient(0deg, #f8f8f800 0%, #ffffff 100%)',
|
'linear-gradient(0deg, transparent 0%, #fafafa 100%)',
|
||||||
'linear-gradient(0deg, #111c2800 0%, #0e151f 100%)'
|
'linear-gradient(0deg, transparent 0%, #0a0a0a 100%)'
|
||||||
)};
|
)};
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@@ -159,86 +335,174 @@ export class DeesAppuiActivitylog extends DeesElement {
|
|||||||
<div class="heading">Activity Log</div>
|
<div class="heading">Activity Log</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="activityContainer">
|
<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 => {
|
<div class="activityentry" @contextmenu=${async eventArg => {
|
||||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||||
{
|
{
|
||||||
name: 'app settings',
|
name: 'Copy activity',
|
||||||
action: async () => {},
|
action: async () => {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'account settings',
|
name: 'View details',
|
||||||
action: async () => {},
|
action: async () => {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'logout',
|
name: 'Filter by user',
|
||||||
action: async () => {},
|
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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
|
<div class="date-separator">Yesterday</div>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<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>
|
||||||
|
|
||||||
<div class="activityentry">
|
<div class="activityentry">
|
||||||
<span class="timestamp">22:14:</span> Max Mustermann added a new user
|
<span class="timestamp">13:30</span>
|
||||||
|
<div class="activity-icon create">
|
||||||
|
<dees-icon .icon=${'lucide:filePlus'}></dees-icon>
|
||||||
|
</div>
|
||||||
|
<div class="activity-text">
|
||||||
|
<span class="activity-user">Max Mustermann</span> created a new invoice
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="activityentry">
|
|
||||||
<span class="timestamp">22:15:</span> Max Mustermann changed password
|
<div class="streamingIndicator bottom">Loading History</div>
|
||||||
</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>
|
</div>
|
||||||
<div class="searchbox">
|
<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>
|
||||||
<div class="topShadow"></div>
|
<div class="topShadow"></div>
|
||||||
<div class="bottomShadow"></div>
|
<div class="bottomShadow"></div>
|
||||||
|
|||||||
@@ -5,19 +5,19 @@ import {
|
|||||||
property,
|
property,
|
||||||
state,
|
state,
|
||||||
html,
|
html,
|
||||||
css,
|
|
||||||
cssManager,
|
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
import * as domtools from '@design.estate/dees-domtools';
|
import * as domtools from '@design.estate/dees-domtools';
|
||||||
import * as interfaces from './interfaces/index.js';
|
import * as interfaces from '../interfaces/index.js';
|
||||||
import * as plugins from './00plugins.js';
|
import * as plugins from '../00plugins.js';
|
||||||
import { demoFunc } from './dees-appui-appbar.demo.js';
|
import { demoFunc } from './demo.js';
|
||||||
|
import { appuiAppbarStyles } from './styles.js';
|
||||||
|
import { renderAppuiAppbar } from './template.js';
|
||||||
|
|
||||||
// Import required components
|
// Import required components
|
||||||
import './dees-icon.js';
|
import '../dees-icon.js';
|
||||||
import './dees-windowcontrols.js';
|
import '../dees-windowcontrols.js';
|
||||||
import './dees-appui-profiledropdown.js';
|
import '../dees-appui-profiledropdown.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -73,259 +73,16 @@ export class DeesAppuiBar extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
private isProfileDropdownOpen: boolean = false;
|
private isProfileDropdownOpen: boolean = false;
|
||||||
|
|
||||||
public static styles = [
|
public static styles = 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;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
];
|
|
||||||
|
|
||||||
// INSTANCE
|
// INSTANCE
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
return html`
|
return renderAppuiAppbar(this);
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderMenuItems(): TemplateResult {
|
|
||||||
|
|
||||||
|
public renderMenuItems(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
${this.menuItems.map((item, index) => this.renderMenuItem(item, `menu-${index}`))}
|
${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) {
|
if (!this.breadcrumbs) {
|
||||||
return html``;
|
return html``;
|
||||||
}
|
}
|
||||||
@@ -417,7 +174,7 @@ export class DeesAppuiBar extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderAccountSection(): TemplateResult {
|
public renderAccountSection(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
${this.showSearch ? html`
|
${this.showSearch ? html`
|
||||||
<dees-icon
|
<dees-icon
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { html, css } from '@design.estate/dees-element';
|
import { html, css } from '@design.estate/dees-element';
|
||||||
import type { DeesAppuiBar } from './dees-appui-appbar.js';
|
import type { DeesAppuiBar } from './component.js';
|
||||||
import type { IAppBarMenuItem } from './interfaces/appbarmenuitem.js';
|
import type { IAppBarMenuItem } from '../interfaces/appbarmenuitem.js';
|
||||||
import '@design.estate/dees-wcctools/demotools';
|
import '@design.estate/dees-wcctools/demotools';
|
||||||
|
import './component.js';
|
||||||
|
|
||||||
export const demoFunc = () => {
|
export const demoFunc = () => {
|
||||||
// Sample menu items with various configurations
|
// 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)
|
// Main menu tabs (left sidebar)
|
||||||
const mainMenuTabs: ITab[] = [
|
const mainMenuTabs: ITab[] = [
|
||||||
{ key: 'dashboard', iconName: 'home', action: () => console.log('Dashboard selected') },
|
{ key: 'dashboard', iconName: 'lucide:home', action: () => console.log('Dashboard selected') },
|
||||||
{ key: 'projects', iconName: 'folder', action: () => console.log('Projects selected') },
|
{ key: 'projects', iconName: 'lucide:folder', action: () => console.log('Projects selected') },
|
||||||
{ key: 'analytics', iconName: 'lineChart', action: () => console.log('Analytics selected') },
|
{ key: 'analytics', iconName: 'lucide:lineChart', action: () => console.log('Analytics selected') },
|
||||||
{ key: 'settings', iconName: 'settings', action: () => console.log('Settings selected') },
|
{ key: 'settings', iconName: 'lucide:settings', action: () => console.log('Settings selected') },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Selector options (second sidebar)
|
// Selector options (second sidebar)
|
||||||
@@ -83,9 +83,9 @@ export const demoFunc = () => {
|
|||||||
|
|
||||||
// Main content tabs
|
// Main content tabs
|
||||||
const mainContentTabs: ITab[] = [
|
const mainContentTabs: ITab[] = [
|
||||||
{ key: 'Details', iconName: 'file', action: () => console.log('Details tab') },
|
{ key: 'Details', iconName: 'lucide:file', action: () => console.log('Details tab') },
|
||||||
{ key: 'Logs', iconName: 'list', action: () => console.log('Logs tab') },
|
{ key: 'Logs', iconName: 'lucide:list', action: () => console.log('Logs tab') },
|
||||||
{ key: 'Metrics', iconName: 'lineChart', action: () => console.log('Metrics tab') },
|
{ key: 'Metrics', iconName: 'lucide:lineChart', action: () => console.log('Metrics tab') },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Profile menu items
|
// Profile menu items
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import * as interfaces from './interfaces/index.js';
|
import * as interfaces from './interfaces/index.js';
|
||||||
import * as plugins from './00plugins.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 { DeesAppuiMainmenu } from './dees-appui-mainmenu.js';
|
||||||
import type { DeesAppuiMainselector } from './dees-appui-mainselector.js';
|
import type { DeesAppuiMainselector } from './dees-appui-mainselector.js';
|
||||||
import type { DeesAppuiMaincontent } from './dees-appui-maincontent.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 { demoFunc } from './dees-appui-base.demo.js';
|
||||||
|
|
||||||
// Import child components
|
// Import child components
|
||||||
import './dees-appui-appbar.js';
|
import './dees-appui-appbar/index.js';
|
||||||
import './dees-appui-mainmenu.js';
|
import './dees-appui-mainmenu.js';
|
||||||
import './dees-appui-mainselector.js';
|
import './dees-appui-mainselector.js';
|
||||||
import './dees-appui-maincontent.js';
|
import './dees-appui-maincontent.js';
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export class DeesAppuiMaincontent extends DeesElement {
|
|||||||
public static demo = () => html`
|
public static demo = () => html`
|
||||||
<dees-appui-maincontent
|
<dees-appui-maincontent
|
||||||
.tabs=${[
|
.tabs=${[
|
||||||
{ key: 'Overview', iconName: 'home', action: () => console.log('Overview') },
|
{ key: 'Overview', iconName: 'lucide:home', action: () => console.log('Overview') },
|
||||||
{ key: 'Details', iconName: 'file', action: () => console.log('Details') },
|
{ key: 'Details', iconName: 'lucide:file', action: () => console.log('Details') },
|
||||||
{ key: 'Settings', iconName: 'cog', action: () => console.log('Settings') },
|
{ key: 'Settings', iconName: 'lucide:settings', action: () => console.log('Settings') },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<div slot="content" style="padding: 40px; color: #ccc;">
|
<div slot="content" style="padding: 40px; color: #ccc;">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as plugins from './00plugins.js';
|
import * as plugins from './00plugins.js';
|
||||||
import * as interfaces from './interfaces/index.js';
|
import * as interfaces from './interfaces/index.js';
|
||||||
|
import { zIndexLayers } from './00zindex.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeesElement,
|
DeesElement,
|
||||||
@@ -21,10 +22,10 @@ export class DeesAppuiMainmenu extends DeesElement {
|
|||||||
public static demo = () => html`
|
public static demo = () => html`
|
||||||
<dees-appui-mainmenu
|
<dees-appui-mainmenu
|
||||||
.tabs=${[
|
.tabs=${[
|
||||||
{ key: 'Dashboard', iconName: 'home', action: () => console.log('Dashboard') },
|
{ key: 'Dashboard', iconName: 'lucide:home', action: () => console.log('Dashboard') },
|
||||||
{ key: 'Projects', iconName: 'folder', action: () => console.log('Projects') },
|
{ key: 'Projects', iconName: 'lucide:folder', action: () => console.log('Projects') },
|
||||||
{ key: 'Analytics', iconName: 'lineChart', action: () => console.log('Analytics') },
|
{ key: 'Analytics', iconName: 'lucide:lineChart', action: () => console.log('Analytics') },
|
||||||
{ key: 'Settings', iconName: 'settings', action: () => console.log('Settings') },
|
{ key: 'Settings', iconName: 'lucide:settings', action: () => console.log('Settings') },
|
||||||
]}
|
]}
|
||||||
></dees-appui-mainmenu>
|
></dees-appui-mainmenu>
|
||||||
`;
|
`;
|
||||||
@@ -34,7 +35,7 @@ export class DeesAppuiMainmenu extends DeesElement {
|
|||||||
// INSTANCE
|
// INSTANCE
|
||||||
@property({ type: Array })
|
@property({ type: Array })
|
||||||
public tabs: interfaces.ITab[] = [
|
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()
|
@property()
|
||||||
@@ -46,7 +47,7 @@ export class DeesAppuiMainmenu extends DeesElement {
|
|||||||
.mainContainer {
|
.mainContainer {
|
||||||
--menuSize: 60px;
|
--menuSize: 60px;
|
||||||
color: ${cssManager.bdTheme('#666', '#ccc')};
|
color: ${cssManager.bdTheme('#666', '#ccc')};
|
||||||
z-index: 10;
|
z-index: ${zIndexLayers.fixed.appBar};
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: var(--menuSize);
|
width: var(--menuSize);
|
||||||
@@ -111,7 +112,7 @@ export class DeesAppuiMainmenu extends DeesElement {
|
|||||||
this.updateTab(tabArg);
|
this.updateTab(tabArg);
|
||||||
}}"
|
}}"
|
||||||
>
|
>
|
||||||
<dees-icon .icon="${tabArg.iconName ? `lucide:${tabArg.iconName}` : ''}"></dees-icon>
|
<dees-icon .icon="${tabArg.iconName || ''}"></dees-icon>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as plugins from './00plugins.js';
|
import * as plugins from './00plugins.js';
|
||||||
|
import { zIndexLayers } from './00zindex.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeesElement,
|
DeesElement,
|
||||||
@@ -73,7 +74,7 @@ export class DeesAppuiProfileDropdown extends DeesElement {
|
|||||||
'0 4px 12px rgba(0, 0, 0, 0.15)',
|
'0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
'0 4px 12px rgba(0, 0, 0, 0.3)'
|
'0 4px 12px rgba(0, 0, 0, 0.3)'
|
||||||
)};
|
)};
|
||||||
z-index: 1000;
|
z-index: ${zIndexLayers.overlay.dropdown};
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.95) translateY(-10px);
|
transform: scale(0.95) translateY(-10px);
|
||||||
transition: opacity 0.2s, transform 0.2s;
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
@@ -258,7 +259,7 @@ export class DeesAppuiProfileDropdown extends DeesElement {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
z-index: 999;
|
z-index: ${zIndexLayers.backdrop.dropdown};
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -14,15 +14,94 @@ import * as domtools from '@design.estate/dees-domtools';
|
|||||||
|
|
||||||
@customElement('dees-appui-tabs')
|
@customElement('dees-appui-tabs')
|
||||||
export class DeesAppuiTabs extends DeesElement {
|
export class DeesAppuiTabs extends DeesElement {
|
||||||
public static demo = () => html`
|
public static demo = () => {
|
||||||
<dees-appui-tabs
|
const horizontalTabs: interfaces.ITab[] = [
|
||||||
.tabs=${[
|
{ key: 'Home', iconName: 'lucide:home', action: () => console.log('Home clicked') },
|
||||||
{ key: 'Tab 1', action: () => console.log('Tab 1 clicked') },
|
{ key: 'Analytics Dashboard', iconName: 'lucide:lineChart', action: () => console.log('Analytics clicked') },
|
||||||
{ key: 'Tab 2', action: () => console.log('Tab 2 clicked') },
|
{ key: 'Reports', iconName: 'lucide:fileText', action: () => console.log('Reports clicked') },
|
||||||
{ key: 'Tab 3', action: () => console.log('Tab 3 clicked') },
|
{ key: 'User Settings', iconName: 'lucide:settings', action: () => console.log('Settings clicked') },
|
||||||
]}
|
{ key: 'Help', iconName: 'lucide:helpCircle', action: () => console.log('Help clicked') },
|
||||||
></dees-appui-tabs>
|
];
|
||||||
`;
|
|
||||||
|
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
|
// INSTANCE
|
||||||
@property({
|
@property({
|
||||||
@@ -50,148 +129,217 @@ export class DeesAppuiTabs extends DeesElement {
|
|||||||
|
|
||||||
.tabs-wrapper {
|
.tabs-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: ${cssManager.bdTheme('#f5f5f5', '#000000')};
|
}
|
||||||
height: 52px;
|
|
||||||
|
.tabs-wrapper.horizontal-wrapper {
|
||||||
|
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabsContainer {
|
.tabsContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabsContainer.horizontal {
|
.tabsContainer.horizontal {
|
||||||
display: grid;
|
display: flex;
|
||||||
padding-top: 20px;
|
align-items: center;
|
||||||
padding-bottom: 0px;
|
|
||||||
margin-left: 24px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabsContainer.horizontal::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabsContainer.vertical {
|
.tabsContainer.vertical {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 20px;
|
padding: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
gap: 2px;
|
||||||
|
position: relative;
|
||||||
|
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
color: ${cssManager.bdTheme('#666', '#a0a0a0')};
|
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: default;
|
cursor: pointer;
|
||||||
transition: color 0.1s;
|
transition: color 0.15s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontal .tab {
|
.horizontal .tab {
|
||||||
margin-right: 30px;
|
padding: 0 16px;
|
||||||
padding-top: 4px;
|
height: 100%;
|
||||||
padding-bottom: 12px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vertical .tab {
|
.horizontal .tab:not(:last-child)::after {
|
||||||
padding: 12px 16px;
|
content: '';
|
||||||
margin-bottom: 4px;
|
position: absolute;
|
||||||
border-radius: 4px;
|
right: -2px;
|
||||||
width: 100%;
|
top: 50%;
|
||||||
display: flex;
|
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;
|
align-items: center;
|
||||||
gap: 8px;
|
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 {
|
.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 {
|
.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 {
|
.horizontal .tab.selectedTab {
|
||||||
color: ${cssManager.bdTheme('#333', '#e0e0e0')};
|
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal .tab.selectedTab::after,
|
||||||
|
.horizontal .tab.selectedTab + .tab::after {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vertical .tab.selectedTab {
|
.vertical .tab.selectedTab {
|
||||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||||
color: ${cssManager.bdTheme('#000', '#ffffff')};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab dees-icon {
|
.tab dees-icon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs-wrapper .tabIndicator {
|
.tabIndicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 0;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
left: 40px;
|
opacity: 0;
|
||||||
bottom: 0px;
|
}
|
||||||
height: 40px;
|
|
||||||
width: 40px;
|
.tabIndicator.no-transition {
|
||||||
background: ${cssManager.bdTheme('#ffffff', '#161616')};
|
transition: none;
|
||||||
transition: all 0.1s;
|
|
||||||
border-top-left-radius: 8px;
|
|
||||||
border-top-right-radius: 8px;
|
|
||||||
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444444')};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vertical .tabIndicator {
|
.tabs-wrapper .tabIndicator {
|
||||||
display: none;
|
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 {
|
.content {
|
||||||
margin-top: 20px;
|
padding: 32px 24px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
${this.tabStyle === 'horizontal' ? html`
|
${this.renderTabsWrapper()}
|
||||||
<style>
|
|
||||||
.tabsContainer.horizontal {
|
|
||||||
grid-template-columns: repeat(${this.tabs.length}, min-content);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<div class="tabs-wrapper">
|
|
||||||
<div class="tabsContainer horizontal">
|
|
||||||
${this.tabs.map((tabArg) => {
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="tab ${tabArg === this.selectedTab ? 'selectedTab' : ''}"
|
|
||||||
@click="${() => this.selectTab(tabArg)}"
|
|
||||||
>
|
|
||||||
${tabArg.key}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
${this.showTabIndicator ? html`
|
|
||||||
<div class="tabIndicator"></div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
` : html`
|
|
||||||
<div class="tabsContainer vertical">
|
|
||||||
${this.tabs.map((tabArg) => {
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="tab ${tabArg === this.selectedTab ? 'selectedTab' : ''}"
|
|
||||||
@click="${() => this.selectTab(tabArg)}"
|
|
||||||
>
|
|
||||||
${tabArg.iconName ? html`<dees-icon .iconName=${tabArg.iconName}></dees-icon>` : ''}
|
|
||||||
${tabArg.key}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</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) {
|
private selectTab(tabArg: interfaces.ITab) {
|
||||||
this.selectedTab = tabArg;
|
this.selectedTab = tabArg;
|
||||||
this.updateTabIndicator();
|
|
||||||
tabArg.action();
|
tabArg.action();
|
||||||
|
|
||||||
// Emit tab-select event
|
// 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() {
|
firstUpdated() {
|
||||||
if (this.tabs && this.tabs.length > 0) {
|
if (this.tabs && this.tabs.length > 0) {
|
||||||
this.selectTab(this.tabs[0]);
|
this.selectTab(this.tabs[0]);
|
||||||
@@ -241,7 +364,88 @@ export class DeesAppuiTabs extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changedProperties.has('selectedTab') || changedProperties.has('tabs')) {
|
if (changedProperties.has('selectedTab') || changedProperties.has('tabs')) {
|
||||||
this.updateTabIndicator();
|
await this.updateComplete;
|
||||||
|
// Wait for fonts to load on first update
|
||||||
|
if (!this.indicatorInitialized && document.fonts) {
|
||||||
|
await document.fonts.ready;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.updateTabIndicator();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private indicatorInitialized = false;
|
||||||
|
|
||||||
|
private updateTabIndicator() {
|
||||||
|
if (!this.shouldShowIndicator()) return;
|
||||||
|
|
||||||
|
const selectedTabElement = this.getSelectedTabElement();
|
||||||
|
if (!selectedTabElement) return;
|
||||||
|
|
||||||
|
const indicator = this.getIndicatorElement();
|
||||||
|
if (!indicator) return;
|
||||||
|
|
||||||
|
this.handleInitialTransition(indicator);
|
||||||
|
|
||||||
|
if (this.tabStyle === 'horizontal') {
|
||||||
|
this.updateHorizontalIndicator(indicator, selectedTabElement);
|
||||||
|
} else {
|
||||||
|
this.updateVerticalIndicator(indicator, selectedTabElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldShowIndicator(): boolean {
|
||||||
|
return this.selectedTab && this.showTabIndicator && this.tabs.includes(this.selectedTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSelectedTabElement(): HTMLElement | null {
|
||||||
|
const selectedIndex = this.tabs.indexOf(this.selectedTab);
|
||||||
|
const isHorizontal = this.tabStyle === 'horizontal';
|
||||||
|
const selector = isHorizontal
|
||||||
|
? `.tabs-wrapper .tabsContainer .tab:nth-child(${selectedIndex + 1})`
|
||||||
|
: `.vertical-wrapper .tabsContainer .tab:nth-child(${selectedIndex + 1})`;
|
||||||
|
|
||||||
|
return this.shadowRoot.querySelector(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIndicatorElement(): HTMLElement | null {
|
||||||
|
return this.shadowRoot.querySelector('.tabIndicator');
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInitialTransition(indicator: HTMLElement): void {
|
||||||
|
if (!this.indicatorInitialized) {
|
||||||
|
indicator.classList.add('no-transition');
|
||||||
|
this.indicatorInitialized = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
indicator.classList.remove('no-transition');
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateHorizontalIndicator(indicator: HTMLElement, tabElement: HTMLElement): void {
|
||||||
|
const tabContent = tabElement.querySelector('.tab-content') as HTMLElement;
|
||||||
|
if (!tabContent) return;
|
||||||
|
|
||||||
|
const wrapperRect = indicator.parentElement.getBoundingClientRect();
|
||||||
|
const contentRect = tabContent.getBoundingClientRect();
|
||||||
|
|
||||||
|
const contentLeft = contentRect.left - wrapperRect.left;
|
||||||
|
const indicatorWidth = contentRect.width + 8;
|
||||||
|
const indicatorLeft = contentLeft - 4;
|
||||||
|
|
||||||
|
indicator.style.width = `${indicatorWidth}px`;
|
||||||
|
indicator.style.left = `${indicatorLeft}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateVerticalIndicator(indicator: HTMLElement, tabElement: HTMLElement): void {
|
||||||
|
const tabsContainer = this.shadowRoot.querySelector('.vertical-wrapper .tabsContainer') as HTMLElement;
|
||||||
|
if (!tabsContainer) return;
|
||||||
|
|
||||||
|
indicator.style.top = `${tabElement.offsetTop + tabsContainer.offsetTop}px`;
|
||||||
|
indicator.style.height = `${tabElement.clientHeight}px`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -35,17 +35,17 @@ export class DeesAppuiView extends DeesElement {
|
|||||||
id: 'demo-view',
|
id: 'demo-view',
|
||||||
name: 'Demo View',
|
name: 'Demo View',
|
||||||
description: 'A demonstration view',
|
description: 'A demonstration view',
|
||||||
iconName: 'home',
|
iconName: 'lucide:home',
|
||||||
tabs: [
|
tabs: [
|
||||||
{
|
{
|
||||||
key: 'overview',
|
key: 'overview',
|
||||||
iconName: 'chart-line',
|
iconName: 'lucide:lineChart',
|
||||||
action: () => console.log('Overview tab'),
|
action: () => console.log('Overview tab'),
|
||||||
content: html`<div style="padding: 20px;">Overview Content</div>`
|
content: html`<div style="padding: 20px;">Overview Content</div>`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'details',
|
key: 'details',
|
||||||
iconName: 'file-alt',
|
iconName: 'lucide:fileText',
|
||||||
action: () => console.log('Details tab'),
|
action: () => console.log('Details tab'),
|
||||||
content: html`<div style="padding: 20px;">Details Content</div>`
|
content: html`<div style="padding: 20px;">Details Content</div>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,421 @@
|
|||||||
import { html } 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`
|
export const demoFunc = () => html`
|
||||||
<dees-button>This is a slotted Text</dees-button>
|
<style>
|
||||||
<p>
|
${css`
|
||||||
<dees-button text="Highlighted: This text shows" type="highlighted">Highlighted</dees-button>
|
.demo-container {
|
||||||
</p>
|
display: flex;
|
||||||
<p><dees-button type="discreet">This is discreete button</dees-button></p>
|
flex-direction: column;
|
||||||
<p><dees-button disabled>This is a disabled button</dees-button></p>
|
gap: 24px;
|
||||||
<p><dees-button type="big">This is a slotted Text</dees-button></p>
|
padding: 24px;
|
||||||
<p><dees-button status="normal">Normal Status</dees-button></p>
|
max-width: 1200px;
|
||||||
<p><dees-button disabled status="pending">Pending Status</dees-button></p>
|
margin: 0 auto;
|
||||||
<p><dees-button disabled status="success">Success Status</dees-button></p>
|
}
|
||||||
<p><dees-button disabled status="error">Error Status</dees-button></p>
|
|
||||||
`;
|
dees-panel {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dees-panel:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-output {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-snippet {
|
||||||
|
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 11.8%)')};
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="demo-container">
|
||||||
|
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||||
|
// Log button clicks for demo purposes
|
||||||
|
const buttons = elementArg.querySelectorAll('dees-button');
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
button.addEventListener('clicked', () => {
|
||||||
|
const type = button.getAttribute('type') || 'default';
|
||||||
|
console.log(`Button variant clicked: ${type}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<dees-panel .title=${'1. Button Variants'} .subtitle=${'Different visual styles for various use cases'}>
|
||||||
|
<div class="button-group">
|
||||||
|
<dees-button type="default">Default</dees-button>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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" 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 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 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>
|
||||||
|
|
||||||
|
<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 {
|
import {
|
||||||
customElement,
|
customElement,
|
||||||
html,
|
html,
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
import * as domtools from '@design.estate/dees-domtools';
|
import * as domtools from '@design.estate/dees-domtools';
|
||||||
|
import { demoFunc } from './dees-button.demo.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -48,138 +48,322 @@ export class DeesButton extends DeesElement {
|
|||||||
@property({
|
@property({
|
||||||
type: String
|
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({
|
@property({
|
||||||
type: String
|
type: String
|
||||||
})
|
})
|
||||||
public status: 'normal' | 'pending' | 'success' | 'error' = 'normal';
|
public status: 'normal' | 'pending' | 'success' | 'error' = 'normal';
|
||||||
|
|
||||||
|
@property({
|
||||||
|
type: Boolean,
|
||||||
|
reflect: true
|
||||||
|
})
|
||||||
|
public insideForm: boolean = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async connectedCallback() {
|
||||||
|
await super.connectedCallback();
|
||||||
|
// Auto-detect if inside a form
|
||||||
|
if (!this.insideForm && this.closest('dees-form')) {
|
||||||
|
this.insideForm = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: inline-block;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: 'Geist Sans', 'monospace';
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
:host([hidden]) {
|
:host([hidden]) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form spacing styles */
|
||||||
|
:host([inside-form]) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([inside-form]:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dees-form[horizontal-layout] :host([inside-form]) {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 16px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dees-form[horizontal-layout] :host([inside-form]:last-child) {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
transition: all 0.1s , color 0s;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
font-size: 14px;
|
display: inline-flex;
|
||||||
font-weight: 400;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: ${cssManager.bdTheme('#fff', '#333')};
|
justify-content: center;
|
||||||
box-shadow: ${cssManager.bdTheme('0px 1px 3px rgba(0,0,0,0.3)', 'none')};
|
white-space: nowrap;
|
||||||
border: 1px solid ${cssManager.bdTheme('#eee', '#333')};
|
border-radius: 6px;
|
||||||
border-top: ${cssManager.bdTheme('1px solid #eee', '1px solid #444')};
|
font-weight: 500;
|
||||||
border-radius: 4px;
|
transition: all 0.15s ease;
|
||||||
height: 40px;
|
cursor: pointer;
|
||||||
padding: 0px 8px;
|
|
||||||
min-width: 100px;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
color: ${cssManager.bdTheme('#333', ' #ccc')};
|
outline: none;
|
||||||
max-width: 500px;
|
letter-spacing: -0.01em;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
/* Size variants */
|
||||||
background: #0050b9;
|
.button.size-default {
|
||||||
color: #ffffff;
|
height: 36px;
|
||||||
border: 1px solid #0050b9;
|
padding: 0 16px;
|
||||||
border-top: 1px solid #0050b9;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:active {
|
.button.size-sm {
|
||||||
background: #0069f2;
|
height: 32px;
|
||||||
border-top: 1px solid #0069f2;
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.highlighted {
|
.button.size-lg {
|
||||||
background: #e4002b;
|
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;
|
border: none;
|
||||||
color: #fff;
|
text-decoration: underline;
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
height: auto;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.highlighted:hover {
|
.button.link:hover:not(.disabled) {
|
||||||
background: #b50021;
|
text-decoration-color: currentColor;
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.discreet {
|
/* Status states */
|
||||||
background: none;
|
.button.pending,
|
||||||
border: 1px solid #9b9b9e;
|
.button.success,
|
||||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
.button.error {
|
||||||
|
pointer-events: none;
|
||||||
|
padding-left: 36px; /* Space for spinner */
|
||||||
|
}
|
||||||
|
|
||||||
|
.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.discreet:hover {
|
.button.pending {
|
||||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
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 {
|
.button.disabled {
|
||||||
background: ${cssManager.bdTheme('#ffffff00', '#11111100')};
|
opacity: 0.5;
|
||||||
border: 1px dashed ${cssManager.bdTheme('#666666', '#666666')};
|
cursor: not-allowed;
|
||||||
color: #9b9b9e;
|
pointer-events: none;
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hidden state */
|
||||||
.button.hidden {
|
.button.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.big {
|
/* Focus state */
|
||||||
width: 300px;
|
.button:focus-visible {
|
||||||
line-height: 48px;
|
outline: 2px solid ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||||
font-size: 16px;
|
outline-offset: 2px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
dees-spinner {
|
dees-spinner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 10px;
|
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 {
|
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`
|
return html`
|
||||||
<div
|
<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'
|
? 'disabled'
|
||||||
: null}"
|
: ''}"
|
||||||
@click="${this.dispatchClick}"
|
@click="${this.dispatchClick}"
|
||||||
>
|
>
|
||||||
${this.status === 'normal' ? html``: html`
|
${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 class="textbox">${this.text || html`<slot>Button</slot>`}</div>
|
||||||
</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 { html, css, cssManager } from '@design.estate/dees-element';
|
||||||
import type { DeesChartArea } from './dees-chart-area.js';
|
import type { DeesChartArea } from './component.js';
|
||||||
import '@design.estate/dees-wcctools/demotools';
|
import '@design.estate/dees-wcctools/demotools';
|
||||||
|
import './component.js';
|
||||||
|
|
||||||
export const demoFunc = () => {
|
export const demoFunc = () => {
|
||||||
// Initial dataset values
|
// Initial dataset values
|
||||||
@@ -402,7 +403,7 @@ export const demoFunc = () => {
|
|||||||
${css`
|
${css`
|
||||||
.demoBox {
|
.demoBox {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #000000;
|
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 9%)')};
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
@@ -425,9 +426,9 @@ export const demoFunc = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
color: #666;
|
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: 'Geist Sans', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Geist Sans', sans-serif;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 8px;
|
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,
|
cssManager.defaultStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
font-family: 'Geist Mono', 'Consolas', 'Monaco', monospace;
|
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
|
||||||
color: #ccc;
|
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.mainbox {
|
.mainbox {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 400px;
|
height: 400px;
|
||||||
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||||
border: 1px solid ${cssManager.bdTheme('#dee2e6', '#333')};
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -71,9 +71,9 @@ export class DeesChartLog extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: ${cssManager.bdTheme('#e9ecef', '#1a1a1a')};
|
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
|
||||||
padding: 8px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid ${cssManager.bdTheme('#dee2e6', '#333')};
|
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -81,8 +81,10 @@ export class DeesChartLog extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: ${cssManager.bdTheme('#212529', '#fff')};
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
@@ -91,44 +93,48 @@ export class DeesChartLog extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.control-button {
|
.control-button {
|
||||||
background: ${cssManager.bdTheme('#e9ecef', '#2a2a2a')};
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')};
|
||||||
border: 1px solid ${cssManager.bdTheme('#ced4da', '#444')};
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
padding: 4px 8px;
|
padding: 6px 12px;
|
||||||
color: ${cssManager.bdTheme('#495057', '#ccc')};
|
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
transition: all 0.2s;
|
font-weight: 500;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button:hover {
|
.control-button:hover {
|
||||||
background: ${cssManager.bdTheme('#dee2e6', '#3a3a3a')};
|
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||||
border-color: ${cssManager.bdTheme('#adb5bd', '#555')};
|
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 {
|
.control-button.active {
|
||||||
background: ${cssManager.bdTheme('#007bff', '#4a4a4a')};
|
background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 93.9%)')};
|
||||||
color: ${cssManager.bdTheme('#fff', '#fff')};
|
color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.logContainer {
|
.logContainer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 8px 16px;
|
padding: 16px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logEntry {
|
.logEntry {
|
||||||
margin-bottom: 2px;
|
margin-bottom: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp {
|
.timestamp {
|
||||||
color: ${cssManager.bdTheme('#6c757d', '#666')};
|
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||||
margin-right: 8px;
|
margin-right: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,38 +149,38 @@ export class DeesChartLog extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.level.debug {
|
.level.debug {
|
||||||
color: ${cssManager.bdTheme('#6c757d', '#999')};
|
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||||
background: ${cssManager.bdTheme('rgba(108, 117, 125, 0.1)', '#333')};
|
background: ${cssManager.bdTheme('hsl(0 0% 45.1% / 0.1)', 'hsl(0 0% 63.9% / 0.1)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.level.info {
|
.level.info {
|
||||||
color: ${cssManager.bdTheme('#0066cc', '#4a9eff')};
|
color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||||
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.1)', 'rgba(74, 158, 255, 0.1)')};
|
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.level.warn {
|
.level.warn {
|
||||||
color: ${cssManager.bdTheme('#ff8800', '#ffb84a')};
|
color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
|
||||||
background: ${cssManager.bdTheme('rgba(255, 136, 0, 0.1)', 'rgba(255, 184, 74, 0.1)')};
|
background: ${cssManager.bdTheme('hsl(25 95% 53% / 0.1)', 'hsl(25 95% 63% / 0.1)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.level.error {
|
.level.error {
|
||||||
color: ${cssManager.bdTheme('#dc3545', '#ff4a4a')};
|
color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
|
||||||
background: ${cssManager.bdTheme('rgba(220, 53, 69, 0.1)', 'rgba(255, 74, 74, 0.1)')};
|
background: ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.1)', 'hsl(0 72.2% 50.6% / 0.1)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.level.success {
|
.level.success {
|
||||||
color: ${cssManager.bdTheme('#28a745', '#4aff88')};
|
color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
|
||||||
background: ${cssManager.bdTheme('rgba(40, 167, 69, 0.1)', 'rgba(74, 255, 136, 0.1)')};
|
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.source {
|
.source {
|
||||||
color: ${cssManager.bdTheme('#6c757d', '#888')};
|
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
color: ${cssManager.bdTheme('#212529', '#ddd')};
|
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +189,7 @@ export class DeesChartLog extends DeesElement {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: ${cssManager.bdTheme('#6c757d', '#666')};
|
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,16 +199,16 @@ export class DeesChartLog extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logContainer::-webkit-scrollbar-track {
|
.logContainer::-webkit-scrollbar-track {
|
||||||
background: ${cssManager.bdTheme('#e9ecef', '#1a1a1a')};
|
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 10%)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.logContainer::-webkit-scrollbar-thumb {
|
.logContainer::-webkit-scrollbar-thumb {
|
||||||
background: ${cssManager.bdTheme('#adb5bd', '#444')};
|
background: ${cssManager.bdTheme('hsl(0 0% 70%)', 'hsl(0 0% 30%)')};
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logContainer::-webkit-scrollbar-thumb:hover {
|
.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`
|
export const demoFunc = () => html`
|
||||||
<style>
|
<style>
|
||||||
.demoContainer {
|
.demoContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 32px;
|
||||||
justify-content: center;
|
padding: 48px;
|
||||||
height: 100%;
|
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
|
||||||
background: #222;
|
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>
|
</style>
|
||||||
<div class="demoContainer">
|
<div class="demoContainer">
|
||||||
<dees-chips
|
<div class="section">
|
||||||
selectionMode="none"
|
<div class="section-title">Non-Selectable Chips</div>
|
||||||
.selectableChips=${[
|
<div class="section-description">Basic chips without selection capability. Use for display-only tags.</div>
|
||||||
{ key: 'account1', value: 'Payment Account 1' },
|
<dees-chips
|
||||||
{ key: 'account2', value: 'PaymentAccount2' },
|
selectionMode="none"
|
||||||
{ key: 'account3', value: 'Payment Account 3' },
|
.selectableChips=${[
|
||||||
]}
|
{ key: 'status', value: 'Active' },
|
||||||
></dees-chips>
|
{ key: 'tier', value: 'Premium' },
|
||||||
<dees-chips
|
{ key: 'region', value: 'EU-West' },
|
||||||
selectionMode="single"
|
{ key: 'type', value: 'Enterprise' },
|
||||||
chipsAreRemovable
|
]}
|
||||||
.selectableChips=${[
|
></dees-chips>
|
||||||
{ key: 'account1', value: 'Payment Account 1' },
|
</div>
|
||||||
{ key: 'account2', value: 'PaymentAccount2' },
|
|
||||||
{ key: 'account3', value: 'Payment Account 3' },
|
<div class="section">
|
||||||
]}
|
<div class="section-title">Single Selection Chips</div>
|
||||||
></dees-chips>
|
<div class="section-description">Click to select one chip at a time. Useful for filters and options.</div>
|
||||||
<dees-chips
|
<dees-chips
|
||||||
selectionMode="multiple"
|
selectionMode="single"
|
||||||
.selectableChips=${[
|
.selectableChips=${[
|
||||||
{ key: 'account1', value: 'Payment Account 1' },
|
{ key: 'all', value: 'All Projects' },
|
||||||
{ key: 'account2', value: 'PaymentAccount2' },
|
{ key: 'active', value: 'Active' },
|
||||||
{ key: 'account3', value: 'Payment Account 3' },
|
{ key: 'archived', value: 'Archived' },
|
||||||
]}
|
{ key: 'drafts', value: 'Drafts' },
|
||||||
></dees-chips>
|
]}
|
||||||
|
></dees-chips>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Multiple Selection Chips</div>
|
||||||
|
<div class="section-description">Select multiple chips simultaneously. Great for tag selection.</div>
|
||||||
|
<dees-chips
|
||||||
|
selectionMode="multiple"
|
||||||
|
.selectableChips=${[
|
||||||
|
{ key: 'js', value: 'JavaScript' },
|
||||||
|
{ key: 'ts', value: 'TypeScript' },
|
||||||
|
{ key: 'react', value: 'React' },
|
||||||
|
{ key: 'vue', value: 'Vue' },
|
||||||
|
{ key: 'angular', value: 'Angular' },
|
||||||
|
{ key: 'node', value: 'Node.js' },
|
||||||
|
]}
|
||||||
|
></dees-chips>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Removable Chips with Keys</div>
|
||||||
|
<div class="section-description">Chips with remove buttons and key-value pairs. Perfect for dynamic lists.</div>
|
||||||
|
<dees-chips
|
||||||
|
selectionMode="single"
|
||||||
|
chipsAreRemovable
|
||||||
|
.selectableChips=${[
|
||||||
|
{ key: 'env', value: 'Production' },
|
||||||
|
{ key: 'version', value: '2.4.1' },
|
||||||
|
{ key: 'branch', value: 'main' },
|
||||||
|
{ key: 'author', value: 'John Doe' },
|
||||||
|
]}
|
||||||
|
></dees-chips>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Mixed Content Example</div>
|
||||||
|
<div class="section-description">Combining different chip types for complex UIs.</div>
|
||||||
|
<dees-chips
|
||||||
|
selectionMode="multiple"
|
||||||
|
chipsAreRemovable
|
||||||
|
.selectableChips=${[
|
||||||
|
{ key: 'priority', value: 'High' },
|
||||||
|
{ key: 'status', value: 'In Progress' },
|
||||||
|
{ key: 'bug', value: 'Bug' },
|
||||||
|
{ key: 'feature', value: 'Feature' },
|
||||||
|
{ key: 'sprint', value: 'Sprint 23' },
|
||||||
|
{ key: 'assignee', value: 'Alice' },
|
||||||
|
]}
|
||||||
|
></dees-chips>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -60,52 +60,93 @@ export class DeesChips extends DeesElement {
|
|||||||
|
|
||||||
.mainbox {
|
.mainbox {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
border-top: ${cssManager.bdTheme('1px solid #CCC', '1px solid #444')};
|
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||||
background: #333333;
|
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
height: 20px;
|
align-items: center;
|
||||||
line-height: 20px;
|
height: 32px;
|
||||||
padding: 0px 8px;
|
padding: 0px 12px;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
color: #fff;
|
font-weight: 500;
|
||||||
border-radius: 40px;
|
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||||
margin-right: 4px;
|
border-radius: 6px;
|
||||||
margin-bottom: 4px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
cursor: pointer;
|
||||||
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.3);
|
transition: all 0.15s ease;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip:hover {
|
.chip:hover {
|
||||||
background: #666666;
|
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
|
||||||
|
border-color: ${cssManager.bdTheme('#d1d5db', '#52525b')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:active {
|
||||||
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip.selected {
|
.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 {
|
.chipKey {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')};
|
||||||
height: 100%;
|
height: 20px;
|
||||||
display: inline-block;
|
line-height: 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
margin-left: -8px;
|
margin-left: -8px;
|
||||||
padding-left: 8px;
|
padding: 0px 8px;
|
||||||
padding-right: 8px;
|
|
||||||
margin-right: 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 {
|
dees-icon {
|
||||||
padding: 0px 6px 0px 4px;
|
display: flex;
|
||||||
margin-left: 4px;
|
align-items: center;
|
||||||
margin-right: -8px;
|
justify-content: center;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
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 {
|
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
|
event.stopPropagation(); // prevent the selectChip event from being triggered
|
||||||
this.removeChip(chip);
|
this.removeChip(chip);
|
||||||
}}
|
}}
|
||||||
.iconFA=${'xmark'}
|
.icon=${'fa:xmark'}
|
||||||
></dees-icon>
|
></dees-icon>
|
||||||
`
|
`
|
||||||
: html``}
|
: html``}
|
||||||
@@ -139,19 +180,25 @@ export class DeesChips extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async firstUpdated() {
|
public async firstUpdated() {
|
||||||
if (!this.textContent) {
|
// Component initialized
|
||||||
this.textContent = 'Button';
|
|
||||||
this.performUpdate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSelected(chip: Tag): boolean {
|
private isSelected(chip: Tag): boolean {
|
||||||
if (this.selectionMode === 'single') {
|
if (this.selectionMode === 'single') {
|
||||||
return this.selectedChip?.key === chip.key;
|
return this.selectedChip ? this.isSameChip(this.selectedChip, chip) : false;
|
||||||
} else {
|
} 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) {
|
public async selectChip(chip: Tag) {
|
||||||
if (this.selectionMode === 'none') {
|
if (this.selectionMode === 'none') {
|
||||||
@@ -168,7 +215,7 @@ export class DeesChips extends DeesElement {
|
|||||||
}
|
}
|
||||||
} else if (this.selectionMode === 'multiple') {
|
} else if (this.selectionMode === 'multiple') {
|
||||||
if (this.isSelected(chip)) {
|
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 {
|
} else {
|
||||||
this.selectedChips = [...this.selectedChips, chip];
|
this.selectedChips = [...this.selectedChips, chip];
|
||||||
}
|
}
|
||||||
@@ -179,13 +226,13 @@ export class DeesChips extends DeesElement {
|
|||||||
|
|
||||||
public removeChip(chipToRemove: Tag): void {
|
public removeChip(chipToRemove: Tag): void {
|
||||||
// Remove the chip from selectableChips
|
// 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
|
// 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 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;
|
this.selectedChip = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,139 +13,203 @@ export const demoFunc = () => html`
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 40px;
|
padding: 20px;
|
||||||
background: #f5f5f5;
|
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
.demo-area {
|
.demo-area {
|
||||||
background: white;
|
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: context-menu;
|
cursor: context-menu;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.demo-area:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<div class="demo-container">
|
<div class="demo-container">
|
||||||
<div class="demo-area" @contextmenu=${(eventArg: MouseEvent) => {
|
<dees-panel heading="Basic Context Menu with Nested Submenus">
|
||||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
<div class="demo-area" @contextmenu=${(eventArg: MouseEvent) => {
|
||||||
{
|
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||||
name: 'Cut',
|
{
|
||||||
iconName: 'scissors',
|
name: 'File',
|
||||||
shortcut: 'Cmd+X',
|
iconName: 'fileText',
|
||||||
action: async () => {
|
action: async () => {}, // Parent items with submenus still need an action
|
||||||
console.log('Cut 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: 'Edit',
|
||||||
name: 'Copy',
|
iconName: 'edit3',
|
||||||
iconName: 'copy',
|
action: async () => {}, // Parent items with submenus still need an action
|
||||||
shortcut: 'Cmd+C',
|
submenu: [
|
||||||
action: async () => {
|
{ name: 'Cut', iconName: 'scissors', shortcut: 'Cmd+X', action: async () => console.log('Cut') },
|
||||||
console.log('Copy action');
|
{ 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: 'Find', iconName: 'search', shortcut: 'Cmd+F', action: async () => console.log('Find') },
|
||||||
|
{ name: 'Replace', iconName: 'repeat', shortcut: 'Cmd+H', action: async () => console.log('Replace') },
|
||||||
|
]
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
name: 'View',
|
||||||
name: 'Paste',
|
iconName: 'eye',
|
||||||
iconName: 'clipboard',
|
action: async () => {}, // Parent items with submenus still need an action
|
||||||
shortcut: 'Cmd+V',
|
submenu: [
|
||||||
action: async () => {
|
{ name: 'Zoom In', iconName: 'zoomIn', shortcut: 'Cmd++', action: async () => console.log('Zoom in') },
|
||||||
console.log('Paste action');
|
{ 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 },
|
||||||
{ divider: true },
|
{
|
||||||
{
|
name: 'Settings',
|
||||||
name: 'Delete',
|
iconName: 'settings',
|
||||||
iconName: 'trash2',
|
action: async () => console.log('Settings')
|
||||||
action: async () => {
|
|
||||||
console.log('Delete action');
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{ divider: true },
|
name: 'Help',
|
||||||
{
|
iconName: 'helpCircle',
|
||||||
name: 'Select All',
|
action: async () => {}, // Parent items with submenus still need an action
|
||||||
shortcut: 'Cmd+A',
|
submenu: [
|
||||||
action: async () => {
|
{ name: 'Documentation', iconName: 'book', action: async () => console.log('Documentation') },
|
||||||
console.log('Select All action');
|
{ 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',
|
||||||
<h3>Right-click anywhere in this area</h3>
|
action: async () => {}, // Parent items with submenus still need an action
|
||||||
<p>A context menu will appear with various options</p>
|
submenu: [
|
||||||
</div>
|
{ 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</dees-button>
|
||||||
|
</dees-panel>
|
||||||
|
|
||||||
|
<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-button @contextmenu=${(eventArg: MouseEvent) => {
|
<dees-panel heading="Static Context Menu (Always Visible)">
|
||||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
|
||||||
{
|
|
||||||
name: 'Button Action 1',
|
|
||||||
iconName: 'play',
|
|
||||||
action: async () => {
|
|
||||||
console.log('Button action 1');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Button Action 2',
|
|
||||||
iconName: 'pause',
|
|
||||||
action: async () => {
|
|
||||||
console.log('Button action 2');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Disabled Action',
|
|
||||||
iconName: 'ban',
|
|
||||||
disabled: true,
|
|
||||||
action: async () => {
|
|
||||||
console.log('This should not run');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ divider: true },
|
|
||||||
{
|
|
||||||
name: 'Settings',
|
|
||||||
iconName: 'settings',
|
|
||||||
action: async () => {
|
|
||||||
console.log('Settings');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}}>Right-click on this button for a different menu</dees-button>
|
|
||||||
|
|
||||||
<div style="margin-top: 20px;">
|
|
||||||
<h4>Static Context Menu (always visible):</h4>
|
|
||||||
<dees-contextmenu
|
<dees-contextmenu
|
||||||
class="withMargin"
|
class="withMargin"
|
||||||
.menuItems=${[
|
.menuItems=${[
|
||||||
{
|
{
|
||||||
name: 'New File',
|
name: 'Project',
|
||||||
iconName: 'filePlus',
|
iconName: 'folder',
|
||||||
shortcut: 'Cmd+N',
|
action: async () => {}, // Parent items with submenus still need an action
|
||||||
action: async () => console.log('New file'),
|
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',
|
name: 'Tools',
|
||||||
iconName: 'folderOpen',
|
iconName: 'tool',
|
||||||
shortcut: 'Cmd+O',
|
action: async () => {}, // Parent items with submenus still need an action
|
||||||
action: async () => console.log('Open file'),
|
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') },
|
||||||
name: 'Save',
|
{ divider: true },
|
||||||
iconName: 'save',
|
{ name: 'Extensions', iconName: 'package', action: async () => console.log('Extensions') },
|
||||||
shortcut: 'Cmd+S',
|
]
|
||||||
action: async () => console.log('Save'),
|
|
||||||
},
|
},
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{
|
{
|
||||||
name: 'Export',
|
name: 'Preferences',
|
||||||
iconName: 'download',
|
iconName: 'sliders',
|
||||||
action: async () => console.log('Export'),
|
action: async () => console.log('Preferences'),
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Import',
|
|
||||||
iconName: 'upload',
|
|
||||||
action: async () => console.log('Import'),
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
></dees-contextmenu>
|
></dees-contextmenu>
|
||||||
</div>
|
</dees-panel>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
|
|
||||||
import * as domtools from '@design.estate/dees-domtools';
|
import * as domtools from '@design.estate/dees-domtools';
|
||||||
import { DeesWindowLayer } from './dees-windowlayer.js';
|
import { DeesWindowLayer } from './dees-windowlayer.js';
|
||||||
|
import { zIndexLayers } from './00zindex.js';
|
||||||
import './dees-icon.js';
|
import './dees-icon.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -30,7 +31,7 @@ export class DeesContextmenu extends DeesElement {
|
|||||||
// STATIC
|
// STATIC
|
||||||
// This will store all the accumulated menu items
|
// This will store all the accumulated menu items
|
||||||
public static contextMenuDeactivated = false;
|
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
|
// Add a global event listener for the right-click context menu
|
||||||
public static initializeGlobalListener() {
|
public static initializeGlobalListener() {
|
||||||
@@ -40,16 +41,16 @@ export class DeesContextmenu extends DeesElement {
|
|||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// Get the target element of the right-click
|
|
||||||
let target: EventTarget | null = event.target;
|
|
||||||
|
|
||||||
// Clear previously accumulated items
|
// Clear previously accumulated items
|
||||||
DeesContextmenu.accumulatedMenuItems = [];
|
DeesContextmenu.accumulatedMenuItems = [];
|
||||||
|
|
||||||
// Traverse up the DOM tree to accumulate menu items
|
// Use composedPath to properly traverse shadow DOM boundaries
|
||||||
while (target) {
|
const path = event.composedPath();
|
||||||
if ((target as any).getContextMenuItems) {
|
|
||||||
const items = (target as any).getContextMenuItems();
|
// 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 (items && items.length > 0) {
|
||||||
if (DeesContextmenu.accumulatedMenuItems.length > 0) {
|
if (DeesContextmenu.accumulatedMenuItems.length > 0) {
|
||||||
DeesContextmenu.accumulatedMenuItems.push({ divider: true });
|
DeesContextmenu.accumulatedMenuItems.push({ divider: true });
|
||||||
@@ -57,7 +58,6 @@ export class DeesContextmenu extends DeesElement {
|
|||||||
DeesContextmenu.accumulatedMenuItems.push(...items);
|
DeesContextmenu.accumulatedMenuItems.push(...items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
target = (target as Node).parentNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the context menu with the accumulated items
|
// Open the context menu with the accumulated items
|
||||||
@@ -66,7 +66,7 @@ export class DeesContextmenu extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// allows opening of a contextmenu with options
|
// 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) {
|
if (this.contextMenuDeactivated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -74,13 +74,18 @@ export class DeesContextmenu extends DeesElement {
|
|||||||
eventArg.stopPropagation();
|
eventArg.stopPropagation();
|
||||||
const contextMenu = new DeesContextmenu();
|
const contextMenu = new DeesContextmenu();
|
||||||
contextMenu.style.position = 'fixed';
|
contextMenu.style.position = 'fixed';
|
||||||
contextMenu.style.zIndex = '10000';
|
contextMenu.style.zIndex = String(zIndexLayers.overlay.contextMenu);
|
||||||
contextMenu.style.opacity = '0';
|
contextMenu.style.opacity = '0';
|
||||||
contextMenu.style.transform = 'scale(0.95) translateY(-10px)';
|
contextMenu.style.transform = 'scale(0.95) translateY(-10px)';
|
||||||
contextMenu.menuItems = menuItemsArg;
|
contextMenu.menuItems = menuItemsArg;
|
||||||
contextMenu.windowLayer = await DeesWindowLayer.createAndShow();
|
contextMenu.windowLayer = await DeesWindowLayer.createAndShow();
|
||||||
contextMenu.windowLayer.addEventListener('click', async () => {
|
contextMenu.windowLayer.addEventListener('click', async (event) => {
|
||||||
await contextMenu.destroy();
|
// 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);
|
document.body.append(contextMenu);
|
||||||
|
|
||||||
@@ -122,8 +127,12 @@ export class DeesContextmenu extends DeesElement {
|
|||||||
@property({
|
@property({
|
||||||
type: Array,
|
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;
|
windowLayer: DeesWindowLayer;
|
||||||
|
|
||||||
|
private submenu: DeesContextmenu | null = null;
|
||||||
|
private submenuTimeout: any = null;
|
||||||
|
private parentMenu: DeesContextmenu | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -166,13 +175,22 @@ export class DeesContextmenu extends DeesElement {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuitem:hover {
|
.menuitem:hover {
|
||||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menuitem.has-submenu::after {
|
||||||
|
content: '›';
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.menuitem:active {
|
.menuitem:active:not(.has-submenu) {
|
||||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,14 +232,20 @@ export class DeesContextmenu extends DeesElement {
|
|||||||
return html`<div class="menu-divider"></div>`;
|
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`
|
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`
|
${menuItem.iconName ? html`
|
||||||
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>
|
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>
|
||||||
` : ''}
|
` : ''}
|
||||||
<span class="menuitem-text">${menuItem.name}</span>
|
<span class="menuitem-text">${menuItem.name}</span>
|
||||||
${menuItem.shortcut ? html`
|
${menuItem.shortcut && !hasSubmenu ? html`
|
||||||
<span class="menuitem-shortcut">${menuItem.shortcut}</span>
|
<span class="menuitem-shortcut">${menuItem.shortcut}</span>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -281,17 +305,151 @@ export class DeesContextmenu extends DeesElement {
|
|||||||
|
|
||||||
public async handleClick(menuItem: plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean }) {
|
public async handleClick(menuItem: plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean }) {
|
||||||
menuItem.action();
|
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() {
|
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.windowLayer.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.style.opacity = '0';
|
this.style.opacity = '0';
|
||||||
this.style.transform = 'scale(0.95) translateY(-10px)';
|
this.style.transform = 'scale(0.95) translateY(-10px)';
|
||||||
await domtools.plugins.smartdelay.delayFor(100);
|
await domtools.plugins.smartdelay.delayFor(100);
|
||||||
this.parentElement.removeChild(this);
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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`
|
||||||
.demoWrapper {
|
<style>
|
||||||
box-sizing: border-box;
|
.demoWrapper {
|
||||||
position: absolute;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
position: relative;
|
||||||
height: 100%;
|
width: 100%;
|
||||||
padding: 20px;
|
min-height: 100vh;
|
||||||
background: none;
|
padding: 48px;
|
||||||
|
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
max-width: 900px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="demoWrapper">
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">TypeScript Code Example</div>
|
||||||
|
<div class="section-description">A comprehensive TypeScript code example with various syntax highlighting.</div>
|
||||||
|
<dees-dataview-codebox proglang="typescript">
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
<div class="demoWrapper">
|
class UserService {
|
||||||
<dees-dataview-codebox proglang="typescript">
|
private users: User[] = [];
|
||||||
import * as text from './hello'; const hiThere = 'nice'; const myFunction = async () => {
|
|
||||||
console.log('nice one'); }
|
constructor(private apiUrl: string) {
|
||||||
</dees-dataview-codebox>
|
console.log('UserService initialized');
|
||||||
</div>`
|
}
|
||||||
|
|
||||||
|
async getUsers(): Promise<User[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiUrl);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.users;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch users:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addUser(user: User): void {
|
||||||
|
this.users.push(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage example
|
||||||
|
const service = new UserService('https://api.example.com/users');
|
||||||
|
const users = await service.getUsers();
|
||||||
|
console.log('Found users:', users.length);
|
||||||
|
</dees-dataview-codebox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">JavaScript Example</div>
|
||||||
|
<div class="section-description">Modern JavaScript with ES6+ features.</div>
|
||||||
|
<dees-dataview-codebox proglang="javascript">
|
||||||
|
// Array manipulation examples
|
||||||
|
const numbers = [1, 2, 3, 4, 5];
|
||||||
|
const doubled = numbers.map(n => n * 2);
|
||||||
|
const filtered = numbers.filter(n => n > 3);
|
||||||
|
|
||||||
|
// Object destructuring
|
||||||
|
const user = { name: 'John', age: 30, city: 'New York' };
|
||||||
|
const { name, age } = user;
|
||||||
|
|
||||||
|
// Promise handling
|
||||||
|
const fetchData = async (url) => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modern syntax
|
||||||
|
const greet = (name = 'World') => \`Hello, \${name}!\`;
|
||||||
|
console.log(greet('ShadCN'));
|
||||||
|
</dees-dataview-codebox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Python Example</div>
|
||||||
|
<div class="section-description">Python code with classes and type hints.</div>
|
||||||
|
<dees-dataview-codebox proglang="python">
|
||||||
|
from typing import List, Optional
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
class DataProcessor:
|
||||||
|
"""A simple data processor class"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
self.data: List[dict] = []
|
||||||
|
|
||||||
|
async def process_data(self, items: List[dict]) -> List[dict]:
|
||||||
|
"""Process data items asynchronously"""
|
||||||
|
results = []
|
||||||
|
for item in items:
|
||||||
|
# Simulate async processing
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
results.append({
|
||||||
|
'id': item.get('id'),
|
||||||
|
'processed': True,
|
||||||
|
'processor': self.name
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_summary(self) -> dict:
|
||||||
|
return {
|
||||||
|
'processor': self.name,
|
||||||
|
'items_processed': len(self.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
processor = DataProcessor("Main")
|
||||||
|
data = await processor.process_data([{'id': 1}, {'id': 2}])
|
||||||
|
</dees-dataview-codebox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">CSS Example</div>
|
||||||
|
<div class="section-description">Modern CSS with custom properties and animations. Note the shorter language label.</div>
|
||||||
|
<dees-dataview-codebox proglang="css">
|
||||||
|
/* Modern CSS with custom properties */
|
||||||
|
:root {
|
||||||
|
--primary-color: #3b82f6;
|
||||||
|
--secondary-color: #10b981;
|
||||||
|
--background: #ffffff;
|
||||||
|
--text-color: #09090b;
|
||||||
|
--border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</dees-dataview-codebox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">JSON Example</div>
|
||||||
|
<div class="section-description">JSON configuration with proper formatting.</div>
|
||||||
|
<dees-dataview-codebox proglang="json">
|
||||||
|
{
|
||||||
|
"name": "@design.estate/dees-catalog",
|
||||||
|
"version": "1.10.7",
|
||||||
|
"description": "A comprehensive catalog of web components",
|
||||||
|
"main": "dist_ts_web/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
|
||||||
|
"watch": "tswatch element",
|
||||||
|
"test": "tstest test/ --web --verbose"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@design.estate/dees-element": "^2.0.45",
|
||||||
|
"highlight.js": "^11.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</dees-dataview-codebox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
state,
|
state,
|
||||||
cssManager,
|
cssManager,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
import { cssGeistFontFamily, cssMonoFontFamily } from './00fonts.js';
|
||||||
|
|
||||||
import hlight from 'highlight.js';
|
import hlight from 'highlight.js';
|
||||||
|
|
||||||
@@ -48,27 +49,27 @@ export class DeesDataviewCodebox extends DeesElement {
|
|||||||
display: block;
|
display: block;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-family: 'Geist Sans', sans-serif;
|
font-family: ${cssGeistFontFamily};
|
||||||
}
|
}
|
||||||
.mainbox {
|
.mainbox {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: ${this.goBright ? '#333333' : '#ffffff'};
|
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||||
border-top: 1px solid ${this.goBright ? '#ffffff' : '#333333'};
|
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||||
box-shadow: 0px 0px 5px ${this.goBright ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.5)'};
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
background: ${this.goBright ? '#ffffff' : '#191919'};
|
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||||
border-radius: 16px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appbar {
|
.appbar {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||||
background: ${cssManager.bdTheme('#ffffff', '#161616')};
|
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
|
||||||
border-bottom: 1px solid ${cssManager.bdTheme('#eeeeeb', '#222222')};
|
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||||
height: 24px;
|
height: 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 24px;
|
line-height: 32px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -81,31 +82,38 @@ export class DeesDataviewCodebox extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bottomBar {
|
.bottomBar {
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
position: relative;
|
||||||
background: ${cssManager.bdTheme('#ffffff', '#161616')};
|
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||||
border-top: 1px solid ${cssManager.bdTheme('#eeeeeb', '#222222')};
|
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
|
||||||
height: 24px;
|
border-top: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||||
|
height: 28px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 24px;
|
line-height: 28px;
|
||||||
text-align: right;
|
display: flex;
|
||||||
padding-right: 100px;
|
justify-content: flex-end;
|
||||||
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacesLabel {
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.languageLabel {
|
.languageLabel {
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 24px;
|
line-height: 28px;
|
||||||
z-index: 10;
|
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
|
||||||
background: #6596ff20;
|
padding: 0px 16px;
|
||||||
display: inline-block;
|
font-weight: 500;
|
||||||
position: absolute;
|
display: flex;
|
||||||
bottom: 0px;
|
align-items: center;
|
||||||
right: 0px;
|
|
||||||
padding: 0px 16px 0px 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-keyword {
|
.hljs-keyword {
|
||||||
color: #ff65ec;
|
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.codegrid {
|
.codegrid {
|
||||||
@@ -115,10 +123,10 @@ export class DeesDataviewCodebox extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lineNumbers {
|
.lineNumbers {
|
||||||
color: ${this.goBright ? '#acacac' : '#666666'};
|
color: ${cssManager.bdTheme('#71717a', '#52525b')};
|
||||||
padding: 30px 16px 0px 0px;
|
padding: 24px 16px 0px 0px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
border-right: 1px solid ${this.goBright ? '#eaeaea' : '#222222'};
|
border-right: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.lineCounter:last-child {
|
.lineCounter:last-child {
|
||||||
@@ -128,11 +136,11 @@ export class DeesDataviewCodebox extends DeesElement {
|
|||||||
pre {
|
pre {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
padding: 30px 40px;
|
padding: 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-weight: ${this.goBright ? '400' : '300'};
|
font-weight: 400;
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
}
|
}
|
||||||
@@ -142,27 +150,43 @@ export class DeesDataviewCodebox extends DeesElement {
|
|||||||
.lineNumbers {
|
.lineNumbers {
|
||||||
line-height: 1.4em;
|
line-height: 1.4em;
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
font-family: 'Intel One Mono', 'Geist Mono', 'monospace';
|
font-family: ${cssMonoFontFamily};
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-string {
|
.hljs-string {
|
||||||
color: #ffa465;
|
color: ${cssManager.bdTheme('#059669', '#10b981')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-built_in {
|
.hljs-built_in {
|
||||||
color: #65ff6a;
|
color: ${cssManager.bdTheme('#8b5cf6', '#a78bfa')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-function {
|
.hljs-function {
|
||||||
color: ${this.goBright ? '#2765DF' : '#6596ff'};
|
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-params {
|
.hljs-params {
|
||||||
color: ${this.goBright ? '#3DB420' : '#65d5ff'};
|
color: ${cssManager.bdTheme('#0891b2', '#06b6d4')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-comment {
|
.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>
|
</style>
|
||||||
<div
|
<div
|
||||||
@@ -197,7 +221,7 @@ export class DeesDataviewCodebox extends DeesElement {
|
|||||||
<pre><code></code></pre>
|
<pre><code></code></pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="bottomBar">
|
<div class="bottomBar">
|
||||||
Spaces: 2
|
<div class="spacesLabel">Spaces: 2</div>
|
||||||
<div class="languageLabel">${this.progLang}</div>
|
<div class="languageLabel">${this.progLang}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,47 +3,162 @@ import * as tsclass from '@tsclass/tsclass';
|
|||||||
|
|
||||||
export const demoFunc = () => html` <style>
|
export const demoFunc = () => html` <style>
|
||||||
.demo {
|
.demo {
|
||||||
background: ${cssManager.bdTheme('#eeeeeb', '#000000')};
|
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
|
||||||
display: block;
|
display: block;
|
||||||
content: '';
|
content: '';
|
||||||
padding: 40px;
|
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>
|
</style>
|
||||||
<div class="demo">
|
<div class="demo">
|
||||||
<dees-dataview-statusobject
|
<div class="demo-note">
|
||||||
.statusObject=${{
|
Right-click on any detail row to copy the value, key, or key:value combination
|
||||||
id: '1',
|
</div>
|
||||||
name: 'Demo Item',
|
<div class="demo-grid">
|
||||||
combinedStatus: 'partly_ok',
|
<div class="demo-section">
|
||||||
combinedStatusText: 'partly_ok',
|
<div class="demo-title">Service Health Status</div>
|
||||||
details: [
|
<dees-dataview-statusobject
|
||||||
{
|
.statusObject=${{
|
||||||
name: 'Detail 1',
|
id: '1',
|
||||||
value: 'Value 1',
|
name: 'API Gateway Service',
|
||||||
status: 'ok',
|
combinedStatus: 'ok',
|
||||||
statusText: 'OK',
|
combinedStatusText: 'All systems operational',
|
||||||
},
|
details: [
|
||||||
{
|
{
|
||||||
name: 'Detail 2',
|
name: 'Response Time',
|
||||||
value: 'Value 2',
|
value: '45ms (avg)',
|
||||||
status: 'partly_ok',
|
status: 'ok',
|
||||||
statusText: 'partly_ok',
|
statusText: 'Within normal range',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Detail 3',
|
name: 'Uptime',
|
||||||
value: 'Value 3',
|
value: '99.99% (30 days)',
|
||||||
status: 'not_ok',
|
status: 'ok',
|
||||||
statusText: 'not_ok',
|
statusText: 'Excellent uptime',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Detail 4',
|
name: 'Active Connections',
|
||||||
value:
|
value: '1,234 / 10,000',
|
||||||
'Value 4 jhdkfjhalskdfjhfdjskalsdkfjhfdjskalskdjfhjdkslaksjdhfjdkslaskdfjhfjdkslaskdjfhjdskalskdjhfdjskalskdjfhdjskl',
|
status: 'ok',
|
||||||
status: 'ok',
|
statusText: 'Normal load',
|
||||||
statusText: 'OK',
|
},
|
||||||
},
|
{
|
||||||
],
|
name: 'SSL Certificate',
|
||||||
} as tsclass.code.IStatusObject}
|
value: 'Valid until 2024-12-31',
|
||||||
>
|
status: 'ok',
|
||||||
</dees-dataview-statusobject>
|
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>`;
|
</div>`;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
import * as tsclass from '@tsclass/tsclass';
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
|
import { DeesContextmenu } from './dees-contextmenu.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -31,109 +32,128 @@ export class DeesDataviewStatusobject extends DeesElement {
|
|||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
css`
|
css`
|
||||||
|
:host {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
.mainbox {
|
.mainbox {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: ${cssManager.bdTheme('#fff', '#1b1b1b')};
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||||
box-shadow: 0px 1px 3px #00000030;
|
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;
|
min-height: 48px;
|
||||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
|
||||||
border-top: ${cssManager.bdTheme('none', '1px solid #ffffff10')};
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
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 {
|
h1 {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
padding: 0px;
|
padding: 0px 12px;
|
||||||
height: 48px;
|
font-size: 14px;
|
||||||
text-transform: uppercase;
|
font-weight: 500;
|
||||||
font-size: 12px;
|
letter-spacing: -0.01em;
|
||||||
line-height: 48px;
|
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusdot {
|
.statusdot {
|
||||||
height: 8px;
|
height: 10px;
|
||||||
width: 8px;
|
width: 10px;
|
||||||
border-radius: 6px;
|
border-radius: 50%;
|
||||||
background: grey;
|
background: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||||
margin: auto;
|
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 {
|
.copyMain {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')};
|
||||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444')};
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4px;
|
padding: 6px 12px;
|
||||||
border-radius: 3px;
|
border-radius: 6px;
|
||||||
margin-right: 16px;
|
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||||
color: ${cssManager.bdTheme('#333', '#ffffff80')};
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copyMain:hover {
|
.copyMain:hover {
|
||||||
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
|
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||||
border: 1px solid ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
|
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||||
color: #fff;
|
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.copyMain:active {
|
.copyMain:active {
|
||||||
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
|
background: ${cssManager.bdTheme('hsl(0 0% 91%)', 'hsl(0 0% 14.9%)')};
|
||||||
border: 1px solid ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
|
transform: scale(0.98);
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusdot.ok {
|
.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{
|
.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 {
|
.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 {
|
.detail {
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px auto;
|
grid-template-columns: 48px auto;
|
||||||
border-top: 1px dotted ${cssManager.bdTheme('#e0e0e0', '#282828')};
|
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(0 0% 14.9%)')};
|
||||||
transition: all 0.2s;
|
transition: background-color 0.15s ease;
|
||||||
|
padding-right: 16px;
|
||||||
|
cursor: context-menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail:hover {
|
.detail:hover {
|
||||||
background: #ffffff05;
|
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail:active {
|
.detail:active {
|
||||||
background: #ffffff10;
|
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 9%)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail .detailsText {
|
.detail .detailsText {
|
||||||
padding-top: 8px;
|
padding: 12px;
|
||||||
padding-bottom: 8px;
|
|
||||||
padding-right: 8px;
|
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail .detailsText .label {
|
.detail .detailsText .label {
|
||||||
font-size: 12px;
|
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 {
|
.detail .detailsText .value {
|
||||||
font-size: 14px;
|
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="mainbox">
|
||||||
<div class="heading">
|
<div class="heading">
|
||||||
<div class="statusdot ${this.statusObject?.combinedStatus}"></div>
|
<div class="statusdot ${this.statusObject?.combinedStatus}"></div>
|
||||||
<h1>${this.statusObject?.name || 'no status object assigned'}</h1>
|
<h1>${this.statusObject?.name || 'No status object assigned'}</h1>
|
||||||
<div class="copyMain">Copy as JSON</div>
|
<div class="copyMain" @click=${this.handleCopyAsJson}>Copy JSON</div>
|
||||||
</div>
|
</div>
|
||||||
${this.statusObject?.details?.map((detailArg) => {
|
${this.statusObject?.details?.map((detailArg) => {
|
||||||
return html`
|
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="statusdot ${detailArg.status}"></div>
|
||||||
<div class="detailsText">
|
<div class="detailsText">
|
||||||
<div class="label">${detailArg.name}</div>
|
<div class="label">${detailArg.name}</div>
|
||||||
@@ -162,4 +210,42 @@ export class DeesDataviewStatusobject extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async firstUpdated() {}
|
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,
|
cssManager,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import * as domtools from '@design.estate/dees-domtools';
|
import * as domtools from '@design.estate/dees-domtools';
|
||||||
|
import { MONACO_VERSION } from './version.js';
|
||||||
|
|
||||||
import type * as monaco from 'monaco-editor';
|
import type * as monaco from 'monaco-editor';
|
||||||
|
|
||||||
@@ -80,10 +81,11 @@ export class DeesEditor extends DeesElement {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
super.firstUpdated(_changedProperties);
|
super.firstUpdated(_changedProperties);
|
||||||
const container = this.shadowRoot.getElementById('container');
|
const container = this.shadowRoot.getElementById('container');
|
||||||
|
const monacoCdnBase = `https://cdn.jsdelivr.net/npm/monaco-editor@${MONACO_VERSION}`;
|
||||||
|
|
||||||
if (!DeesEditor.monacoDeferred) {
|
if (!DeesEditor.monacoDeferred) {
|
||||||
DeesEditor.monacoDeferred = domtools.plugins.smartpromise.defer();
|
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');
|
const script = document.createElement('script');
|
||||||
script.src = scriptUrl;
|
script.src = scriptUrl;
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
@@ -94,7 +96,7 @@ export class DeesEditor extends DeesElement {
|
|||||||
await DeesEditor.monacoDeferred.promise;
|
await DeesEditor.monacoDeferred.promise;
|
||||||
|
|
||||||
(window as any).require.config({
|
(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 () => {
|
(window as any).require(['vs/editor/editor.main'], async () => {
|
||||||
const editor = ((window as any).monaco.editor as typeof monaco.editor).create(container, {
|
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);
|
this.editorDeferred.resolve(editor);
|
||||||
});
|
});
|
||||||
const css = await (
|
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();
|
).text();
|
||||||
const styleElement = document.createElement('style');
|
const styleElement = document.createElement('style');
|
||||||
styleElement.textContent = css;
|
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';
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
cssManager,
|
cssManager,
|
||||||
property,
|
property,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import { DeesForm } from './dees-form.js';
|
import type { DeesForm } from './dees-form.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -57,9 +57,10 @@ export class DeesFormSubmit extends DeesElement {
|
|||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parentElement: DeesForm = this.parentElement as DeesForm;
|
// Walk up the DOM tree to find the nearest dees-form element
|
||||||
if (parentElement && parentElement.gatherAndDispatch) {
|
const parentFormElement = this.closest('dees-form') as DeesForm;
|
||||||
parentElement.gatherAndDispatch();
|
if (parentFormElement && parentFormElement.gatherAndDispatch) {
|
||||||
|
parentFormElement.gatherAndDispatch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,246 +3,311 @@ import type { DeesForm } from './dees-form.js';
|
|||||||
import '@design.estate/dees-wcctools/demotools';
|
import '@design.estate/dees-wcctools/demotools';
|
||||||
|
|
||||||
export const demoFunc = () => html`
|
export const demoFunc = () => html`
|
||||||
<dees-demowrapper>
|
<style>
|
||||||
<style>
|
${css`
|
||||||
${css`
|
.demo-container {
|
||||||
.demo-container {
|
display: flex;
|
||||||
display: flex;
|
flex-direction: column;
|
||||||
flex-direction: column;
|
gap: 24px;
|
||||||
gap: 24px;
|
padding: 24px;
|
||||||
padding: 24px;
|
max-width: 1200px;
|
||||||
max-width: 1200px;
|
margin: 0 auto;
|
||||||
margin: 0 auto;
|
}
|
||||||
}
|
|
||||||
|
dees-panel {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dees-panel:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-output {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message.success {
|
||||||
|
background: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.2)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(142.1 70.6% 35.3%)', 'hsl(142.1 70.6% 65.3%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message.error {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 72.2% 50.6% / 0.2)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 72.2% 40.6%)', 'hsl(0 72.2% 60.6%)')};
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="demo-container">
|
||||||
|
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||||
|
const form = elementArg.querySelector('dees-form') as DeesForm;
|
||||||
|
const outputDiv = elementArg.querySelector('.form-output');
|
||||||
|
|
||||||
|
if (form && outputDiv) {
|
||||||
|
form.addEventListener('formData', async (eventArg: CustomEvent) => {
|
||||||
|
const data = eventArg.detail.data;
|
||||||
|
console.log('Form submitted with data:', data);
|
||||||
|
|
||||||
|
// Show processing state
|
||||||
|
form.setStatus('pending', 'Processing your registration...');
|
||||||
|
outputDiv.innerHTML = `<strong>Submitted Data:</strong>\n${JSON.stringify(data, null, 2)}`;
|
||||||
|
|
||||||
|
// Simulate API call
|
||||||
|
await domtools.plugins.smartdelay.delayFor(2000);
|
||||||
|
|
||||||
|
// Show success
|
||||||
|
form.setStatus('success', 'Registration completed successfully!');
|
||||||
|
|
||||||
|
// Reset form after delay
|
||||||
|
await domtools.plugins.smartdelay.delayFor(2000);
|
||||||
|
form.reset();
|
||||||
|
outputDiv.innerHTML = '<em>Form has been reset</em>';
|
||||||
|
});
|
||||||
|
|
||||||
.demo-section {
|
// Track individual field changes
|
||||||
background: #f8f9fa;
|
const inputs = form.querySelectorAll('dees-input-text, dees-input-dropdown, dees-input-checkbox');
|
||||||
border-radius: 8px;
|
inputs.forEach((input) => {
|
||||||
padding: 24px;
|
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"
|
||||||
|
label="First Name"
|
||||||
|
.description=${'Your given name'}
|
||||||
|
></dees-input-text>
|
||||||
|
|
||||||
|
<dees-input-text
|
||||||
|
.required=${true}
|
||||||
|
key="lastName"
|
||||||
|
label="Last Name"
|
||||||
|
></dees-input-text>
|
||||||
|
|
||||||
|
<dees-input-text
|
||||||
|
.required=${true}
|
||||||
|
key="email"
|
||||||
|
label="Email Address"
|
||||||
|
.description=${'We will use this to contact you'}
|
||||||
|
></dees-input-text>
|
||||||
|
|
||||||
|
<dees-input-dropdown
|
||||||
|
.required=${true}
|
||||||
|
key="country"
|
||||||
|
.label=${'Country'}
|
||||||
|
.options=${[
|
||||||
|
{ option: 'United States', key: 'us' },
|
||||||
|
{ option: 'Canada', key: 'ca' },
|
||||||
|
{ option: 'Germany', key: 'de' },
|
||||||
|
{ option: 'France', key: 'fr' },
|
||||||
|
{ option: 'United Kingdom', key: 'uk' },
|
||||||
|
]}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
|
||||||
|
<dees-input-text
|
||||||
|
.required=${true}
|
||||||
|
key="password"
|
||||||
|
label="Password"
|
||||||
|
isPasswordBool
|
||||||
|
.description=${'Minimum 8 characters'}
|
||||||
|
></dees-input-text>
|
||||||
|
|
||||||
|
<dees-input-checkbox
|
||||||
|
.required=${true}
|
||||||
|
key="terms"
|
||||||
|
label="I agree to the Terms and Conditions"
|
||||||
|
></dees-input-checkbox>
|
||||||
|
|
||||||
|
<dees-input-checkbox
|
||||||
|
key="newsletter"
|
||||||
|
label="Send me promotional emails"
|
||||||
|
.value=${true}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
|
||||||
|
<dees-form-submit>Create Account</dees-form-submit>
|
||||||
|
</dees-form>
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
<div class="form-output">
|
||||||
.demo-section {
|
<em>Submit the form to see the collected data...</em>
|
||||||
background: #1a1a1a;
|
</div>
|
||||||
}
|
</dees-panel>
|
||||||
}
|
</dees-demowrapper>
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-container {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.form-container {
|
|
||||||
background: #222;
|
|
||||||
border-color: #333;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal-form {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="demo-container">
|
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||||
<div class="demo-section">
|
const form = elementArg.querySelector('dees-form') as DeesForm;
|
||||||
<h3>Complete Form Example</h3>
|
|
||||||
<p>A comprehensive form with various input types, validation, and form submission handling</p>
|
|
||||||
|
|
||||||
<div class="form-container">
|
|
||||||
<dees-form
|
|
||||||
@formData=${async (eventArg) => {
|
|
||||||
const form: DeesForm = eventArg.currentTarget;
|
|
||||||
form.setStatus('pending', 'Processing...');
|
|
||||||
await domtools.plugins.smartdelay.delayFor(2000);
|
|
||||||
form.setStatus('success', 'Form submitted successfully!');
|
|
||||||
await domtools.plugins.smartdelay.delayFor(2000);
|
|
||||||
form.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<dees-input-text
|
|
||||||
.required=${true}
|
|
||||||
key="firstName"
|
|
||||||
label="First Name"
|
|
||||||
.description=${'Your given name'}
|
|
||||||
></dees-input-text>
|
|
||||||
|
|
||||||
<dees-input-text
|
|
||||||
.required=${true}
|
|
||||||
key="lastName"
|
|
||||||
label="Last Name"
|
|
||||||
></dees-input-text>
|
|
||||||
|
|
||||||
<dees-input-text
|
|
||||||
.required=${true}
|
|
||||||
key="email"
|
|
||||||
label="Email Address"
|
|
||||||
.description=${'We will use this to contact you'}
|
|
||||||
></dees-input-text>
|
|
||||||
|
|
||||||
<dees-input-dropdown
|
|
||||||
.required=${true}
|
|
||||||
key="country"
|
|
||||||
.label=${'Country'}
|
|
||||||
.options=${[
|
|
||||||
{ option: 'United States', key: 'us' },
|
|
||||||
{ option: 'Canada', key: 'ca' },
|
|
||||||
{ option: 'Germany', key: 'de' },
|
|
||||||
{ option: 'France', key: 'fr' },
|
|
||||||
{ option: 'United Kingdom', key: 'uk' },
|
|
||||||
]}
|
|
||||||
></dees-input-dropdown>
|
|
||||||
|
|
||||||
<dees-input-text
|
|
||||||
.required=${true}
|
|
||||||
key="password"
|
|
||||||
label="Password"
|
|
||||||
isPasswordBool
|
|
||||||
.description=${'Minimum 8 characters'}
|
|
||||||
></dees-input-text>
|
|
||||||
|
|
||||||
<dees-input-checkbox
|
|
||||||
.required=${true}
|
|
||||||
key="terms"
|
|
||||||
label="I agree to the Terms and Conditions"
|
|
||||||
></dees-input-checkbox>
|
|
||||||
|
|
||||||
<dees-input-checkbox
|
|
||||||
key="newsletter"
|
|
||||||
label="Send me promotional emails"
|
|
||||||
.value=${true}
|
|
||||||
></dees-input-checkbox>
|
|
||||||
|
|
||||||
<dees-form-submit>Create Account</dees-form-submit>
|
|
||||||
</dees-form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="demo-section">
|
if (form) {
|
||||||
<h3>Horizontal Form Layout</h3>
|
// Track horizontal layout behavior
|
||||||
<p>Compact form with inputs arranged horizontally - perfect for filters and quick forms</p>
|
console.log('Horizontal form layout active');
|
||||||
|
|
||||||
<div class="form-container">
|
// Monitor filter changes
|
||||||
<dees-form horizontal-layout>
|
form.addEventListener('formData', (event: CustomEvent) => {
|
||||||
<dees-input-text
|
const filters = event.detail.data;
|
||||||
key="search"
|
console.log('Filter applied:', filters);
|
||||||
label="Search"
|
|
||||||
></dees-input-text>
|
// Simulate search
|
||||||
|
const resultsCount = Math.floor(Math.random() * 100) + 1;
|
||||||
<dees-input-dropdown
|
console.log(`Found ${resultsCount} results with filters:`, filters);
|
||||||
key="category"
|
});
|
||||||
.label=${'Category'}
|
|
||||||
.enableSearch=${false}
|
// Setup real-time filter updates
|
||||||
.options=${[
|
const inputs = form.querySelectorAll('[key]');
|
||||||
{ option: 'All', key: 'all' },
|
inputs.forEach((input) => {
|
||||||
{ option: 'Products', key: 'products' },
|
input.addEventListener('changeSubject', async () => {
|
||||||
{ option: 'Services', key: 'services' },
|
// Get current form data
|
||||||
{ option: 'Support', key: 'support' },
|
const formData = await form.collectFormData();
|
||||||
]}
|
console.log('Live filter update:', formData);
|
||||||
></dees-input-dropdown>
|
});
|
||||||
|
});
|
||||||
<dees-input-dropdown
|
}
|
||||||
key="sort"
|
}}>
|
||||||
.label=${'Sort By'}
|
<dees-panel .heading="Horizontal Form Layout" .description="Compact form with inputs arranged horizontally - perfect for filters and quick forms">
|
||||||
.enableSearch=${false}
|
<dees-form horizontal-layout>
|
||||||
.options=${[
|
<dees-input-text
|
||||||
{ option: 'Newest', key: 'newest' },
|
key="search"
|
||||||
{ option: 'Popular', key: 'popular' },
|
label="Search"
|
||||||
{ option: 'Price: Low to High', key: 'price_asc' },
|
placeholder="Enter keywords..."
|
||||||
{ option: 'Price: High to Low', key: 'price_desc' },
|
></dees-input-text>
|
||||||
]}
|
|
||||||
></dees-input-dropdown>
|
<dees-input-dropdown
|
||||||
|
key="category"
|
||||||
<dees-input-checkbox
|
.label=${'Category'}
|
||||||
key="inStock"
|
.enableSearch=${false}
|
||||||
label="In Stock Only"
|
.options=${[
|
||||||
.value=${true}
|
{ option: 'All', key: 'all' },
|
||||||
></dees-input-checkbox>
|
{ option: 'Products', key: 'products' },
|
||||||
</dees-form>
|
{ option: 'Services', key: 'services' },
|
||||||
</div>
|
{ option: 'Support', key: 'support' },
|
||||||
</div>
|
]}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
|
||||||
|
<dees-input-dropdown
|
||||||
|
key="sort"
|
||||||
|
.label=${'Sort By'}
|
||||||
|
.enableSearch=${false}
|
||||||
|
.options=${[
|
||||||
|
{ option: 'Newest', key: 'newest' },
|
||||||
|
{ option: 'Popular', key: 'popular' },
|
||||||
|
{ option: 'Price: Low to High', key: 'price_asc' },
|
||||||
|
{ option: 'Price: High to Low', key: 'price_desc' },
|
||||||
|
]}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
|
||||||
|
<dees-input-checkbox
|
||||||
|
key="inStock"
|
||||||
|
label="In Stock Only"
|
||||||
|
.value=${true}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</dees-form>
|
||||||
|
</dees-panel>
|
||||||
|
</dees-demowrapper>
|
||||||
|
|
||||||
|
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||||
|
const form = elementArg.querySelector('dees-form') as DeesForm;
|
||||||
|
const statusDiv = elementArg.querySelector('#status-display');
|
||||||
|
|
||||||
<div class="demo-section">
|
if (form) {
|
||||||
<h3>Advanced Form Features</h3>
|
form.addEventListener('formData', async (eventArg: CustomEvent) => {
|
||||||
<p>Form with specialized input types and complex validation</p>
|
const data = eventArg.detail.data;
|
||||||
|
console.log('Advanced form data:', data);
|
||||||
|
|
||||||
|
// Show validation in progress
|
||||||
|
form.setStatus('pending', 'Validating your information...');
|
||||||
|
|
||||||
|
// Simulate validation
|
||||||
|
await domtools.plugins.smartdelay.delayFor(1500);
|
||||||
|
|
||||||
|
// Check IBAN validity (simple check)
|
||||||
|
if (data.iban && data.iban.length > 15) {
|
||||||
|
form.setStatus('success', 'Application submitted successfully!');
|
||||||
|
|
||||||
|
if (statusDiv) {
|
||||||
|
statusDiv.className = 'status-message success';
|
||||||
|
statusDiv.textContent = '✓ Your application has been submitted. We will contact you soon.';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form.setStatus('error', 'Please check your IBAN');
|
||||||
|
|
||||||
|
if (statusDiv) {
|
||||||
|
statusDiv.className = 'status-message error';
|
||||||
|
statusDiv.textContent = '✗ Invalid IBAN format. Please check and try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Form data logged:', data);
|
||||||
|
});
|
||||||
|
|
||||||
<div class="form-container">
|
// Monitor file uploads
|
||||||
<dees-form
|
const fileUpload = form.querySelector('dees-input-fileupload');
|
||||||
@formData=${async (eventArg) => {
|
if (fileUpload) {
|
||||||
const form: DeesForm = eventArg.currentTarget;
|
fileUpload.addEventListener('change', (event: any) => {
|
||||||
const data = eventArg.detail.data;
|
const files = event.detail?.files || [];
|
||||||
console.log('Form data:', data);
|
console.log(`${files.length} file(s) selected for upload`);
|
||||||
form.setStatus('success', 'Data logged to console!');
|
});
|
||||||
}}
|
}
|
||||||
>
|
}
|
||||||
<dees-input-iban
|
}}>
|
||||||
key="iban"
|
<dees-panel .heading="Advanced Form Features" .description="Form with specialized input types and complex validation">
|
||||||
label="IBAN"
|
<dees-form>
|
||||||
.required=${true}
|
<dees-input-iban
|
||||||
></dees-input-iban>
|
key="iban"
|
||||||
|
label="IBAN"
|
||||||
<dees-input-phone
|
.required=${true}
|
||||||
key="phone"
|
></dees-input-iban>
|
||||||
label="Phone Number"
|
|
||||||
.required=${true}
|
<dees-input-phone
|
||||||
></dees-input-phone>
|
key="phone"
|
||||||
|
label="Phone Number"
|
||||||
<dees-input-multitoggle
|
.required=${true}
|
||||||
key="preferences"
|
></dees-input-phone>
|
||||||
.label=${'Notification Preferences'}
|
|
||||||
.options=${['Email', 'SMS', 'Push', 'In-App']}
|
<dees-input-multitoggle
|
||||||
.selectedOption=${'Email'}
|
key="preferences"
|
||||||
></dees-input-multitoggle>
|
.label=${'Notification Preferences'}
|
||||||
|
.options=${['Email', 'SMS', 'Push', 'In-App']}
|
||||||
<dees-input-multiselect
|
.selectedOption=${'Email'}
|
||||||
key="interests"
|
></dees-input-multitoggle>
|
||||||
.label=${'Areas of Interest'}
|
|
||||||
.options=${[
|
<dees-input-multiselect
|
||||||
{ option: 'Technology', key: 'tech' },
|
key="interests"
|
||||||
{ option: 'Design', key: 'design' },
|
.label=${'Areas of Interest'}
|
||||||
{ option: 'Business', key: 'business' },
|
.options=${[
|
||||||
{ option: 'Marketing', key: 'marketing' },
|
{ option: 'Technology', key: 'tech' },
|
||||||
{ option: 'Sales', key: 'sales' },
|
{ option: 'Design', key: 'design' },
|
||||||
]}
|
{ option: 'Business', key: 'business' },
|
||||||
></dees-input-multiselect>
|
{ option: 'Marketing', key: 'marketing' },
|
||||||
|
{ option: 'Sales', key: 'sales' },
|
||||||
<dees-input-fileupload
|
]}
|
||||||
key="documents"
|
></dees-input-multiselect>
|
||||||
.label=${'Upload Documents'}
|
|
||||||
.description=${'PDF, DOC, or DOCX files up to 10MB'}
|
<dees-input-fileupload
|
||||||
></dees-input-fileupload>
|
key="documents"
|
||||||
|
.label=${'Upload Documents'}
|
||||||
<dees-form-submit>Submit Application</dees-form-submit>
|
.description=${'PDF, DOC, or DOCX files up to 10MB'}
|
||||||
</dees-form>
|
></dees-input-fileupload>
|
||||||
</div>
|
|
||||||
</div>
|
<dees-form-submit>Submit Application</dees-form-submit>
|
||||||
</div>
|
</dees-form>
|
||||||
</dees-demowrapper>
|
|
||||||
`;
|
<div id="status-display"></div>
|
||||||
|
</dees-panel>
|
||||||
|
</dees-demowrapper>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -9,29 +9,31 @@ import {
|
|||||||
import * as domtools from '@design.estate/dees-domtools';
|
import * as domtools from '@design.estate/dees-domtools';
|
||||||
|
|
||||||
import { DeesInputCheckbox } from './dees-input-checkbox.js';
|
import { DeesInputCheckbox } from './dees-input-checkbox.js';
|
||||||
|
import { DeesInputDatepicker } from './dees-input-datepicker/index.js';
|
||||||
import { DeesInputText } from './dees-input-text.js';
|
import { DeesInputText } from './dees-input-text.js';
|
||||||
import { DeesInputQuantitySelector } from './dees-input-quantityselector.js';
|
import { DeesInputQuantitySelector } from './dees-input-quantityselector.js';
|
||||||
import { DeesInputRadio } from './dees-input-radio.js';
|
import { DeesInputRadiogroup } from './dees-input-radiogroup.js';
|
||||||
import { DeesInputDropdown } from './dees-input-dropdown.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 { DeesInputIban } from './dees-input-iban.js';
|
||||||
import { DeesInputMultitoggle } from './dees-input-multitoggle.js';
|
import { DeesInputMultitoggle } from './dees-input-multitoggle.js';
|
||||||
import { DeesInputPhone } from './dees-input-phone.js';
|
import { DeesInputPhone } from './dees-input-phone.js';
|
||||||
import { DeesInputTypelist } from './dees-input-typelist.js';
|
import { DeesInputTypelist } from './dees-input-typelist.js';
|
||||||
import { DeesFormSubmit } from './dees-form-submit.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';
|
import { demoFunc } from './dees-form.demo.js';
|
||||||
|
|
||||||
// Unified set for form input types
|
// Unified set for form input types
|
||||||
const FORM_INPUT_TYPES = [
|
const FORM_INPUT_TYPES = [
|
||||||
DeesInputCheckbox,
|
DeesInputCheckbox,
|
||||||
|
DeesInputDatepicker,
|
||||||
DeesInputDropdown,
|
DeesInputDropdown,
|
||||||
DeesInputFileupload,
|
DeesInputFileupload,
|
||||||
DeesInputIban,
|
DeesInputIban,
|
||||||
DeesInputMultitoggle,
|
DeesInputMultitoggle,
|
||||||
DeesInputPhone,
|
DeesInputPhone,
|
||||||
DeesInputQuantitySelector,
|
DeesInputQuantitySelector,
|
||||||
DeesInputRadio,
|
DeesInputRadiogroup,
|
||||||
DeesInputText,
|
DeesInputText,
|
||||||
DeesInputTypelist,
|
DeesInputTypelist,
|
||||||
DeesTable,
|
DeesTable,
|
||||||
@@ -39,13 +41,14 @@ const FORM_INPUT_TYPES = [
|
|||||||
|
|
||||||
export type TFormInputElement =
|
export type TFormInputElement =
|
||||||
| DeesInputCheckbox
|
| DeesInputCheckbox
|
||||||
|
| DeesInputDatepicker
|
||||||
| DeesInputDropdown
|
| DeesInputDropdown
|
||||||
| DeesInputFileupload
|
| DeesInputFileupload
|
||||||
| DeesInputIban
|
| DeesInputIban
|
||||||
| DeesInputMultitoggle
|
| DeesInputMultitoggle
|
||||||
| DeesInputPhone
|
| DeesInputPhone
|
||||||
| DeesInputQuantitySelector
|
| DeesInputQuantitySelector
|
||||||
| DeesInputRadio
|
| DeesInputRadiogroup
|
||||||
| DeesInputText
|
| DeesInputText
|
||||||
| DeesInputTypelist
|
| DeesInputTypelist
|
||||||
| DeesTable<any>;
|
| DeesTable<any>;
|
||||||
@@ -132,12 +135,16 @@ export class DeesForm extends DeesElement {
|
|||||||
public async collectFormData() {
|
public async collectFormData() {
|
||||||
const children = this.getFormElements();
|
const children = this.getFormElements();
|
||||||
const valueObject: { [key: string]: string | number | boolean | any[] | File[] | { option: string; key: string; payload?: any } } = {};
|
const valueObject: { [key: string]: string | number | boolean | any[] | File[] | { option: string; key: string; payload?: any } } = {};
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
if (!child.key) {
|
if (!child.key) {
|
||||||
console.log(`form element with label "${child.label}" has no key. skipping.`);
|
console.log(`form element with label "${child.label}" has no key. skipping.`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
valueObject[child.key] = child.value;
|
valueObject[child.key] = child.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return valueObject;
|
return valueObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
import { demoFunc } from './dees-heading.demo.js';
|
import { demoFunc } from './dees-heading.demo.js';
|
||||||
|
import { cssCalSansFontFamily } from './00fonts.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -39,7 +40,7 @@ export class DeesHeading extends DeesElement {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
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; }
|
h2 { font-size: 28px; }
|
||||||
h3 { font-size: 24px; }
|
h3 { font-size: 24px; }
|
||||||
h4 { font-size: 20px; }
|
h4 { font-size: 20px; }
|
||||||
|
|||||||
@@ -40,6 +40,26 @@ export const demoFunc = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Define the functions in TS scope instead of script tags
|
// 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 searchIcons = (event: InputEvent) => {
|
||||||
const searchTerm = (event.target as HTMLInputElement).value.toLowerCase().trim();
|
const searchTerm = (event.target as HTMLInputElement).value.toLowerCase().trim();
|
||||||
// Get the demo container first, then search within it
|
// Get the demo container first, then search within it
|
||||||
@@ -111,6 +131,7 @@ export const demoFunc = () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#iconSearch {
|
#iconSearch {
|
||||||
@@ -129,6 +150,27 @@ export const demoFunc = () => {
|
|||||||
border-color: #e4002b;
|
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 {
|
dees-icon {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -239,6 +281,7 @@ export const demoFunc = () => {
|
|||||||
<div class="demoContainer">
|
<div class="demoContainer">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input type="text" id="iconSearch" placeholder="Search icons..." @input=${searchIcons}>
|
<input type="text" id="iconSearch" placeholder="Search icons..." @input=${searchIcons}>
|
||||||
|
<button class="copy-all-button" @click=${copyAllIconNames}>📋 Copy All Icon Names</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="api-note">
|
<div class="api-note">
|
||||||
@@ -258,7 +301,7 @@ export const demoFunc = () => {
|
|||||||
return html`
|
return html`
|
||||||
<div class="iconContainer fa-icon" data-name=${iconName.toLowerCase()} @click=${() => copyIconName(iconName, 'fa')}>
|
<div class="iconContainer fa-icon" data-name=${iconName.toLowerCase()} @click=${() => copyIconName(iconName, 'fa')}>
|
||||||
<dees-icon .icon=${prefixedName as IconWithPrefix} iconSize="24"></dees-icon>
|
<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>
|
<span class="copy-tooltip">Click to copy</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -279,7 +322,7 @@ export const demoFunc = () => {
|
|||||||
return html`
|
return html`
|
||||||
<div class="iconContainer lucide-icon" data-name=${iconName.toLowerCase()} @click=${() => copyIconName(iconName, 'lucide')}>
|
<div class="iconContainer lucide-icon" data-name=${iconName.toLowerCase()} @click=${() => copyIconName(iconName, 'lucide')}>
|
||||||
<dees-icon .icon=${prefixedName as IconWithPrefix} iconSize="24"></dees-icon>
|
<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>
|
<span class="copy-tooltip">Click to copy</span>
|
||||||
</div>
|
</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 '@design.estate/dees-wcctools/demotools';
|
||||||
|
import './dees-panel.js';
|
||||||
import type { DeesInputCheckbox } from './dees-input-checkbox.js';
|
import type { DeesInputCheckbox } from './dees-input-checkbox.js';
|
||||||
import './dees-button.js';
|
import './dees-button.js';
|
||||||
|
|
||||||
@@ -41,62 +42,49 @@ export const demoFunc = () => html`
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.demo-section {
|
dees-panel {
|
||||||
background: #f8f9fa;
|
margin-bottom: 24px;
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
dees-panel:last-child {
|
||||||
.demo-section {
|
margin-bottom: 0;
|
||||||
background: #1a1a1a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-section h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: #0069f2;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-section p {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.demo-section p {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-group {
|
.checkbox-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-list {
|
.horizontal-checkboxes {
|
||||||
background: #f0f0f0;
|
display: flex;
|
||||||
border-radius: 4px;
|
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;
|
padding: 16px;
|
||||||
margin-bottom: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.output-text {
|
||||||
.feature-list {
|
font-family: monospace;
|
||||||
background: #0a0a0a;
|
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 {
|
.button-group {
|
||||||
@@ -104,70 +92,112 @@ export const demoFunc = () => html`
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 16px;
|
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>
|
</style>
|
||||||
|
|
||||||
<div class="demo-container">
|
<div class="demo-container">
|
||||||
<div class="demo-section">
|
<dees-panel .title=${'Basic Checkboxes'} .subtitle=${'Simple checkbox examples with various labels'}>
|
||||||
<h3>Basic Checkboxes</h3>
|
<div class="checkbox-group">
|
||||||
<p>Standard checkbox inputs for boolean selections</p>
|
<dees-input-checkbox
|
||||||
|
.label=${'I agree to the Terms and Conditions'}
|
||||||
<dees-input-checkbox
|
.value=${true}
|
||||||
.label=${'I agree to the Terms and Conditions'}
|
.key=${'terms'}
|
||||||
.value=${true}
|
></dees-input-checkbox>
|
||||||
.key=${'terms'}
|
|
||||||
></dees-input-checkbox>
|
<dees-input-checkbox
|
||||||
|
.label=${'Subscribe to newsletter'}
|
||||||
<dees-input-checkbox
|
.value=${false}
|
||||||
.label=${'Subscribe to newsletter'}
|
.key=${'newsletter'}
|
||||||
.value=${false}
|
></dees-input-checkbox>
|
||||||
.key=${'newsletter'}
|
|
||||||
></dees-input-checkbox>
|
<dees-input-checkbox
|
||||||
|
.label=${'Enable notifications'}
|
||||||
<dees-input-checkbox
|
.value=${false}
|
||||||
.label=${'Enable notifications'}
|
.description=${'Receive email updates about your account'}
|
||||||
.required=${true}
|
.key=${'notifications'}
|
||||||
.key=${'notifications'}
|
></dees-input-checkbox>
|
||||||
></dees-input-checkbox>
|
</div>
|
||||||
</div>
|
</dees-panel>
|
||||||
|
|
||||||
<div class="demo-section">
|
<dees-panel .title=${'Checkbox States'} .subtitle=${'Different checkbox states and configurations'}>
|
||||||
<h3>Horizontal Layout</h3>
|
<div class="checkbox-group">
|
||||||
<p>Checkboxes arranged horizontally for compact forms</p>
|
<dees-input-checkbox
|
||||||
|
.label=${'Default state'}
|
||||||
<div class="horizontal-group">
|
.value=${false}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
|
||||||
|
<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
|
<dees-input-checkbox
|
||||||
.label=${'Option A'}
|
.label=${'Option A'}
|
||||||
|
.value=${false}
|
||||||
.layoutMode=${'horizontal'}
|
.layoutMode=${'horizontal'}
|
||||||
.key=${'optionA'}
|
.key=${'optionA'}
|
||||||
></dees-input-checkbox>
|
></dees-input-checkbox>
|
||||||
|
|
||||||
<dees-input-checkbox
|
<dees-input-checkbox
|
||||||
.label=${'Option B'}
|
.label=${'Option B'}
|
||||||
.layoutMode=${'horizontal'}
|
|
||||||
.value=${true}
|
.value=${true}
|
||||||
|
.layoutMode=${'horizontal'}
|
||||||
.key=${'optionB'}
|
.key=${'optionB'}
|
||||||
></dees-input-checkbox>
|
></dees-input-checkbox>
|
||||||
|
|
||||||
<dees-input-checkbox
|
<dees-input-checkbox
|
||||||
.label=${'Option C'}
|
.label=${'Option C'}
|
||||||
|
.value=${false}
|
||||||
.layoutMode=${'horizontal'}
|
.layoutMode=${'horizontal'}
|
||||||
.key=${'optionC'}
|
.key=${'optionC'}
|
||||||
></dees-input-checkbox>
|
></dees-input-checkbox>
|
||||||
|
|
||||||
<dees-input-checkbox
|
<dees-input-checkbox
|
||||||
.label=${'Option D'}
|
.label=${'Option D'}
|
||||||
.layoutMode=${'horizontal'}
|
|
||||||
.value=${true}
|
.value=${true}
|
||||||
|
.layoutMode=${'horizontal'}
|
||||||
.key=${'optionD'}
|
.key=${'optionD'}
|
||||||
></dees-input-checkbox>
|
></dees-input-checkbox>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dees-panel>
|
||||||
|
|
||||||
<div class="demo-section">
|
<dees-panel .title=${'Feature Selection Example'} .subtitle=${'Common use case for feature toggles with batch operations'}>
|
||||||
<h3>Feature Selection Example</h3>
|
|
||||||
<p>Common use case for feature toggles with batch operations</p>
|
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<dees-button id="select-all-btn" type="secondary">Select All</dees-button>
|
<dees-button id="select-all-btn" type="secondary">Select All</dees-button>
|
||||||
<dees-button id="clear-all-btn" type="secondary">Clear 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>
|
></dees-input-checkbox>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dees-panel>
|
||||||
|
|
||||||
<div class="demo-section">
|
<dees-panel .title=${'Privacy Settings Example'} .subtitle=${'Checkboxes in a typical form context'}>
|
||||||
<h3>States</h3>
|
<div class="form-section">
|
||||||
<p>Different checkbox states and configurations</p>
|
<h4 class="section-title">Privacy Preferences</h4>
|
||||||
|
|
||||||
<dees-input-checkbox
|
<div class="checkbox-group">
|
||||||
.label=${'Disabled Unchecked'}
|
<dees-input-checkbox
|
||||||
.disabled=${true}
|
.label=${'Share analytics data'}
|
||||||
.key=${'disabled1'}
|
.value=${true}
|
||||||
></dees-input-checkbox>
|
.description=${'Help us improve by sharing anonymous usage data'}
|
||||||
|
></dees-input-checkbox>
|
||||||
<dees-input-checkbox
|
|
||||||
.label=${'Disabled Checked'}
|
<dees-input-checkbox
|
||||||
.disabled=${true}
|
.label=${'Personalized recommendations'}
|
||||||
.value=${true}
|
.value=${true}
|
||||||
.key=${'disabled2'}
|
.description=${'Get suggestions based on your activity'}
|
||||||
></dees-input-checkbox>
|
></dees-input-checkbox>
|
||||||
|
|
||||||
<dees-input-checkbox
|
<dees-input-checkbox
|
||||||
.label=${'Required Checkbox'}
|
.label=${'Marketing communications'}
|
||||||
.required=${true}
|
.value=${false}
|
||||||
.key=${'required'}
|
.description=${'Receive promotional emails and special offers'}
|
||||||
></dees-input-checkbox>
|
></dees-input-checkbox>
|
||||||
</div>
|
|
||||||
|
<dees-input-checkbox
|
||||||
|
.label=${'Third-party integrations'}
|
||||||
|
.value=${false}
|
||||||
|
.description=${'Allow approved partners to access your data'}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dees-panel>
|
||||||
|
|
||||||
<div class="demo-section">
|
<dees-panel .title=${'Interactive Example'} .subtitle=${'Click checkboxes to see value changes'}>
|
||||||
<h3>Real-world Examples</h3>
|
|
||||||
<p>Common checkbox patterns in applications</p>
|
|
||||||
|
|
||||||
<div class="checkbox-group">
|
<div class="checkbox-group">
|
||||||
<dees-input-checkbox
|
<dees-input-checkbox
|
||||||
.label=${'Remember me on this device'}
|
.label=${'Feature toggle'}
|
||||||
.value=${true}
|
|
||||||
.key=${'rememberMe'}
|
|
||||||
></dees-input-checkbox>
|
|
||||||
|
|
||||||
<dees-input-checkbox
|
|
||||||
.label=${'Make my profile public'}
|
|
||||||
.value=${false}
|
.value=${false}
|
||||||
.key=${'publicProfile'}
|
@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>
|
||||||
|
|
||||||
<dees-input-checkbox
|
<dees-input-checkbox
|
||||||
.label=${'Allow others to find me by email'}
|
.label=${'Debug mode'}
|
||||||
.value=${false}
|
.value=${false}
|
||||||
.key=${'findByEmail'}
|
@changeSubject=${(event: CustomEvent) => {
|
||||||
></dees-input-checkbox>
|
const output = document.querySelector('#debug-output');
|
||||||
|
if (output && event.detail) {
|
||||||
<dees-input-checkbox
|
const isChecked = event.detail.getValue();
|
||||||
.label=${'Send me product updates and announcements'}
|
output.textContent = `Debug mode: ${isChecked ? 'ON' : 'OFF'}`;
|
||||||
.value=${true}
|
}
|
||||||
.key=${'productUpdates'}
|
}}
|
||||||
></dees-input-checkbox>
|
></dees-input-checkbox>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</dees-demowrapper>
|
</dees-demowrapper>
|
||||||
`;
|
`;
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import { DeesInputBase } from './dees-input-base.js';
|
import { DeesInputBase } from './dees-input-base.js';
|
||||||
import { demoFunc } from './dees-input-checkbox.demo.js';
|
import { demoFunc } from './dees-input-checkbox.demo.js';
|
||||||
|
import { cssGeistFontFamily } from './00fonts.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -27,6 +28,9 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
|
|||||||
})
|
})
|
||||||
public value: boolean = false;
|
public value: boolean = false;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
public indeterminate: boolean = false;
|
||||||
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -44,79 +48,106 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
|
|||||||
:host {
|
:host {
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
font-family: ${cssGeistFontFamily};
|
||||||
:host(:hover) {
|
|
||||||
filter: ${cssManager.bdTheme('brightness(0.95)', 'brightness(1.1)')};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.maincontainer {
|
.maincontainer {
|
||||||
padding: 5px 0px;
|
display: inline-flex;
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
align-items: flex-start;
|
||||||
}
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
.maincontainer:hover {
|
user-select: none;
|
||||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
transition: all 0.15s ease;
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-bottom: 1px solid #e4002b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
transition: all 0.1s;
|
position: relative;
|
||||||
box-sizing: border-box;
|
height: 18px;
|
||||||
border: 1px solid ${cssManager.bdTheme('#CCC', '#999')};
|
width: 18px;
|
||||||
border-radius: 2px;
|
flex-shrink: 0;
|
||||||
height: 24px;
|
border-radius: 4px;
|
||||||
width: 24px;
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||||
display: inline-block;
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||||
background: ${cssManager.bdTheme('#fafafa', '#222')};
|
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 {
|
.checkbox.selected {
|
||||||
background: #0050b9;
|
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||||
border: 1px solid #0050b9;
|
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox.selected .checkmark {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox .checkmark svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
stroke: white;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state */
|
||||||
|
.maincontainer.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox.disabled {
|
.checkbox.disabled {
|
||||||
background: none;
|
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||||
border: 1px dashed ${cssManager.bdTheme('#666666', '#666666')};
|
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox .checkmark {
|
/* Label */
|
||||||
display: inline-block;
|
.label-container {
|
||||||
width: 22px;
|
display: flex;
|
||||||
height: 22px;
|
flex-direction: column;
|
||||||
-ms-transform: rotate(45deg); /* IE 9 */
|
gap: 2px;
|
||||||
-webkit-transform: rotate(45deg); /* Chrome, Safari, Opera */
|
flex: 1;
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox .checkmark_stem {
|
.checkbox-label {
|
||||||
position: absolute;
|
font-size: 14px;
|
||||||
width: 3px;
|
font-weight: 500;
|
||||||
height: 9px;
|
line-height: 20px;
|
||||||
background-color: #fff;
|
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||||
left: 11px;
|
transition: color 0.15s ease;
|
||||||
top: 6px;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox .checkmark_kick {
|
.maincontainer:hover .checkbox-label {
|
||||||
position: absolute;
|
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||||
width: 3px;
|
|
||||||
height: 3px;
|
|
||||||
background-color: #fff;
|
|
||||||
left: 8px;
|
|
||||||
top: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox.disabled .checkmark_stem, .checkbox.disabled .checkmark_kick {
|
.maincontainer.disabled:hover .checkbox-label {
|
||||||
background-color: ${cssManager.bdTheme('#333', '#fff')};
|
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
/* Description */
|
||||||
padding: 4px;
|
.description-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@@ -124,19 +155,35 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
|
|||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<div class="maincontainer" @click="${this.toggleSelected}">
|
<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
|
${this.value
|
||||||
? html`
|
? html`
|
||||||
<span class="checkmark">
|
<span class="checkmark">
|
||||||
<div class="checkmark_stem"></div>
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<div class="checkmark_kick"></div>
|
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
`
|
`
|
||||||
: html``}
|
: this.indeterminate
|
||||||
|
? html`
|
||||||
|
<span class="checkmark">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5 12H19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
: html``}
|
||||||
|
</div>
|
||||||
|
<div class="label-container">
|
||||||
|
${this.label ? html`<div class="checkbox-label">${this.label}</div>` : ''}
|
||||||
|
${this.description ? html`<div class="description-text">${this.description}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dees-label .label=${this.label}></dees-label>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -169,4 +216,11 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
|
|||||||
(checkboxDiv as any).focus();
|
(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>
|
||||||
|
`;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user